Skip to content

Design a hotel management system

TL;DR

  • You build the front desk: search availability for a date range, reserve against a room type, pay, check in to a physical room, check out with an invoice, and hand the room to housekeeping.
  • Three decisions carry the interview: half-open date ranges so a departure and an arrival on the same day do not collide; inventory as a per-night counter per room type, guarded by one lock per type, so overlapping ranges are an integer comparison rather than an interval search; assign the physical room at check-in, not at booking, so a maintenance closure never invalidates a reservation made three months ago.
  • Patterns that earn their place: Builder (hotel construction), Strategy (pricing, cancellation), State (room, reservation), Observer (housekeeping), Facade (FrontDeskService).

Problem statement

"Design a hotel management system. Guests search for rooms of a given type over a date range, reserve them, pay, arrive, stay, and check out with an invoice that includes extras. Rooms have housekeeping status; staff have roles. Rates change by season and cancellation is governed by a policy. Show me the classes, how you decide whether a date range is available, and what happens when two travel agents book the last suite at the same instant."

Requirements

Functional

  • Rooms with a number, a floor, a type (single, double, deluxe, suite) and a housekeeping status.
  • Availability for a room type over a date range; the answer is the free count on the tightest night.
  • Reserve one or more rooms of one or more types for a guest, all-or-nothing across every requested type and every night.
  • Pay to confirm; a replayed idempotency key must not charge twice.
  • Check in (assign physical rooms), add extras during the stay, check out (invoice with tax), and free the nights.
  • Seasonal nightly pricing and a swappable cancellation policy with refunds.
  • Housekeeping tasks raised automatically on checkout, assignable to staff with the right role.
  • Sweep no-shows so an unclaimed reservation frees the nights for walk-ins.
  • An overbooking allowance per room type, off by default.

Non-functional and constraints

  • Correct under concurrency: the per-night counters are the contended resource.
  • In-memory and single-process; the counters map one-to-one onto a table you would index by (room_type, night).
  • Deterministic and testable: the clock, the ids and the payment gateway are injected; "today" is clock.now_dt().date().

Out of scope: multi-property chains, channel managers and OTA sync, loyalty programmes, revenue management.

Clarifying questions and assumptions

Question to ask Assumption taken here
Does the guest book a room number or a room type? A type. Real hotels assign the number at the desk, and it removes an entire class of "your room broke" failures.
Is checkout day a billable night? No. Ranges are half-open [start, end), so a three-night stay is 11 → 14.
Which timezone decides "today"? The property's local day, injected via Clock. Never the guest's, never the server's.
Do we overbook? Not by default. AvailabilityService takes an allowance per type, because hotels do overbook and the interviewer will ask.
What if the room is not clean when the guest arrives? Check-in fails with NoRoomReadyError and the reservation stays CONFIRMED. Upgrading the guest is a policy, not a lock change.
Can a guest check out early? Yes. The nights go back on sale immediately; whether the guest is refunded is the cancellation policy's business, not check-out's.
Is the availability page strongly consistent? No. Search returns a snapshot; only reserve is authoritative. Say it out loud — it is why the search path can be cached.

Core entities and relationships

  • Hotel 1 → * Room. A Room is physical: number, floor, type, and a housekeeping RoomStatus. Its status answers "can a guest walk into it right now", never "is it sellable in June".
  • AvailabilityService — the sales ledger: dict[RoomType, dict[date, int]] of nights already sold, the overbooking allowance, and one lock per room type.
  • DateRange — a frozen half-open interval with nights(), nights_count and overlaps(). Every date question in the system routes through it.
  • RoomRequest — (room_type, count). A reservation is a tuple of these, which is what makes a family booking a deluxe and a suite a first-class case rather than a hack.
  • Reservation 1 → 1 guest, 1 → * RoomRequest, 1 → 0..1 Payment, and a status machine with six states.
  • Invoice 1 → * InvoiceLine — room nights plus extras, then tax.
  • FrontDeskService — the Facade the reception UI calls: search, quote, reserve, pay, check_in, add_charge, check_out, cancel, sweep_no_shows.
  • HousekeepingService and NotificationService — observers of stay events; PricingStrategy and CancellationPolicy — the two swappable rules; HotelBuilder — stepwise construction with validation.

Class diagram

Structure: physical rooms on one side, the sold-nights ledger on the other.

classDiagram
    class Hotel {
        +name: str
        +tax_rate: Decimal
        +room(number) Room
        +rooms_of(room_type) List~Room~
        +inventory() Dict~RoomType,int~
        +first_ready(room_type) Room
    }
    class HotelBuilder {
        +named(name) HotelBuilder
        +with_rooms(room_type, count, floor) HotelBuilder
        +with_tax_rate(rate) HotelBuilder
        +build() Hotel
    }
    class Room {
        +number: str
        +floor: int
        +type: RoomType
        +status: RoomStatus
        +occupy(reservation_id)
        +vacate()
        +mark_clean()
        +unassign()
    }
    class DateRange {
        +start: date
        +end: date
        +nights_count: int
        +nights() List~date~
        +overlaps(other) bool
    }
    class RoomRequest {
        +room_type: RoomType
        +count: int
    }
    class Reservation {
        +id: str
        +stay: DateRange
        +rooms: tuple
        +amount: Money
        +status: ReservationStatus
        +assigned_rooms: tuple
        +transition_to(target)
    }
    class Guest {
        +id: str
        +email: str
    }
    class Staff {
        +id: str
        +role: StaffRole
    }
    class Payment {
        +id: str
        +amount: Money
        +idempotency_key: str
        +captured: bool
    }
    class Invoice {
        +lines: tuple
        +tax: Money
        +total: Money
        +subtotal() Money
    }
    class InvoiceLine {
        +description: str
        +amount: Money
    }
    class HousekeepingTask {
        +room_number: str
        +kind: TaskKind
        +assigned_to: str
        +done: bool
    }
    HotelBuilder ..> Hotel : builds
    Hotel "1" *-- "many" Room
    Reservation "1" *-- "many" RoomRequest
    Reservation --> DateRange
    Reservation --> Guest
    Reservation "1" o-- "0..1" Payment
    Invoice "1" *-- "many" InvoiceLine
    Invoice --> Reservation
    HousekeepingTask --> Room
    HousekeepingTask --> Staff

Behaviour: one facade, one availability ledger, four injected seams.

classDiagram
    class FrontDeskService {
        -_reservations_lock: Lock
        +search(room_type, stay) int
        +quote(rooms, stay) Money
        +reserve(guest_id, rooms, stay) Reservation
        +pay(reservation_id, method, key) Reservation
        +check_in(reservation_id) tuple
        +check_out(reservation_id) Invoice
        +cancel(reservation_id) Money
        +sweep_no_shows() List~str~
    }
    class AvailabilityService {
        -_locks: Dict~RoomType,Lock~
        +types_locked(room_types)
        +capacity(room_type) int
        +available(room_type, stay) int
        +reserve(rooms, stay, reservation_id)
        +release(reservation_id) bool
        +calendar(room_type, stay) Dict~date,int~
    }
    class PricingStrategy {
        <<interface>>
        +price_night(room_type, night) Money
    }
    class SeasonalPricing
    class FlatRatePricing
    class CancellationPolicy {
        <<interface>>
        +refund(reservation, today) Money
    }
    class FreeUntilDaysBefore
    class NonRefundablePolicy
    class PaymentGateway {
        <<interface>>
        +charge(payment_id, amount, method) bool
        +refund(payment_id, amount)
    }
    class AlwaysApprovesGateway
    class StayListener {
        <<interface>>
        +on_stay_event(event, reservation, room)
    }
    class HousekeepingService {
        +create_task(room_number, kind) HousekeepingTask
        +assign(task_id, staff) HousekeepingTask
        +open_tasks() List~HousekeepingTask~
    }
    class NotificationService {
        +outbox() List~str~
    }
    class Hotel
    FrontDeskService --> AvailabilityService
    FrontDeskService --> Hotel
    FrontDeskService --> PricingStrategy
    FrontDeskService --> CancellationPolicy
    FrontDeskService --> PaymentGateway
    FrontDeskService --> "many" StayListener : notifies
    AvailabilityService --> Hotel
    PricingStrategy <|.. SeasonalPricing
    PricingStrategy <|.. FlatRatePricing
    CancellationPolicy <|.. FreeUntilDaysBefore
    CancellationPolicy <|.. NonRefundablePolicy
    PaymentGateway <|.. AlwaysApprovesGateway
    StayListener <|.. HousekeepingService
    StayListener <|.. NotificationService

Design patterns applied

Pattern Where Why it earns its place
Builder HotelBuilder Construction is genuinely stepwise (name, tax rate, N floors of rooms) and genuinely needs validation (a hotel with no rooms, duplicate numbers). build() is the only place a Hotel can be born invalid, so it is the only place that has to check.
Strategy PricingStrategy, CancellationPolicy "Now make July 50 % more expensive" and "now make the last 48 hours non-refundable" are the two follow-ups you will get. price_night is per night on purpose: a stay price then composes for free, including across a season boundary.
State Room guards, Reservation.transition_to with RESERVATION_TRANSITIONS Six reservation states and four room states with no per-state behaviour: a transition table beats six classes. The guard clauses on Room are inline because they run inside the lock.
Observer StayListener, HousekeepingService, NotificationService A checkout must create cleaning work and send an email. Neither belongs in check_out. Listeners are notified outside every lock.
Facade FrontDeskService Reception calls nine methods; pricing, locking, gateway and observers stay behind them.
Factory HousekeepingService.create_task The task kind is derived from the stay (five nights or more means a deep clean), so callers never pick a checklist. One method, no class hierarchy — say that out loud rather than inventing a TaskFactory.

What was deliberately not used: an interval tree for availability. It is the answer candidates reach for, and it is the wrong shape here. Rooms are fungible within a type, so "is this range free" collapses to "is the minimum free count over these nights at least N" — a loop over at most a few hundred integers. Reach for interval structures when the resource is not fungible (one specific meeting room, one specific vehicle).

Key flows

Reserve, pay, arrive, leave. Note where the physical room enters: at check-in, not before.

sequenceDiagram
    autonumber
    actor G as Guest
    participant FD as FrontDeskService
    participant AV as AvailabilityService
    participant H as Hotel
    participant PG as PaymentGateway
    participant HK as HousekeepingService
    G->>FD: search(DELUXE, 11 Mar to 14 Mar)
    FD->>AV: available(type, stay)
    AV-->>FD: free on the tightest night
    G->>FD: reserve(guest, 2 x DELUXE, stay)
    FD->>AV: reserve(rooms, stay, reservation_id)
    AV->>AV: lock the types in sorted order
    alt every night has room
        AV->>AV: increment the counter for each night
        FD-->>G: Reservation(PENDING)
    else any night short
        AV-->>FD: NoAvailabilityError
        FD-->>G: try other dates
    end
    G->>FD: pay(reservation_id, CARD, idempotency_key)
    FD->>PG: charge(payment_id, amount, CARD)
    PG-->>FD: approved
    FD->>FD: PENDING to CONFIRMED
    G->>FD: check_in(reservation_id)
    FD->>H: first_ready(DELUXE) under the type lock
    H-->>FD: room 201, room 202
    FD->>FD: CONFIRMED to CHECKED_IN
    G->>FD: check_out(reservation_id)
    FD->>AV: release(reservation_id)
    FD-)HK: checked_out event
    HK->>HK: raise a cleaning task per room
    FD-->>G: Invoice(nights + extras + tax)

Room lifecycle. The CLEANING state is what stops the desk handing a dirty room to the next guest.

stateDiagram-v2
    [*] --> AVAILABLE : room commissioned
    AVAILABLE --> OCCUPIED : check_in assigns it
    OCCUPIED --> AVAILABLE : check-in rolled back
    OCCUPIED --> CLEANING : check_out
    CLEANING --> AVAILABLE : housekeeping signs it off
    AVAILABLE --> OUT_OF_SERVICE : maintenance closes it
    OUT_OF_SERVICE --> CLEANING : repair finished

Reservation lifecycle. Three terminal states, and the sweeper is what makes NO_SHOW real rather than a label nobody sets.

stateDiagram-v2
    [*] --> PENDING : nights held, payment not captured
    PENDING --> CONFIRMED : charge captured
    PENDING --> CANCELLED : guest abandons
    CONFIRMED --> CHECKED_IN : rooms assigned at the desk
    CONFIRMED --> CANCELLED : cancel, refund by policy
    CONFIRMED --> NO_SHOW : arrival date passed, nobody came
    CHECKED_IN --> CHECKED_OUT : invoice issued
    CHECKED_OUT --> [*]
    CANCELLED --> [*]
    NO_SHOW --> [*]

Implementation

Write the value object first. In this problem DateRange is not plumbing — it is the model, and getting half-open right in the first two minutes buys you the rest of the interview.

code/lld/hotel_management/models.py — enums
class RoomType(StrEnum):
    SINGLE = "single"
    DOUBLE = "double"
    DELUXE = "deluxe"
    SUITE = "suite"


class RoomStatus(StrEnum):
    AVAILABLE = "available"  # clean and sellable
    OCCUPIED = "occupied"
    CLEANING = "cleaning"  # vacated, waiting for housekeeping
    OUT_OF_SERVICE = "out_of_service"


class ReservationStatus(StrEnum):
    PENDING = "pending"  # inventory held, payment not captured
    CONFIRMED = "confirmed"
    CHECKED_IN = "checked_in"
    CHECKED_OUT = "checked_out"
    CANCELLED = "cancelled"
    NO_SHOW = "no_show"


class StaffRole(StrEnum):
    RECEPTIONIST = "receptionist"
    HOUSEKEEPER = "housekeeper"
    MANAGER = "manager"


class TaskKind(StrEnum):
    TURNDOWN = "turndown"
    DEEP_CLEAN = "deep_clean"
    MAINTENANCE = "maintenance"


class PaymentMethod(StrEnum):
    CARD = "card"
    CASH = "cash"
code/lld/hotel_management/models.py — date range
@dataclass(frozen=True, slots=True, order=True)
class DateRange:
    """A stay as a half-open interval: arrive on ``start``, leave on ``end``.

    >>> DateRange(date(2026, 3, 1), date(2026, 3, 3)).nights_count
    2
    """

    start: date
    end: date

    def __post_init__(self) -> None:
        if self.end <= self.start:
            raise ValidationError(f"checkout {self.end} must be after check-in {self.start}")

    @property
    def nights_count(self) -> int:
        return (self.end - self.start).days

    def nights(self) -> list[date]:
        """The nights actually slept: ``end`` is a departure, never a night."""
        return [self.start + timedelta(days=i) for i in range(self.nights_count)]

    def overlaps(self, other: DateRange) -> bool:
        """Half-open overlap: touching intervals do not overlap."""
        return self.start < other.end and other.start < self.end

    def __str__(self) -> str:
        return f"{self.start.isoformat()}..{self.end.isoformat()}"


@dataclass(frozen=True, slots=True)
class RoomRequest:
    """How many rooms of one type a reservation asks for."""

    room_type: RoomType
    count: int = 1

    def __post_init__(self) -> None:
        if self.count < 1:
            raise ValidationError("a room request needs at least one room")

The physical room carries housekeeping status only. unassign exists so a partially completed check-in can be rolled back, which is the same all-or-nothing idea as the reservation itself.

code/lld/hotel_management/models.py — entities
@dataclass(frozen=True, slots=True)
class Guest:
    id: str
    name: str
    email: str


@dataclass(frozen=True, slots=True)
class Staff:
    id: str
    name: str
    role: StaffRole


@dataclass(slots=True)
class Room:
    """A physical room. Its status is about *housekeeping*, not about sales.

    Sellability for a future date lives in ``AvailabilityService``; this status
    answers "can a guest walk into it right now".
    """

    number: str
    floor: int
    type: RoomType
    status: RoomStatus = RoomStatus.AVAILABLE
    reservation_id: str | None = None

    def occupy(self, reservation_id: str) -> None:
        if self.status is not RoomStatus.AVAILABLE:
            raise RoomStateError(f"room {self.number} is {self.status}, not available")
        self.status = RoomStatus.OCCUPIED
        self.reservation_id = reservation_id

    def vacate(self) -> None:
        if self.status is not RoomStatus.OCCUPIED:
            raise RoomStateError(f"room {self.number} is {self.status}, not occupied")
        self.status = RoomStatus.CLEANING
        self.reservation_id = None

    def mark_clean(self) -> None:
        if self.status is not RoomStatus.CLEANING:
            raise RoomStateError(f"room {self.number} is {self.status}, not cleaning")
        self.status = RoomStatus.AVAILABLE

    def unassign(self) -> None:
        """Roll back an assignment the guest never received (partial check-in failure)."""
        if self.status is not RoomStatus.OCCUPIED:
            raise RoomStateError(f"room {self.number} is {self.status}, not occupied")
        self.status = RoomStatus.AVAILABLE
        self.reservation_id = None


@dataclass(frozen=True, slots=True)
class InvoiceLine:
    description: str
    amount: Money


@dataclass(frozen=True, slots=True)
class Invoice:
    id: str
    reservation_id: str
    lines: tuple[InvoiceLine, ...]
    tax: Money
    total: Money

    def subtotal(self) -> Money:
        return self.total - self.tax


@dataclass(slots=True)
class HousekeepingTask:
    id: str
    room_number: str
    kind: TaskKind
    created_at: float
    assigned_to: str | None = None
    done: bool = False


@dataclass(slots=True)
class Payment:
    id: str
    reservation_id: str
    amount: Money
    method: PaymentMethod
    idempotency_key: str
    captured: bool = False

The reservation state machine is a table. Six states, one guard, no ladder.

code/lld/hotel_management/models.py — reservation
RESERVATION_TRANSITIONS: dict[ReservationStatus, frozenset[ReservationStatus]] = {
    ReservationStatus.PENDING: frozenset(
        {ReservationStatus.CONFIRMED, ReservationStatus.CANCELLED}
    ),
    ReservationStatus.CONFIRMED: frozenset(
        {ReservationStatus.CHECKED_IN, ReservationStatus.CANCELLED, ReservationStatus.NO_SHOW}
    ),
    ReservationStatus.CHECKED_IN: frozenset({ReservationStatus.CHECKED_OUT}),
    ReservationStatus.CHECKED_OUT: frozenset(),
    ReservationStatus.CANCELLED: frozenset(),
    ReservationStatus.NO_SHOW: frozenset(),
}


@dataclass(slots=True)
class Reservation:
    id: str
    guest_id: str
    stay: DateRange
    rooms: tuple[RoomRequest, ...]
    amount: Money
    status: ReservationStatus = ReservationStatus.PENDING
    assigned_rooms: tuple[str, ...] = ()
    extras: list[InvoiceLine] = field(default_factory=list)
    payment_id: str | None = None
    refunded: Money | None = None

    @property
    def room_count(self) -> int:
        return sum(r.count for r in self.rooms)

    def transition_to(self, target: ReservationStatus) -> None:
        """The entire reservation state machine, table-driven."""
        if target not in RESERVATION_TRANSITIONS[self.status]:
            raise ReservationStateError(
                f"reservation {self.id}: {self.status} -> {target} is not allowed"
            )
        self.status = target

HotelBuilder is the one place a hotel can be built wrong, so it is the only place that validates.

code/lld/hotel_management/hotel.py — hotel and builder
class Hotel:
    """A named property with a fixed set of rooms and a tax rate."""

    def __init__(self, name: str, rooms: list[Room], tax_rate: Decimal = Decimal("0.12")) -> None:
        self.name = name
        self.tax_rate = tax_rate
        self._rooms: dict[str, Room] = {r.number: r for r in rooms}

    def room(self, number: str) -> Room:
        try:
            return self._rooms[number]
        except KeyError:
            raise UnknownReservationError(f"{self.name} has no room {number!r}") from None

    def all_rooms(self) -> list[Room]:
        return list(self._rooms.values())

    def rooms_of(self, room_type: RoomType) -> list[Room]:
        return [r for r in self._rooms.values() if r.type is room_type]

    def inventory(self) -> dict[RoomType, int]:
        """Sellable capacity per type: rooms out of service are not inventory."""
        counts: dict[RoomType, int] = {}
        for room in self._rooms.values():
            if room.status is not RoomStatus.OUT_OF_SERVICE:
                counts[room.type] = counts.get(room.type, 0) + 1
        return counts

    def first_ready(self, room_type: RoomType) -> Room | None:
        """Lowest-numbered clean room of the type, or None. Callers hold the type lock."""
        ready = [r for r in self.rooms_of(room_type) if r.status is RoomStatus.AVAILABLE]
        return min(ready, key=lambda r: r.number) if ready else None


class HotelBuilder:
    """Stepwise construction with validation - the one place Builder earns its keep.

    >>> hotel = HotelBuilder().named("Seaside").with_rooms(RoomType.DOUBLE, 2, floor=1).build()
    >>> len(hotel.all_rooms())
    2
    """

    def __init__(self) -> None:
        self._name: str | None = None
        self._tax_rate = Decimal("0.12")
        self._rooms: list[Room] = []

    def named(self, name: str) -> Self:
        self._name = name.strip()
        return self

    def with_tax_rate(self, rate: Decimal) -> Self:
        if rate < 0:
            raise ValidationError("tax rate cannot be negative")
        self._tax_rate = rate
        return self

    def with_rooms(self, room_type: RoomType, count: int, floor: int) -> Self:
        """Add ``count`` rooms of one type on one floor, numbered ``<floor><nn>``."""
        if count < 1:
            raise ValidationError("count must be at least 1")
        existing = sum(1 for r in self._rooms if r.floor == floor)
        for i in range(count):
            self._rooms.append(Room(number=f"{floor}{existing + i + 1:02d}", floor=floor, type=room_type))
        return self

    def build(self) -> Hotel:
        if not self._name:
            raise ValidationError("a hotel needs a name")
        if not self._rooms:
            raise ValidationError("a hotel needs at least one room")
        numbers = [r.number for r in self._rooms]
        if len(set(numbers)) != len(numbers):
            raise ValidationError(f"duplicate room numbers: {sorted(numbers)}")
        return Hotel(self._name, self._rooms, self._tax_rate)

Now the piece the interview is about. _free_on takes the minimum across the nights of the stay; reserve checks every request before it increments anything; types_locked sorts so multi-type bookings cannot deadlock.

code/lld/hotel_management/services.py — availability
class AvailabilityService:
    """Per-night counters per room type, plus the lock that makes them atomic.

    Reservations are sold against a *type*, never a room number: that is how hotels
    actually work and it keeps the contended resource a single integer per night.
    """

    def __init__(self, hotel: Hotel, overbooking: dict[RoomType, int] | None = None) -> None:
        self._hotel = hotel
        self._overbooking = overbooking or {}
        self._booked: dict[RoomType, dict[date, int]] = {}
        self._holders: dict[str, tuple[tuple[RoomRequest, ...], DateRange]] = {}
        self._locks: dict[RoomType, threading.Lock] = {}
        self._registry_lock = threading.Lock()

    def _lock_for(self, room_type: RoomType) -> threading.Lock:
        with self._registry_lock:
            return self._locks.setdefault(room_type, threading.Lock())

    @contextmanager
    def types_locked(self, room_types: Sequence[RoomType]) -> Iterator[None]:
        """Acquire one lock per room type, always in sorted order.

        A family booking a deluxe and a suite takes DELUXE before SUITE; so does every
        other caller, so the ABBA deadlock cannot happen.
        """
        acquired: list[threading.Lock] = []
        try:
            for room_type in sorted(set(room_types)):
                lock = self._lock_for(room_type)
                lock.acquire()
                acquired.append(lock)
            yield
        finally:
            for lock in reversed(acquired):
                lock.release()

    def capacity(self, room_type: RoomType) -> int:
        return self._hotel.inventory().get(room_type, 0) + self._overbooking.get(room_type, 0)

    def _free_on(self, room_type: RoomType, stay: DateRange) -> int:
        """Rooms free on the *tightest* night of the stay. Callers hold the type lock."""
        booked = self._booked.setdefault(room_type, {})
        capacity = self.capacity(room_type)
        return min(capacity - booked.get(night, 0) for night in stay.nights())

    def available(self, room_type: RoomType, stay: DateRange) -> int:
        with self.types_locked([room_type]):
            return self._free_on(room_type, stay)

    def reserve(
        self, rooms: Sequence[RoomRequest], stay: DateRange, reservation_id: str
    ) -> None:
        """All-or-nothing across every requested type and every night of the stay."""
        with self.types_locked([r.room_type for r in rooms]):
            short = [
                f"{r.count} x {r.room_type}"
                for r in rooms
                if self._free_on(r.room_type, stay) < r.count
            ]
            if short:
                raise NoAvailabilityError(f"{stay}: cannot sell {', '.join(short)}")
            for request in rooms:
                counters = self._booked.setdefault(request.room_type, {})
                for night in stay.nights():
                    counters[night] = counters.get(night, 0) + request.count
            self._holders[reservation_id] = (tuple(rooms), stay)

    def release(self, reservation_id: str) -> bool:
        """Give the nights back. Idempotent: releasing twice is a no-op, not a bug."""
        held = self._holders.get(reservation_id)
        if held is None:
            return False
        rooms, stay = held
        with self.types_locked([r.room_type for r in rooms]):
            if self._holders.pop(reservation_id, None) is None:
                return False
            for request in rooms:
                counters = self._booked[request.room_type]
                for night in stay.nights():
                    counters[night] = max(0, counters.get(night, 0) - request.count)
        return True

    def calendar(self, room_type: RoomType, stay: DateRange) -> dict[date, int]:
        """Free rooms per night - what the availability grid in the UI renders."""
        with self.types_locked([room_type]):
            booked = self._booked.setdefault(room_type, {})
            capacity = self.capacity(room_type)
            return {night: capacity - booked.get(night, 0) for night in stay.nights()}

The facade. Read check_in: the room roster is mutated under the same room-type lock that guards the counters, which is why two receptionists cannot hand out room 201 twice.

code/lld/hotel_management/services.py — front desk
class FrontDeskService:
    """Facade: search, reserve, pay, check in, check out, cancel, no-show sweep."""

    def __init__(
        self,
        hotel: Hotel,
        availability: AvailabilityService,
        gateway: PaymentGateway | None = None,
        pricing: PricingStrategy | None = None,
        cancellation: CancellationPolicy | None = None,
        clock: Clock | None = None,
        ids: IdGenerator | None = None,
        payment_ids: IdGenerator | None = None,
    ) -> None:
        self._hotel = hotel
        self._availability = availability
        self._gateway = gateway or AlwaysApprovesGateway()
        self._pricing = pricing or SeasonalPricing()
        self._cancellation = cancellation or FreeUntilDaysBefore(2)
        self._clock = clock or SystemClock()
        self._ids = ids or SequentialIdGenerator("RSV")
        self._payment_ids = payment_ids or SequentialIdGenerator("PAY")
        self._reservations: dict[str, Reservation] = {}
        self._by_key: dict[str, str] = {}
        self._payments: dict[str, Payment] = {}
        self._listeners: list[StayListener] = []
        self._reservations_lock = threading.Lock()

    def subscribe(self, listener: StayListener) -> None:
        self._listeners.append(listener)

    def today(self) -> date:
        return self._clock.now_dt().date()

    def search(self, room_type: RoomType, stay: DateRange) -> int:
        return self._availability.available(room_type, stay)

    def quote(self, rooms: Sequence[RoomRequest], stay: DateRange) -> Money:
        return quote_stay(self._pricing, tuple(rooms), stay)

    def reserve(self, guest_id: str, rooms: Sequence[RoomRequest], stay: DateRange) -> Reservation:
        """Hold the nights first; only then does a reservation exist."""
        if not rooms:
            raise NoAvailabilityError("a reservation needs at least one room request")
        requests = tuple(rooms)
        reservation_id = self._ids.next_id()
        self._availability.reserve(requests, stay, reservation_id)
        reservation = Reservation(
            id=reservation_id,
            guest_id=guest_id,
            stay=stay,
            rooms=requests,
            amount=self.quote(requests, stay),
        )
        with self._reservations_lock:
            self._reservations[reservation.id] = reservation
        self._emit("reserved", reservation, None)
        return reservation

    def pay(self, reservation_id: str, method: PaymentMethod, idempotency_key: str) -> Reservation:
        """Charge outside every lock, then confirm. Replaying the key never charges twice."""
        with self._reservations_lock:
            replayed = self._by_key.get(idempotency_key)
            if replayed is not None:
                return self._require(replayed)
            reservation = self._require(reservation_id)
            if reservation.status is not ReservationStatus.PENDING:
                raise ReservationStateError(
                    f"reservation {reservation_id} is {reservation.status}, not pending"
                )
            payment = Payment(
                id=self._payment_ids.next_id(),
                reservation_id=reservation.id,
                amount=reservation.amount,
                method=method,
                idempotency_key=idempotency_key,
            )
            self._payments[payment.id] = payment
            self._by_key[idempotency_key] = reservation.id

        if not self._gateway.charge(payment.id, reservation.amount, method):
            with self._reservations_lock:
                del self._by_key[idempotency_key]
            raise PaymentDeclinedError(f"{method} payment of {reservation.amount} declined")

        with self._reservations_lock:
            payment.captured = True
            reservation.payment_id = payment.id
            reservation.transition_to(ReservationStatus.CONFIRMED)
        self._emit("confirmed", reservation, None)
        return reservation

    def check_in(self, reservation_id: str) -> tuple[str, ...]:
        """Assign physical rooms as late as possible: at the desk, under the type lock."""
        with self._reservations_lock:
            reservation = self._require(reservation_id)
            if reservation.status is not ReservationStatus.CONFIRMED:
                raise ReservationStateError(
                    f"reservation {reservation_id} is {reservation.status}, not confirmed"
                )
        assigned: list[Room] = []
        with self._availability.types_locked([r.room_type for r in reservation.rooms]):
            for request in reservation.rooms:
                for _ in range(request.count):
                    room = self._hotel.first_ready(request.room_type)
                    if room is None:
                        for taken in assigned:
                            taken.unassign()  # check-in is all-or-nothing too
                        raise NoRoomReadyError(
                            f"no clean {request.room_type} available for {reservation_id}"
                        )
                    room.occupy(reservation.id)
                    assigned.append(room)
        with self._reservations_lock:
            reservation.assigned_rooms = tuple(r.number for r in assigned)
            reservation.transition_to(ReservationStatus.CHECKED_IN)
        for room in assigned:
            self._emit("checked_in", reservation, room)
        return reservation.assigned_rooms

    def add_charge(self, reservation_id: str, description: str, amount: Money) -> None:
        with self._reservations_lock:
            reservation = self._require(reservation_id)
            if reservation.status is not ReservationStatus.CHECKED_IN:
                raise ReservationStateError("extras can only be added during the stay")
            reservation.extras.append(InvoiceLine(description, amount))

    def check_out(self, reservation_id: str) -> Invoice:
        """Vacate the rooms, release the nights, bill the stay, raise the cleaning tasks."""
        with self._reservations_lock:
            reservation = self._require(reservation_id)
            if reservation.status is not ReservationStatus.CHECKED_IN:
                raise ReservationStateError(
                    f"reservation {reservation_id} is {reservation.status}, not checked in"
                )
            invoice = self._invoice(reservation)
            reservation.transition_to(ReservationStatus.CHECKED_OUT)
            rooms = [self._hotel.room(n) for n in reservation.assigned_rooms]
        with self._availability.types_locked([r.type for r in rooms]):
            for room in rooms:
                room.vacate()
        self._availability.release(reservation.id)
        for room in rooms:
            self._emit("checked_out", reservation, room)
        return invoice

    def cancel(self, reservation_id: str) -> Money:
        today = self.today()
        with self._reservations_lock:
            reservation = self._require(reservation_id)
            refund = (
                self._cancellation.refund(reservation, today)
                if reservation.status is ReservationStatus.CONFIRMED
                else Money(0, reservation.amount.currency)
            )
            reservation.transition_to(ReservationStatus.CANCELLED)
            reservation.refunded = refund
            payment_id = reservation.payment_id
        self._availability.release(reservation.id)
        if payment_id is not None and not refund.is_zero():
            self._gateway.refund(payment_id, refund)
        self._emit("cancelled", reservation, None)
        return refund

    def sweep_no_shows(self) -> list[str]:
        """Arrival date passed and nobody checked in: free the nights for walk-ins."""
        today = self.today()
        swept: list[Reservation] = []
        with self._reservations_lock:
            for reservation in self._reservations.values():
                if (
                    reservation.status is ReservationStatus.CONFIRMED
                    and reservation.stay.start < today
                ):
                    reservation.transition_to(ReservationStatus.NO_SHOW)
                    swept.append(reservation)
        for reservation in swept:
            self._availability.release(reservation.id)
            self._emit("no_show", reservation, None)
        return [r.id for r in swept]

    def reservation(self, reservation_id: str) -> Reservation:
        with self._reservations_lock:
            return self._require(reservation_id)

    def _invoice(self, reservation: Reservation) -> Invoice:
        lines = [
            InvoiceLine(
                f"{request.count} x {request.room_type} x {reservation.stay.nights_count} nights",
                quote_stay(self._pricing, (request,), reservation.stay),
            )
            for request in reservation.rooms
        ]
        lines.extend(reservation.extras)
        subtotal = Money(0, reservation.amount.currency)
        for line in lines:
            subtotal = subtotal + line.amount
        tax = subtotal * self._hotel.tax_rate
        return Invoice(
            id=self._payment_ids.next_id(),
            reservation_id=reservation.id,
            lines=tuple(lines),
            tax=tax,
            total=subtotal + tax,
        )

    def _require(self, reservation_id: str) -> Reservation:
        try:
            return self._reservations[reservation_id]
        except KeyError:
            raise UnknownReservationError(f"unknown reservation {reservation_id!r}") from None

    def _emit(self, event: str, reservation: Reservation, room: Room | None) -> None:
        # Outside every lock: a slow listener must never stall the front desk.
        for listener in self._listeners:
            listener.on_stay_event(event, reservation, room)

Pricing is per night so a stay that crosses a season boundary is priced correctly with no special case:

code/lld/hotel_management/strategies.py — pricing
DEFAULT_NIGHTLY_RATES: dict[RoomType, Money] = {
    RoomType.SINGLE: Money.of("80.00"),
    RoomType.DOUBLE: Money.of("120.00"),
    RoomType.DELUXE: Money.of("180.00"),
    RoomType.SUITE: Money.of("320.00"),
}

# High season: December to February and June to August in this hotel's market.
DEFAULT_SEASON_MULTIPLIERS: dict[int, Decimal] = {
    12: Decimal("1.4"),
    1: Decimal("1.4"),
    2: Decimal("1.2"),
    6: Decimal("1.3"),
    7: Decimal("1.5"),
    8: Decimal("1.3"),
}


class PricingStrategy(Protocol):
    """Price one room of one type for one night. Stateless, therefore thread-safe."""

    def price_night(self, room_type: RoomType, night: date) -> Money: ...


class SeasonalPricing:
    """Base rate per type, scaled by the month. The default policy."""

    def __init__(
        self,
        rates: dict[RoomType, Money] | None = None,
        multipliers: dict[int, Decimal] | None = None,
    ) -> None:
        self._rates = rates or DEFAULT_NIGHTLY_RATES
        self._multipliers = multipliers or DEFAULT_SEASON_MULTIPLIERS

    def price_night(self, room_type: RoomType, night: date) -> Money:
        return self._rates[room_type] * self._multipliers.get(night.month, Decimal("1.0"))


class FlatRatePricing:
    """One rate per type all year (corporate contracts, long stays)."""

    def __init__(self, rates: dict[RoomType, Money] | None = None) -> None:
        self._rates = rates or DEFAULT_NIGHTLY_RATES

    def price_night(self, room_type: RoomType, night: date) -> Money:
        return self._rates[room_type]


def quote_stay(
    pricing: PricingStrategy, rooms: tuple[RoomRequest, ...], stay: DateRange
) -> Money:
    """Sum every requested room over every night. Nightly pricing composes for free."""
    total = Money(0)
    for request in rooms:
        for night in stay.nights():
            total = total + pricing.price_night(request.room_type, night) * request.count
    return total

Running python -m lld.hotel_management.demo walks a day at the desk:

Seaside Grand: 7 rooms, inventory {'deluxe': 2, 'double': 4, 'suite': 1}
doubles free 2026-03-11..2026-03-14: 4
RSV-1 2026-03-11..2026-03-14 2 x double -> pending, 720.00 USD
overlapping request rejected: 2026-03-13..2026-03-16: cannot sell 3 x double
RSV-3 2026-03-13..2026-03-16 2 x double -> pending (nights 13 and 14 were free)
RSV-1 paid once, replay is a no-op -> confirmed
no-show sweep on 2026-03-11 -> ['RSV-4'], suite back on sale
RSV-1 checked in to rooms 101,102
  2 x double x 3 nights: 720.00 USD
  minibar: 45.50 USD
  tax 12%: 91.86 USD -> total 857.36 USD
housekeeping raised [('101', 'turndown'), ('102', 'turndown')]
room 101 is available again
RSV-3 cancelled 2 days out -> refund 720.00 USD
doubles free 2026-03-13..2026-03-16 after the cancellation: 4
notifications: 12 sent, last was cancelled: RSV-3 (2026-03-13..2026-03-16)

The third request wanted three doubles over 13 → 16, and only two were free on the night of the 13th — so it was refused outright rather than partially filled. The two-room version of the same request went through.

Concurrency and edge cases

Which lock protects what.

  1. AvailabilityService._locks — one threading.Lock per RoomType, created lazily under _registry_lock. It guards two things at once: the per-night counters for that type and the physical room roster for that type. That pairing is deliberate — "sell a deluxe" and "hand out deluxe 201" must not interleave, and one lock is simpler to defend than two that must be ordered.
  2. FrontDeskService._reservations_lock — guards the reservation registry, every transition_to and the idempotency-key table. It is never held while a room-type lock is held, so the two families cannot form a cycle.

Lock ordering. A booking that spans a deluxe and a suite acquires DELUXE before SUITE, because types_locked sorts. Every caller sorts, so the ABBA deadlock cannot occur. The cost is trivial: an uncontended mutex is about 17 ns, so even a four-type group booking spends under 100 ns in acquisition.

Overlapping ranges without an interval search. Because rooms are fungible within a type, availability is min(capacity - booked[night] for night in stay.nights()). A 30-night stay is 30 integer comparisons. The parametrised test pins the boundary behaviour: a range ending on your arrival day and a range starting on your departure day both leave availability untouched, while a range straddling either end reduces it. That is the half-open interval doing its job.

All-or-nothing. reserve runs two passes under the locks: check every RoomRequest against its tightest night, then increment. A family never ends up with the suite but not the deluxe. Check-in does the same for physical rooms and calls unassign on anything it already took if a later room is not ready.

Overbooking. capacity() is inventory plus an allowance per type, so a hotel that deliberately sells 102 % of its deluxe rooms changes one dict and nothing else. The failure mode — a confirmed guest with no clean room — surfaces as NoRoomReadyError at check-in, which is exactly where a human can walk the guest to a partner property.

Date boundaries and timezones. "Today" comes from the injected clock as clock.now_dt().date(), so the no-show sweep is testable to the day and the property's local midnight is a configuration choice rather than a server accident. A stay of zero nights is rejected in DateRange.__post_init__, before it can become a division by zero in a rate calculation.

Other edge cases handled: releasing nights twice is a no-op, not a negative counter; a declined card leaves the reservation PENDING with the nights still held so the guest can retry; early checkout frees the remaining nights immediately; extras can only be added while CHECKED_IN; a room taken out of service leaves the inventory count, so future availability drops automatically.

Common mistake

Modelling availability as "is this room free for this range" and scanning reservations for overlaps. It is O(reservations) per search, it forces you to pick a physical room at booking time, and it turns every cancellation into an index rebuild. Say instead: "rooms are fungible within a type, so inventory is a counter per type per night, and the physical room is chosen at check-in." The other classic slip is inclusive end dates — then a guest leaving on the 14th blocks the guest arriving on the 14th, and you have silently lost 30 % of your sellable nights.

Extensibility and follow-ups

  • Persistence. The counters map onto room_inventory(room_type, night, sold) with a primary key on (room_type, night). reserve becomes a transaction doing SELECT ... FOR UPDATE ... ORDER BY night then a conditional UPDATE; the sorted acquisition you already have is the same discipline. A single primary handles roughly 5k–20k writes/s, which is far more than any one property generates.
  • Hotel chains. AvailabilityService is scoped to one Hotel; a chain is one instance per property plus a search service that fans out. Nothing in the reservation flow changes, and the fan-out is where the conversation becomes the HLD version.
  • Group bookings already work: a Reservation holds a tuple of RoomRequest. What a real group needs on top is a partial-allocation policy ("give me 8 of the 10 rooms"), which is a strategy passed to reserve, not a change to the locking.
  • Loyalty and rate plans. A LoyaltyPricing that composes SeasonalPricing and applies a tier discount, exactly like the surge wrapper in the movie ticket sibling.
  • OTA channels. Each channel gets an allotment carved out of capacity(), plus an outbound event on every counter change — a third StayListener, not a rewrite.
  • Housekeeping scheduling. Today tasks are raised and assigned by hand. Adding a scheduler that batches tasks by floor is a consumer of open_tasks(); the observer boundary already isolates it.

Interview tip

When you reach availability, draw the calendar grid — room types down the side, nights across the top, an integer in every cell — before you write a line of code. Then point at a column and say "this cell is the contended resource, and this is the lock." Interviewers grade date-range problems on whether you found the counter; candidates who start from Reservation.overlaps() almost never get there in 45 minutes.

Tests

tests/test_hotel_management.py has 18 cases. The boundary test is the one to walk through out loud, because it is where date-range problems are won or lost:

code/lld/hotel_management/tests/test_hotel_management.py — half-open boundaries
@pytest.mark.parametrize(
    ("start", "end", "expected_free"),
    [
        (date(2026, 3, 8), date(2026, 3, 11), 2),  # ends the morning we arrive: no overlap
        (date(2026, 3, 14), date(2026, 3, 16), 2),  # starts the morning we leave: no overlap
        (date(2026, 3, 10), date(2026, 3, 12), 1),  # straddles the first night
        (date(2026, 3, 13), date(2026, 3, 15), 1),  # straddles the last night
        (date(2026, 3, 9), date(2026, 3, 20), 1),  # swallows the whole stay
    ],
)
def test_half_open_ranges_touch_without_overlapping(
    clock: FakeClock, start: date, end: date, expected_free: int
) -> None:
    hotel = make_hotel(doubles=2)
    desk, availability = make_desk(clock, hotel)
    desk.reserve("g-1", [RoomRequest(RoomType.DOUBLE, 1)], STAY)
    assert availability.available(RoomType.DOUBLE, DateRange(start, end)) == expected_free

The concurrency test asserts the invariant rather than a winner: 30 agents, five rooms, and afterwards every night of the contended stay is at exactly zero free while the following window is untouched.

code/lld/hotel_management/tests/test_hotel_management.py — concurrency
def test_thirty_agents_race_for_five_rooms_and_never_oversell(clock: FakeClock) -> None:
    hotel = make_hotel(doubles=5, suites=0)
    desk, availability = make_desk(clock, hotel)
    peak = DateRange(date(2026, 3, 11), date(2026, 3, 14))

    def book(i: int) -> str | None:
        try:
            return desk.reserve(f"g-{i}", [RoomRequest(RoomType.DOUBLE, 1)], peak).id
        except NoAvailabilityError:
            return None

    with ThreadPoolExecutor(max_workers=12) as pool:
        results = list(pool.map(book, range(30)))

    winners = [r for r in results if r is not None]
    assert len(winners) == 5 and len(set(winners)) == 5  # each room sold exactly once
    assert all(free == 0 for free in availability.calendar(RoomType.DOUBLE, peak).values())
    later = DateRange(date(2026, 3, 14), date(2026, 3, 17))
    assert availability.available(RoomType.DOUBLE, later) == 5  # the counter is per night

The rest cover: the full reserve-to-invoice path with tax and a housekeeping task; capacity refusal and the overbooking allowance; every illegal reservation transition; a partial check-in rolling back when the second room is still dirty; a declined card keeping the hold; the no-show sweep; all four cancellation outcomes via parametrize; seasonal rates and builder validation; and the housekeeping role check with a deep clean for long stays. Run them with uv run pytest code/lld/hotel_management -q.

45-minute pacing

Minutes What to do What to say or write
0–5 Clarify Room number or room type? Is checkout day billable? Whose timezone? Do we overbook? Park chains and OTAs.
5–10 The date model Draw the calendar grid. Write DateRange as half-open and say why. This is the highest-value five minutes on the clock.
10–16 Entities Hotel, Room, RoomRequest, Reservation, Invoice, HousekeepingTask. State that reservations are sold against a type.
16–22 State machines Room (four states) and Reservation (six). Mark where check-in and the no-show sweep sit.
22–34 Code DateRange.nights → _free_on (say "minimum over the nights") → reserve (say "check all, then increment all") → check_in (say "same lock as the counters").
34–41 Concurrency Lock per room type, sorted acquisition, overbooking as a capacity change. Describe the 30-agent test and the boundary parametrisation.
41–45 Extensions The SQL mapping, chains as one service per property, loyalty as a pricing wrapper, OTAs as allotments.