Hotel Management System

Rooms, bookings, services, billing. Multi-aggregate transactional design; the closest OOD prompt to a real product.

System Intermediate
14 min read
ood case-study hotel state-pattern strategy-pattern

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 Booking and 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.statusPending → Confirmed → CheckedIn → CheckedOut, plus a Cancelled branch reachable from Pending and Confirmed. Make the legal transitions explicit; reject illegal ones with IllegalStateException.
  • Strategy pattern on PricingStrategy and PaymentStrategy. 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.state and Booking.status are 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 = Booked is a leak of the booking’s concern into the room.
  • No BookingManager god 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.
  • DateRange is its own type with overlaps on it. Passing two LocalDate parameters everywhere is a smell; the range is a noun in the domain.
  • Money is its own type. Nightly totals, taxes, refunds — none of these are double.
  • Booking enforces transitions. require(expected) keeps the state machine honest at the call site.
  • Hotel is synchronized on book. 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:

DecisionWhyCost if requirements change
RoomType as a flat enumThree 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 separateThey have different owners and different lifetimes.None — this is the right separation. Conflating them is the regression.
In-memory holds on RoomNo persistence requirement was given.Move to a RoomAvailability table with row locks once you have a database.
Single Bill per BookingOne stay = one bill.Splitting a bill across guests requires a Folio aggregate above Bill.
Coarse synchronized on Hotel.bookCorrectness first; throughput is well within budget.Per-room locking, or optimistic concurrency on a version field once persisted.
PricingStrategy interfaceSeasonal 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 GroupBooking aggregate owns a set of Bookings with shared billing and a single point of contact. Hotel-level book becomes bookGroup.
  • Channel managers (OTAs). An InventoryService decouples the room availability from booking sources; bookings arrive via an adapter per channel. The aggregate boundaries do not move.
  • Loyalty. A Membership on Guest plus a LoyaltyDiscount decorator on PricingStrategy. No change to Hotel.
  • Housekeeping. A Room already passes through Dirty; a HousekeepingService polls or subscribes to RoomDirty events and transitions Dirty → Available. Observer pattern.
  • Walk-ins. A WalkIn is a booking with range = today..tomorrow and no prior hold. The same book path applies; no new code.

Mock interview follow-ups#

  • “What’s the state machine on Booking?”Pending → Confirmed → CheckedIn → CheckedOut, with Cancelled reachable from Pending and Confirmed. Show the activity diagram.
  • “What happens when two guests try to book the same room concurrently?” — Coarse synchronized on Hotel.book serialises the hold; the loser sees IllegalStateException from room.hold(range). If the interviewer pushes, partition the lock per room.
  • “Where would you put observers?” — On Booking state transitions, so notifications, audit, and analytics subscribe without Hotel knowing. (Observer pattern.)
  • “How do refunds work on cancellation?” — Booking’s cancel() releases the room and transitions to Cancelled; a RefundPolicy strategy consulted by Hotel.cancel decides the refundable amount based on the deadline. Payment integration runs the reversal.
  • “What changes if you add a second hotel?” — Promote Hotel to one instance per property; introduce a Chain aggregate 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 by Booking and 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 updates range. Same compensation pattern as the initial book.
  • Parking Lot — same overall shape (lot/hotel, spot/room, ticket/booking) at a lower complexity ceiling.
  • State PatternBooking.status and Room.state are textbook applications.
  • Strategy PatternPricingStrategy and PaymentStrategy are 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.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.