One deluxe room, one long weekend, fifty guests pressing "Book" at once. Exactly one gets a payment page; the guest checking in on Sunday afternoon still gets the room the first guest leaves that morning.
Hotel booking looks like the movie-ticket problem with dates added, and the dates change everything. A cinema seat is sold once per show; a hotel room is sold per night, and stays overlap in every possible way. Candidates who store "room 201 is booked" as a flag, or compare stays with <=, end up refusing a guest who arrives the day another leaves, or selling one room twice for the middle night of two overlapping stays.
This lesson builds it in one process: half-open stays, inventory counted per room type per night, expiring holds, a pricing chain and a cancellation policy. The insight that sets strong candidates apart is the first checkpoint: sell room types, not room numbers.
How the round runs#
"Design the reservation system for a single hotel. Guests search dates, see what's available and the price, hold a room while they pay, and can cancel. Prices vary with weekends and demand. Many guests book at once. Show me working code. Payments and the front desk are out of scope, beyond what you need."
- 0–10 min — clarify: room types vs rooms, date semantics, hold length, pricing rules, cancellation, scale
- 10–25 min — the key decisions: half-open stays, inventory per type per night, assign rooms at check-in
- 25–45 min —
Stay,Inventory, and the pricing rules - 45–65 min — the service: search, hold, confirm, cancel, lazy expiry, one lock per room type
- 65–80 min — a demo: a 50-guest race, back-to-back stays, an expired hold, surge pricing, cancellations
- 80–90 min — follow-ups: a database version, overbooking, multi-room bookings, many hotels
Requirements and clarifying questions#
| You ask | They answer | What it changes |
|---|---|---|
| Does a guest book a room or a room type? | A type. The room number is given at check-in. | Inventory is a count per type per night |
| What do the dates mean? | Check-in and check-out dates; check-in is at 14:00. | Stay is the half-open range of nights [in, out) |
| Can a guest arrive the day another leaves? | Of course. | Back-to-back stays share no night |
| How long is a room held while the guest pays? | 15 minutes. | HELD bookings with an expiry, counted against inventory |
| How is the price set? | Base rate per type; Friday and Saturday nights +20%; +25% once 75% of a type is taken that night; 10% off stays of 7+ nights. | A chain of PricingRules applied per night |
| Cancellation? | Free until 48 hours before check-in; after that the first night is charged. | A CancellationPolicy |
| A payment that arrives after the hold expired? | Refund it. | confirm re-checks the hold |
Assign room 201 when the guest books, or sell "a deluxe" and pick the room later?
The hotel has two standard rooms. Guest A books Monday night, guest B books Wednesday night. Guest C wants Monday to Thursday (three nights). Every one of those nights has a standard room free. Can C be accommodated, and does your design say so?
One strong answer
Only if rooms aren't fixed at booking time. If A got room 101 and B room 102, no single room is free all three nights. Put A and B in the same room and C fits in the other. So sell room types and count inventory per night: a stay can be sold if, on every night, fewer rooms of that type are taken than exist. Assign real rooms at check-in, in arrival order: any free room will do, and that never needs more rooms than the busiest night, so every sale the counts allowed gets a room. Assigning at booking time fragments the calendar and turns away guests who fit.
The classes#
%% title: Hotel reservation: the classes
classDiagram
class Stay {
+check_in date
+check_out date
+nights() list
}
class Booking {
+booking_id str
+guest str
+room_type RoomType
+stay Stay
+nightly tuple
+status BookingStatus
+hold_expires datetime
}
class Inventory {
+rooms dict
+free(type, stay) int
+take(type, stay)
+release(type, stay)
+occupancy(type, night) float
}
class PricingRule {
<<interface>>
+apply(price, ctx) int
}
class Pricer {
+base dict
+rules list
+quote(type, stay, inventory) list
}
class CancellationPolicy {
<<interface>>
+penalty(booking, now) int
}
class HotelService {
+search(stay, now) dict
+hold(guest, type, stay, now) Booking
+confirm(booking_id, payment_ref, now) Booking
+cancel(booking_id, now) int
}
Booking --> Stay
HotelService --> Inventory
HotelService --> Pricer
HotelService --> CancellationPolicy
HotelService "1" o-- "many" Booking
Pricer --> PricingRule
PricingRule <|.. WeekendRule
PricingRule <|.. OccupancyRule
PricingRule <|.. LongStayRule
CancellationPolicy <|.. FreeUntil48hA stay is a set of nights. [check_in, check_out) is every night from check-in up to, not including, check-out. Two stays overlap exactly when a.check_in < b.check_out and b.check_in < a.check_out; with <, a guest leaving on the 13th and one arriving on the 13th don't clash. The inventory never compares stays pairwise: it counts rooms taken per type per night, so an overlap is just two bookings counting the same night. Holds and confirmed bookings count; expired and cancelled ones give their nights back.
The interfaces#
# hotel.py, part 1: stays as half-open ranges of nights, and the booking record
from __future__ import annotations
import itertools
import threading
from abc import ABC, abstractmethod
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
from enum import Enum
class RoomType(Enum):
STANDARD = "standard"
DELUXE = "deluxe"
SUITE = "suite"
@dataclass(frozen=True)
class Stay: # [check_in, check_out): the nights slept, not the days present
check_in: date
check_out: date
def __post_init__(self) -> None:
if self.check_out <= self.check_in:
raise ValueError("check-out must be after check-in")
def nights(self) -> list[date]:
return [self.check_in + timedelta(days=i) for i in range((self.check_out - self.check_in).days)]
class BookingStatus(Enum):
HELD = "held"
CONFIRMED = "confirmed"
CANCELLED = "cancelled"
EXPIRED = "expired"
@dataclass
class Booking:
booking_id: str
guest: str
room_type: RoomType
stay: Stay
nightly: tuple[int, ...] # the price of each night, fixed when the hold is taken
hold_expires: datetime
status: BookingStatus = BookingStatus.HELD
payment_ref: str | None = None
@property
def amount(self) -> int:
return sum(self.nightly)
class SoldOut(Exception): ...
class HoldExpired(Exception): ...Inventory and pricing#
The inventory is a counter per (room type, night); free() is the minimum over the stay's nights, since one full night refuses the whole stay. Pricing runs per night, because a Thursday-to-Sunday stay has two weekend nights and one weekday night, and occupancy differs night by night. Each rule takes the price so far and returns a new one, so rules stack in order and a new rule is a new class. Integer rupees keep quotes exact.
# part 2: inventory counted per room type per night, and pricing rules that stack per night
class Inventory:
def __init__(self, rooms: dict[RoomType, int]) -> None:
self.rooms = rooms
self.taken: dict[tuple[RoomType, date], int] = defaultdict(int) # holds and confirmed bookings
def free(self, rt: RoomType, stay: Stay) -> int:
return min(self.rooms[rt] - self.taken[(rt, n)] for n in stay.nights())
def take(self, rt: RoomType, stay: Stay) -> None:
for n in stay.nights():
self.taken[(rt, n)] += 1
def release(self, rt: RoomType, stay: Stay) -> None:
for n in stay.nights():
self.taken[(rt, n)] -= 1
def occupancy(self, rt: RoomType, night: date) -> float:
return self.taken[(rt, night)] / self.rooms[rt]
@dataclass(frozen=True)
class NightContext:
room_type: RoomType
night: date
occupancy: float # share of this type already taken that night
stay_nights: int
class PricingRule(ABC):
@abstractmethod
def apply(self, price: int, ctx: NightContext) -> int: ...
class WeekendRule(PricingRule):
def apply(self, price: int, ctx: NightContext) -> int:
return price * 120 // 100 if ctx.night.weekday() in (4, 5) else price # Friday and Saturday nights
class OccupancyRule(PricingRule):
def __init__(self, threshold: float = 0.75, pct: int = 25) -> None:
self.threshold, self.pct = threshold, pct
def apply(self, price: int, ctx: NightContext) -> int:
return price * (100 + self.pct) // 100 if ctx.occupancy >= self.threshold else price
class LongStayRule(PricingRule):
def __init__(self, min_nights: int = 7, pct: int = 10) -> None:
self.min_nights, self.pct = min_nights, pct
def apply(self, price: int, ctx: NightContext) -> int:
return price * (100 - self.pct) // 100 if ctx.stay_nights >= self.min_nights else price
class Pricer:
def __init__(self, base: dict[RoomType, int], rules: list[PricingRule]) -> None:
self.base, self.rules = base, rules
def quote(self, rt: RoomType, stay: Stay, inv: Inventory) -> list[int]:
out = []
for night in stay.nights():
ctx = NightContext(rt, night, inv.occupancy(rt, night), len(stay.nights()))
price = self.base[rt]
for rule in self.rules:
price = rule.apply(price, ctx)
out.append(price)
return out
class CancellationPolicy(ABC):
@abstractmethod
def penalty(self, b: Booking, now: datetime) -> int: ...
class FreeUntil48h(CancellationPolicy):
def penalty(self, b: Booking, now: datetime) -> int:
check_in_at = datetime.combine(b.stay.check_in, time(14, 0))
return 0 if now <= check_in_at - timedelta(hours=48) else b.nightly[0]The patterns, and why#
| Pattern | Where | Why it earns its place |
|---|---|---|
| Strategy, chained | PricingRule list in Pricer |
Revenue teams change prices constantly: festival dates, corporate rates, a last-minute discount. Each is a class added to the list. |
| Strategy | CancellationPolicy |
Non-refundable rates, free-until-24h rates: one interface, chosen per rate plan. |
| Value object | Stay (frozen, validated) |
A stay with check-out before check-in can't exist; every other class trusts nights(). |
| State (as a status enum) | BookingStatus |
Only a HELD booking can be confirmed; only HELD or CONFIRMED can be cancelled. |
| Idempotency | confirm() with the payment reference |
A retried callback finds the booking confirmed with that reference and does nothing. |
Concurrency and edge cases#
Overselling is a check-then-act race: two guests both see one deluxe room free, and both take it. The fix is to make check free, quote, take one step under a lock. The lock is per room type, because a deluxe booking and a suite booking never touch the same counters; one hotel-wide lock would serialise them for nothing. Expired holds are released lazily: every operation on a type first sweeps that type's expired holds, under the same lock, so no background thread is needed for correctness.
A payment racing its hold's expiry: the guest pays at 14 minutes 59 seconds and the callback lands at 15 minutes 1 second. confirm() re-checks the hold under the lock; if it has expired, the nights may already be someone else's, so the booking becomes EXPIRED and the payment is refunded.
In a database, one transaction runs UPDATE inventory SET taken = taken + 1 WHERE room_type = ? AND night >= ? AND night < ? AND taken < total and commits only if the row count equals the number of nights. Lock the rows in date order and two overlapping stays can't deadlock.
- Check-out on or before check-in: rejected when the
Stayis built - Back-to-back stays: share no night, so both succeed
- A stay where only one night is full: refused as a whole; offer the other nights or another type
- The price changes between search and hold: the hold fixes each night's price; the guest pays what the hold showed
- The guest's own hold pushes occupancy over 75%: the quote is computed before taking the room, so a guest never pays surge for their own booking
- A payment after expiry or cancellation: refund, never revive the booking
The code#
# part 3: the service: search, hold, confirm, cancel, with lazy expiry and a lock per room type
class HotelService:
def __init__(self, rooms: dict[RoomType, int], pricer: Pricer, cancellation: CancellationPolicy, hold_ttl: timedelta = timedelta(minutes=15)) -> None:
self.inventory, self.pricer, self.cancellation, self.hold_ttl = Inventory(rooms), pricer, cancellation, hold_ttl
self.bookings: dict[str, Booking] = {}
self.refunds: list[tuple[str, int]] = [] # stands in for the payment gateway's refund call
self._locks = {rt: threading.Lock() for rt in rooms}
self._ids = itertools.count(1)
def _expire(self, rt: RoomType, now: datetime) -> None: # lazy: run at the start of every operation
for b in self.bookings.values():
if b.room_type is rt and b.status is BookingStatus.HELD and b.hold_expires <= now:
b.status = BookingStatus.EXPIRED
self.inventory.release(rt, b.stay)
def search(self, stay: Stay, now: datetime) -> dict[str, tuple[int, int]]:
out = {}
for rt, lock in self._locks.items():
with lock:
self._expire(rt, now)
if (free := self.inventory.free(rt, stay)) > 0:
out[rt.value] = (free, sum(self.pricer.quote(rt, stay, self.inventory)))
return out
def hold(self, guest: str, rt: RoomType, stay: Stay, now: datetime) -> Booking:
with self._locks[rt]:
self._expire(rt, now)
if self.inventory.free(rt, stay) < 1:
raise SoldOut(f"no {rt.value} room for every night of that stay")
nightly = tuple(self.pricer.quote(rt, stay, self.inventory)) # priced before this room is taken
self.inventory.take(rt, stay)
b = Booking(f"HB{next(self._ids):04d}", guest, rt, stay, nightly, now + self.hold_ttl)
self.bookings[b.booking_id] = b
return b
def confirm(self, booking_id: str, payment_ref: str, now: datetime) -> Booking:
b = self.bookings[booking_id]
with self._locks[b.room_type]:
if b.status is BookingStatus.CONFIRMED and b.payment_ref == payment_ref:
return b # the gateway retried its callback
self._expire(b.room_type, now)
if b.status is BookingStatus.HELD:
b.status, b.payment_ref = BookingStatus.CONFIRMED, payment_ref
return b
self.refunds.append((payment_ref, b.amount)) # paid for nights we no longer hold
raise HoldExpired(f"{b.status.value}: payment refunded")
def cancel(self, booking_id: str, now: datetime) -> int:
b = self.bookings[booking_id]
with self._locks[b.room_type]:
if b.status not in (BookingStatus.HELD, BookingStatus.CONFIRMED):
raise ValueError(f"booking is {b.status.value}")
paid = b.status is BookingStatus.CONFIRMED
refund = b.amount - self.cancellation.penalty(b, now) if paid else 0
b.status = BookingStatus.CANCELLED
self.inventory.release(b.room_type, b.stay)
if paid:
self.refunds.append((b.payment_ref, refund))
return refundThe demo: a December weekend at a small hotel with four standard rooms, one deluxe and one suite.
# part 4: a 50-guest race, back-to-back stays, an expired hold, surge pricing, a long stay, cancellations
if __name__ == "__main__":
rules: list[PricingRule] = [WeekendRule(), OccupancyRule(), LongStayRule()]
pricer = Pricer({RoomType.STANDARD: 3_000, RoomType.DELUXE: 5_000, RoomType.SUITE: 9_000}, rules)
hotel = HotelService({RoomType.STANDARD: 4, RoomType.DELUXE: 1, RoomType.SUITE: 1}, pricer, FreeUntil48h())
now = datetime(2026, 12, 1, 10, 0)
weekend = Stay(date(2026, 12, 11), date(2026, 12, 13)) # Friday and Saturday nights
print("search Fri-Sun:", hotel.search(weekend, now))
wins: list[Booking] = []
told_no: list[str] = []
def try_hold(guest: str) -> None:
try:
wins.append(hotel.hold(guest, RoomType.DELUXE, weekend, now))
except SoldOut:
told_no.append(guest)
ts = [threading.Thread(target=try_hold, args=(f"guest{i}",)) for i in range(50)]
for t in ts:
t.start()
for t in ts:
t.join()
first = wins[0]
print(len(wins), "hold,", len(told_no), "sold out | nightly:", first.nightly)
print("confirm:", hotel.confirm(first.booking_id, "pay_1", now).status.value, "| retry:", hotel.confirm(first.booking_id, "pay_1", now).status.value)
sunday = hotel.hold("neha", RoomType.DELUXE, Stay(date(2026, 12, 13), date(2026, 12, 15)), now) # arrives as the first guest leaves
hotel.confirm(sunday.booking_id, "pay_2", now)
print("back-to-back Sun-Tue:", sunday.status.value, sunday.nightly)
try:
hotel.hold("omar", RoomType.DELUXE, Stay(date(2026, 12, 12), date(2026, 12, 14)), now)
except SoldOut as e:
print("Sat-Mon:", e)
late = hotel.hold("priya", RoomType.SUITE, weekend, now)
try:
hotel.confirm(late.booking_id, "pay_3", now + timedelta(minutes=16))
except HoldExpired as e:
print("priya pays after 16 min:", e, hotel.refunds)
print("suite free again:", "suite" in hotel.search(weekend, now + timedelta(minutes=16)))
weekday = Stay(date(2026, 12, 14), date(2026, 12, 15)) # one Monday night
print("standard Mon, as rooms fill:", [hotel.hold(f"s{i}", RoomType.STANDARD, weekday, now).amount for i in range(4)])
long_stay = hotel.hold("ravi", RoomType.SUITE, Stay(date(2026, 12, 14), date(2026, 12, 21)), now)
print("suite, 7 nights from Mon:", long_stay.nightly, "=", long_stay.amount)
print("cancel Fri-Sun 10 days ahead, refund:", hotel.cancel(first.booking_id, now))
print("cancel Sun-Tue on Saturday, refund:", hotel.cancel(sunday.booking_id, datetime(2026, 12, 12, 18, 0)))
print("deluxe free Fri-Sun again:", hotel.search(weekend, now).get("deluxe"))search Fri-Sun: {'standard': (4, 7200), 'deluxe': (1, 12000), 'suite': (1, 21600)}
1 hold, 49 sold out | nightly: (6000, 6000)
confirm: confirmed | retry: confirmed
back-to-back Sun-Tue: confirmed (5000, 5000)
Sat-Mon: no deluxe room for every night of that stay
priya pays after 16 min: expired: payment refunded [('pay_3', 21600)]
suite free again: True
standard Mon, as rooms fill: [3000, 3000, 3000, 3750]
suite, 7 nights from Mon: (8100, 8100, 8100, 8100, 9720, 9720, 8100) = 59940
cancel Fri-Sun 10 days ahead, refund: 12000
cancel Sun-Tue on Saturday, refund: 5000
deluxe free Fri-Sun again: (1, 12000)Fifty threads, one hold, at ₹6,000 a night because both are weekend nights; the retried callback changes nothing. Neha arrives on the 13th, the morning the first guest leaves, with no clash, while Omar's Saturday-to-Monday stay needs a Saturday night that is already sold. Priya pays a minute after her 15-minute hold, so her ₹21,600 is refunded and the suite is back on sale. The fourth standard room on Monday costs ₹3,750: three of four were taken when it was quoted, which triggers the 25% surge. Ravi's seven nights are 10% off each, with Friday and Saturday still 20% above the rest. Cancelling ten days ahead refunds everything; cancelling the evening before Neha's Sunday check-in keeps her first night, ₹5,000.
Extending it: the follow-ups#
- "Move it to a database with ten app servers." One inventory row per type per night and the conditional
UPDATEabove, in one transaction; holds become rows with an expiry, swept lazily and by a job.HotelServicekeeps its shape. - "Book three rooms for a family." A booking with several lines, taken all or nothing: check every line, then take every line, inside the locks of every type involved, acquired in a fixed order.
- "Let us overbook by 5%, like airlines." The capacity per type becomes
rooms × 1.05inInventory, and check-in handles the guest who doesn't fit. A business rule, not a rewrite. - "Many hotels, and a search across a city." Inventory keyed by hotel too; search reads a cached availability summary per hotel and night, and only the hold goes to the authoritative counters.
- "Change the dates of a booking." Hold the new stay first, then cancel the old one without penalty; if the new hold fails, nothing has changed.
Common mistakes#
- Comparing stays with
<=, so a guest arriving the day another leaves is refused - Booking specific room numbers at sale time, which fragments the calendar and turns away stays that fit
- Check, then take, without a lock, so two guests get the last room
- One hotel-wide lock, so suites wait on standard rooms for nothing
- Prices recomputed at payment time, so the guest pays a different amount from the one shown at the hold
- Holds that never expire, so abandoned checkouts quietly sell out the weekend
Hotel reservation
Q1/3 Stay A is 11 to 13 December, stay B is 13 to 15 December, both for the only deluxe room. Do they clash?
Answer & explanation
Answer: B
Stays are half-open ranges of nights. A's check-out day is B's check-in day, and no night is counted twice.
Q2/3 Two standard rooms. A has Monday night, B has Wednesday night, and C asks for Monday to Thursday. When can C be sold?
Answer & explanation
Answer: B
Counting per night says yes. Assigning real rooms at check-in, in arrival order, puts A and B in the same room and C in the other. Fixing room numbers at booking time could make C impossible.
Q3/3 The payment callback for a held room arrives one minute after the hold expired. What should confirm() do?
Answer & explanation
Answer: C
After expiry the nights went back to the inventory and may be someone else's. Every rupee ends in a booking or a refund.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.