Hotel Management System
Rooms, bookings, services, billing. Multi-aggregate transactional design; the closest OOD prompt to a real product.
Context#
A hotel sells rooms by the night, attaches optional services (room-service, laundry, mini-bar) to a stay, and issues a single bill at check-out that aggregates room charges, taxes, and service line-items. The booking flow has three commits that must all succeed or all be undone — room held, payment captured, confirmation issued — and the room state across a single guest’s stay walks a five-step lifecycle. That makes Hotel Management the closest OOD prompt to a real shipped product: every decision has a counterpart in a system somebody is operating today.
The interviewer’s hidden objectives, in roughly the order they will be tested:
- Can you identify aggregates — Booking, Room, Guest, Bill — and keep their state changes independent until the booking commits?
- Can you model the room lifecycle (Available → Held → Occupied → Dirty → Available) without conflating it with the booking lifecycle (Pending → Confirmed → CheckedIn → CheckedOut)?
- Can you apply the State pattern on
Bookingand the Strategy pattern on pricing without inventing a god class? - Can you narrate the two-phase nature of a booking — search-and-hold then commit — without hand-waving the failure modes?
- Can you draw the billing aggregate so that adding a new service tomorrow is a one-class change?
Requirements (functional and non-functional)#
Clarifying scope is decisive. Below is the cut that fits a 45-minute round; flag anything outside it.
Functional — in scope.
- A hotel has multiple room types (Standard, Deluxe, Suite) with distinct nightly rates and capacities.
- A guest can search for available rooms across a date range, filter by type, and book a room — yielding a confirmed booking with a confirmation number.
- The system supports check-in and check-out. On check-out it produces a bill combining room nights, attached services, and tax.
- During a stay, a guest can request services (room-service order, laundry pickup) which post line-items to the bill.
- Cancellation is allowed up to a configured deadline with a refund policy.
- Pricing varies by season (peak / off-peak / shoulder); a strategy interface owns the rate computation.
Functional — out of scope (called out explicitly). Multi-property chains, loyalty programs, channel managers (third-party OTAs), housekeeping schedules, dynamic pricing, dispute resolution, group bookings, weddings/events. These would each justify their own round.
Non-functional.
- A single property with up to ~500 rooms. In-process data structures plus a thin repository seam suffice.
- Booking creation must be atomic with respect to room availability — no double-booking under concurrent requests.
- The room-assignment decision and bill computation should each return in under 200 ms.
- Audit trail: every booking state change is observable (foreshadows the Observer hook).
Use case diagram#
┌───────────────┐ │ Guest │ └───────┬───────┘ │ ┌───────────────┼───────────────┬───────────────┐ ▼ ▼ ▼ ▼ [search rooms] [book room] [request service] [check out] │ │ │ │ ▼ ▼ ▼ ▼ ┌────────────────────────────────────────────────────┐ │ Hotel Management System │ └────────────────┬───────────────────┬───────────────┘ ▲ ▲ │ │ ┌───────────────┐ ┌────────────────┐ │ Receptionist │ │ Manager │ ├───────────────┤ ├────────────────┤ │ check-in │ │ configure rates│ │ assign room │ │ audit revenue │ │ cancel booking│ │ block rooms │ └───────────────┘ └────────────────┘Three actors. The guest’s flows are the primary scope; the receptionist’s check-in is on the critical path; the manager’s flows stay implicit but worth naming.
Class diagram#
┌───────────────────────────┐ │ Hotel │ ├───────────────────────────┤ │ rooms : List<Room> │ │ bookings : BookingRepo │ │ pricing : PricingStrategy│ │ tax : TaxPolicy │ ├───────────────────────────┤ │ search(range, type) │ │ book(guest, room, range) │ │ checkIn(bookingId) │ │ checkOut(bookingId) │ └───────────┬───────────────┘ │ 1..* ▼ ┌───────────────────────────┐ │ Room │ ◇──── state: RoomState ├───────────────────────────┤ │ number, type : RoomType │ Available / Held / │ capacity │ Occupied / Dirty / ├───────────────────────────┤ OutOfOrder │ hold(range) │ │ occupy() / vacate() │ │ markClean() │ └───────────────────────────┘
┌──────────────────┐ ┌────────────────────┐ ┌──────────────────────┐ │ Booking │ │ Guest │ │ PricingStrategy │◁── Seasonal, ├──────────────────┤ ├────────────────────┤ ├──────────────────────┤ Flat, │ id, guest │ │ id, name │ │ quoteFor(room, range)│ Promo │ room, range │ │ contact, id-proof │ └──────────────────────┘ │ status: State │ └────────────────────┘ │ bill : Bill │ └────────┬─────────┘ │ ▼ ┌──────────────────┐ ┌───────────────────┐ ┌──────────────────────┐ │ Bill │ │ ServiceItem │ │ PaymentStrategy │◁── Card, Cash, UPI ├──────────────────┤ ├───────────────────┤ ├──────────────────────┤ │ lineItems │◇───▶│ kind, amount, at │ │ pay(amount) : Receipt│ │ tax, total │ └───────────────────┘ └──────────────────────┘ │ add(item) │ │ finalise() │ └──────────────────┘Three patterns are doing the load-bearing work:
- State pattern on
Booking.status—Pending → Confirmed → CheckedIn → CheckedOut, plus aCancelledbranch reachable fromPendingandConfirmed. Make the legal transitions explicit; reject illegal ones withIllegalStateException. - Strategy pattern on
PricingStrategyandPaymentStrategy. Seasonal pricing is the textbook case — the hotel does not switch on a season enum. - Composite / aggregate on
Bill— room charges and service items are line-items of the same kind; the bill totals them uniformly.
What is deliberately not in the diagram:
- Two separate state machines.
Room.stateandBooking.statusare distinct lifecycles. The Booking owns the transactional contract with the guest; the Room owns the physical occupancy state. Conflating them is the most common mistake in this prompt —Room.state = Bookedis a leak of the booking’s concern into the room. - No
BookingManagergod class. The hotel orchestrates; the booking aggregate owns its state transitions; the bill owns its line-items.
Sequence diagram (key flows)#
Search and book — the multi-step transaction:
Guest Hotel Room Booking Pricing Payment │ search(range, type)│ │ │ │ │──────────────────► │ │ │ │ │ │ (filter rooms by type & range)│ │ │ List<Room> │ │ │ │ │◄───────────────────│ │ │ │ │ book(room, range) │ │ │ │ │──────────────────► │ │ │ │ │ │ hold(range) │ │ │ │ │─────────────►│ │ │ │ │ │ │ │ │ │ new Booking(PENDING) │ │ │ │──────────────────────────────►│ │ │ │ quoteFor(room, range) │ │ │ │──────────────────────────────────────────────►│ │ │ quote │ │ │◄──────────────────────────────────────────────│ │ │ pay(quote) │ │ │──────────────────────────────────────────────►│ │ │ receipt │ │ │◄──────────────────────────────────────────────│ │ │ confirm() │ │ │ │ │──────────────────────────────►│ │ │ Booking(CONFIRMED)│ │ │ │ │◄───────────────────│ │ │ │If pay() fails the hotel calls room.release(range) and transitions the booking to Cancelled. This is the compensating action — there is no distributed transaction, just a deterministic rollback.
Check-out and bill the stay:
Guest Hotel Booking Bill Room Payment │ checkOut(id)│ │ │ │ │ │────────────►│ │ │ │ │ │ │ load(id) │ │ │ │ │ │────────────►│ │ │ │ │ │ finalise() │ │ │ │ │─────────────────────────────►│ │ │ │ │ total │ │ │ │ │◄─────────────────────────────│ │ │ │ │ pay(balance) │ │ │ │ │─────────────────────────────────────────────────────────────►│ │ │ receipt │ │ │◄─────────────────────────────────────────────────────────────│ │ │ markCheckedOut() │ │ │ │ │────────────►│ │ │ │ │ │ vacate() │ │ │ │────────────────────────────────────────────►│ │ │ Bill │ │ │ │ │ │◄────────────│ │ │ │ │Activity diagram (for non-trivial state)#
The booking lifecycle:
┌─────────┐ │ start │ └────┬────┘ ▼ ┌────────────┐ │ Pending │── payment captured ─────► [hold valid?] └─────┬──────┘ │ │ payment failed yes │ no ▼ │ ▼ ┌────────────┐ │ ┌────────────┐ │ Cancelled │◄─── cancel pre-arrival ────────│ │ Cancelled │ └────────────┘ │ └────────────┘ ▼ ┌────────────┐ │ Confirmed │── cancel within deadline ──► Cancelled └─────┬──────┘ │ check-in ▼ ┌────────────┐ │ CheckedIn │── service requests post to Bill (loop) └─────┬──────┘ │ check-out ▼ ┌────────────┐ │ CheckedOut │ (terminal) └────────────┘CheckedOut is terminal. Cancelled is reachable from Pending and Confirmed but not from CheckedIn — a guest who has occupied the room must check out, not cancel. The deadline on cancellation is a strategy parameter; the state machine itself is policy-agnostic.
Java implementation#
A representative slice. The booking aggregate, room state, and the bill composition are shown; pricing and payment are sketched.
public enum RoomType { STANDARD, DELUXE, SUITE }public enum RoomState { AVAILABLE, HELD, OCCUPIED, DIRTY, OUT_OF_ORDER }public enum BookingStatus { PENDING, CONFIRMED, CHECKED_IN, CHECKED_OUT, CANCELLED }
public final class DateRange { private final LocalDate from, to; // inclusive from, exclusive to public DateRange(LocalDate from, LocalDate to) { if (!from.isBefore(to)) throw new IllegalArgumentException("from must precede to"); this.from = from; this.to = to; } public boolean overlaps(DateRange other) { return !this.to.isBefore(other.from) && !other.to.isBefore(this.from) && !(this.to.equals(other.from)) && !(other.to.equals(this.from)); } public long nights() { return ChronoUnit.DAYS.between(from, to); } public LocalDate from() { return from; } public LocalDate to() { return to; }}
public final class Room { private final String number; private final RoomType type; private final int capacity; private RoomState state = RoomState.AVAILABLE; private final List<DateRange> holds = new ArrayList<>();
public Room(String number, RoomType type, int capacity) { this.number = number; this.type = type; this.capacity = capacity; }
public synchronized boolean isFreeFor(DateRange r) { if (state == RoomState.OUT_OF_ORDER) return false; return holds.stream().noneMatch(h -> h.overlaps(r)); }
public synchronized void hold(DateRange r) { if (!isFreeFor(r)) throw new IllegalStateException("Room " + number + " not free for " + r); holds.add(r); }
public synchronized void release(DateRange r) { holds.remove(r); } public synchronized void occupy() { this.state = RoomState.OCCUPIED; } public synchronized void vacate() { this.state = RoomState.DIRTY; } public synchronized void markClean() { this.state = RoomState.AVAILABLE; }
public RoomType type() { return type; } public String number() { return number; }}
public interface PricingStrategy { Money quoteFor(Room room, DateRange range);}
public final class SeasonalPricing implements PricingStrategy { private final Map<RoomType, Money> base; private final Function<LocalDate, Double> seasonMultiplier; // 0.8 off-peak, 1.0 shoulder, 1.5 peak
public SeasonalPricing(Map<RoomType, Money> base, Function<LocalDate, Double> seasonMultiplier) { this.base = base; this.seasonMultiplier = seasonMultiplier; }
public Money quoteFor(Room room, DateRange range) { Money total = Money.zero(); LocalDate d = range.from(); while (d.isBefore(range.to())) { total = total.plus(base.get(room.type()).times(seasonMultiplier.apply(d))); d = d.plusDays(1); } return total; }}
public final class ServiceItem { public enum Kind { ROOM_SERVICE, LAUNDRY, MINIBAR } private final Kind kind; private final Money amount; private final Instant at; public ServiceItem(Kind k, Money a, Instant t) { kind = k; amount = a; at = t; } public Money amount() { return amount; }}
public final class Bill { private final List<LineItem> items = new ArrayList<>(); private final TaxPolicy tax; private Money total; private boolean finalised = false;
public Bill(TaxPolicy tax) { this.tax = tax; }
public void addRoomCharges(Money roomTotal) { ensureOpen(); items.add(new LineItem("Room", roomTotal)); } public void addService(ServiceItem s) { ensureOpen(); items.add(new LineItem(s.toString(), s.amount())); }
public Money finalise() { ensureOpen(); Money subtotal = items.stream().map(LineItem::amount).reduce(Money.zero(), Money::plus); this.total = subtotal.plus(tax.on(subtotal)); this.finalised = true; return total; }
private void ensureOpen() { if (finalised) throw new IllegalStateException("Bill is finalised"); }
public record LineItem(String label, Money amount) {}}
public final class Booking { private final String id; private final Guest guest; private final Room room; private final DateRange range; private final Bill bill; private BookingStatus status = BookingStatus.PENDING;
public Booking(String id, Guest g, Room r, DateRange range, TaxPolicy tax) { this.id = id; this.guest = g; this.room = r; this.range = range; this.bill = new Bill(tax); }
public void confirm() { require(BookingStatus.PENDING); status = BookingStatus.CONFIRMED; }
public void checkIn() { require(BookingStatus.CONFIRMED); room.occupy(); status = BookingStatus.CHECKED_IN; }
public Money checkOut() { require(BookingStatus.CHECKED_IN); Money total = bill.finalise(); room.vacate(); status = BookingStatus.CHECKED_OUT; return total; }
public void cancel() { if (status != BookingStatus.PENDING && status != BookingStatus.CONFIRMED) { throw new IllegalStateException("Cannot cancel from " + status); } room.release(range); status = BookingStatus.CANCELLED; }
public void addService(ServiceItem s) { require(BookingStatus.CHECKED_IN); bill.addService(s); }
private void require(BookingStatus expected) { if (status != expected) throw new IllegalStateException("Expected " + expected + ", was " + status); }
public BookingStatus status() { return status; } public Bill bill() { return bill; } public Room room() { return room; } public DateRange range() { return range; }}
public final class Hotel { private final List<Room> rooms; private final PricingStrategy pricing; private final PaymentStrategy payment; private final TaxPolicy tax; private final Map<String, Booking> bookings = new ConcurrentHashMap<>();
public Hotel(List<Room> rooms, PricingStrategy p, PaymentStrategy pay, TaxPolicy tax) { this.rooms = rooms; this.pricing = p; this.payment = pay; this.tax = tax; }
public List<Room> search(DateRange range, RoomType type) { return rooms.stream() .filter(r -> r.type() == type) .filter(r -> r.isFreeFor(range)) .toList(); }
public synchronized Booking book(Guest g, Room room, DateRange range) { room.hold(range); // 1. hold (compensable) Booking b = new Booking(UUID.randomUUID().toString(), g, room, range, tax); Money quote = pricing.quoteFor(room, range); b.bill().addRoomCharges(quote); try { payment.pay(quote); // 2. payment } catch (PaymentFailedException e) { room.release(range); // compensate b.cancel(); throw e; } b.confirm(); // 3. commit bookings.put(b.id(), b); return b; }}Notes the interviewer will look for:
- Three commits, one compensating path. Hold the room, capture payment, transition to Confirmed. Any failure rolls back the hold. This is the multi-aggregate transaction the prompt is named for.
DateRangeis its own type withoverlapson it. Passing twoLocalDateparameters everywhere is a smell; the range is a noun in the domain.Moneyis its own type. Nightly totals, taxes, refunds — none of these aredouble.Bookingenforces transitions.require(expected)keeps the state machine honest at the call site.Hotelissynchronizedonbook. Coarse correctness; 500 rooms and bookings per minute fit comfortably. Per-room locking is the next step if contention shows up.
Trade-offs and extensions#
Decisions explicitly made and what they cost:
| Decision | Why | Cost if requirements change |
|---|---|---|
RoomType as a flat enum | Three types, no per-type behaviour beyond pricing. | If a type later carries distinct behaviour (e.g. suites have multi-room layouts), promote to a class hierarchy. |
| Two state machines (Room, Booking) kept separate | They have different owners and different lifetimes. | None — this is the right separation. Conflating them is the regression. |
In-memory holds on Room | No persistence requirement was given. | Move to a RoomAvailability table with row locks once you have a database. |
Single Bill per Booking | One stay = one bill. | Splitting a bill across guests requires a Folio aggregate above Bill. |
Coarse synchronized on Hotel.book | Correctness first; throughput is well within budget. | Per-room locking, or optimistic concurrency on a version field once persisted. |
PricingStrategy interface | Seasonal today; promo codes and corporate rates tomorrow without editing Hotel. | None — it is the right shape now. |
Likely follow-up extensions and the shape of the answer:
- Group bookings. A new
GroupBookingaggregate owns a set ofBookings with shared billing and a single point of contact. Hotel-levelbookbecomesbookGroup. - Channel managers (OTAs). An
InventoryServicedecouples the room availability from booking sources; bookings arrive via an adapter per channel. The aggregate boundaries do not move. - Loyalty. A
MembershiponGuestplus aLoyaltyDiscountdecorator onPricingStrategy. No change toHotel. - Housekeeping. A
Roomalready passes throughDirty; aHousekeepingServicepolls or subscribes toRoomDirtyevents and transitionsDirty → Available. Observer pattern. - Walk-ins. A
WalkInis a booking withrange = today..tomorrowand no prior hold. The samebookpath applies; no new code.
Mock interview follow-ups#
- “What’s the state machine on
Booking?” —Pending → Confirmed → CheckedIn → CheckedOut, withCancelledreachable fromPendingandConfirmed. Show the activity diagram. - “What happens when two guests try to book the same room concurrently?” — Coarse
synchronizedonHotel.bookserialises the hold; the loser seesIllegalStateExceptionfromroom.hold(range). If the interviewer pushes, partition the lock per room. - “Where would you put observers?” — On
Bookingstate transitions, so notifications, audit, and analytics subscribe withoutHotelknowing. (Observer pattern.) - “How do refunds work on cancellation?” — Booking’s
cancel()releases the room and transitions toCancelled; aRefundPolicystrategy consulted byHotel.canceldecides the refundable amount based on the deadline. Payment integration runs the reversal. - “What changes if you add a second hotel?” — Promote
Hotelto one instance per property; introduce aChainaggregate above for shared concerns (loyalty, brand-wide pricing floors). The booking aggregate does not move. - “How would you persist this?” — One repository per aggregate root:
BookingRepository,RoomRepository,GuestRepository. Bill is owned byBookingand saved transitively. The in-memory design becomes the in-memory test double. - “What if a guest extends their stay mid-trip?” — Booking gains an
extend(DateRange newTo)method that re-holds the room for the delta, re-quotes, captures payment for the delta, and updatesrange. Same compensation pattern as the initial book.
Related#
- Parking Lot — same overall shape (lot/hotel, spot/room, ticket/booking) at a lower complexity ceiling.
- State Pattern —
Booking.statusandRoom.stateare textbook applications. - Strategy Pattern —
PricingStrategyandPaymentStrategyare the canonical use. - Observer Pattern — the answer to the “where would observers go?” follow-up.
- Approaching the OOD Interview — the meta-script behind this writeup’s structure.