~learn.

LLD 1312 min+60 XP

Design a Hotel Reservation System.

Design room inventory over date ranges, holds, pricing and cancellation, then race it

5 code

Lesson · 12 min

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#

Interviewer

"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."

A good 90 minutes
  • 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
Your turn · checkpoint 1not answered

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?

Think it through, or jot it down. Then compare.

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#

DiagramHotel reservation: 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 <|.. FreeUntil48h

A 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#

pythonRoomType · 58 lines
# 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.

pythonInventory · 79 lines
# 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.

Edge cases
  • Check-out on or before check-in: rejected when the Stay is 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#

pythonHotelService · 59 lines
# 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 refund

The demo: a December weekend at a small hotel with four standard rooms, one deluxe and one suite.

pythontry_hold · 50 lines
# 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"))
text12 lines
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 UPDATE above, in one transaction; holds become rows with an expiry, swept lazily and by a job. HotelService keeps 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.05 in Inventory, 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.
✗ Pitfall

Common mistakes#

What costs candidates the round
  • 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
Quick check3 questions · +5 XP each

Hotel reservation

  1. 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.

  2. 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.

  3. 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.

End of LLD 13 · Design a Hotel Reservation System
Finish LLD 13Stages clear as you read them (0/1 so far). Skipping ahead doesn't count: marking complete unlocks after ~3 more min of reading.

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.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…