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. ARoomis physical: number, floor, type, and a housekeepingRoomStatus. 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_countandoverlaps(). 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 → 1guest,1 → *RoomRequest,1 → 0..1Payment, 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.
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"
@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.
@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.
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.
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.
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.
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:
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.
AvailabilityService._locks— onethreading.LockperRoomType, 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.FrontDeskService._reservations_lock— guards the reservation registry, everytransition_toand 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).reservebecomes a transaction doingSELECT ... FOR UPDATE ... ORDER BY nightthen a conditionalUPDATE; 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.
AvailabilityServiceis scoped to oneHotel; 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
Reservationholds a tuple ofRoomRequest. What a real group needs on top is a partial-allocation policy ("give me 8 of the 10 rooms"), which is a strategy passed toreserve, not a change to the locking. - Loyalty and rate plans. A
LoyaltyPricingthat composesSeasonalPricingand 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 thirdStayListener, 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:
@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.
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. |
Related¶
- Design a movie ticket booking system (BookMyShow) — the same hold-then-confirm shape over seats instead of nights
- Design Ticketmaster (with a hotel-booking variant) — the distributed version, including date-range inventory at scale
- Strategy — the pricing and cancellation seams
- Builder —
HotelBuilderand the validate-in-buildidiom - State — the transition table behind
Reservation - Facade — why reception talks to one object