Design a hotel booking system
Room types, availability by date range, reservations and check-in.
- Intervals and scheduling — Meetings, bookings and conflicts as interval arithmetic.
- From requirements to classes — Nouns, verbs and the use-case walk that finds the classes.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
- Specification and rule objects — Business rules as composable objects you can and, or and not.
- Law of Demeter and tell-don't-ask — Talk to your neighbours, not your neighbours' insides.
- Reading and drawing class diagrams — The notation interviewers expect, and what to leave out.
01The design, in the six-step framework
1. Requirements and use cases
Hotels differ from cinemas in one way that drives the whole design: a booking spans a range of nights, so availability is a question about intervals, not single slots.
- A guest searches for rooms of a type (single, double, suite) available for a check-in and check-out date range.
- A guest reserves a room type for the range and later cancels.
- Reception checks in a reservation, assigning a physical room, and checks out, producing the bill.
- Two guests reserving the last room of a type for overlapping nights: only one succeeds.
- Rates vary by room type and by season; the bill uses the rate of each night.
Out of scope: payments, loyalty points, housekeeping. Constraints: availability for a type over a date range should be O(nights) or better, not O(all reservations); the check-then-reserve step must be atomic per room type; a reservation holds a type, not a room, until check-in, which is how real hotels work and what makes overbooking rules possible later.
2. Core entities and relationships
DateRange: a half-open [check_in, check_out) in day numbers withnights()andoverlaps().RoomType: a name, a nightly base rate and the count of physical rooms.Room: a physical room with a number and type.Reservation: guest, type, range and a status enum; a room is attached at check-in.Availability: per type, a counter of reserved rooms per night; answers "is there a free room every night of this range".RatePlan: a specification-like object returning the rate for a type on a given day.Hotel: the facade: search, reserve, cancel, check in, check out.
The hotel composes rooms, types, reservations and the availability ledger. A reservation aggregates a room only after check-in, and the room outlives the reservation, which is why that edge is a hollow diamond with multiplicity 0..1.
3. Interfaces and key methods
from dataclasses import dataclass
from enum import Enum, auto
from typing import Optional, Protocol
@dataclass(frozen=True)
class DateRange:
check_in: int # day number, inclusive
check_out: int # exclusive
def nights(self) -> int:
return self.check_out - self.check_in
def days(self) -> range:
return range(self.check_in, self.check_out)
def overlaps(self, other: "DateRange") -> bool:
return self.check_in < other.check_out and other.check_in < self.check_out
class ResStatus(Enum):
RESERVED = auto()
CHECKED_IN = auto()
CHECKED_OUT = auto()
CANCELLED = auto()
class RatePlan(Protocol):
def rate(self, room_type: str, day: int) -> int: ...
class Availability:
def is_free(self, room_type: str, rng: DateRange) -> bool:
"""Every night of rng has reserved < count for the type."""
def take(self, room_type: str, rng: DateRange) -> bool:
"""Atomic check-and-increment over all nights; False and no change if any night is full."""
def give_back(self, room_type: str, rng: DateRange) -> None:
"""Decrement every night; never below zero."""
class Hotel:
def reserve(self, guest: str, room_type: str, rng: DateRange) -> "Reservation":
"""Raises NoAvailability if take() fails."""
def check_in(self, res_id: str) -> "Room":
"""Assigns a free physical room of the type; only from RESERVED."""
def check_out(self, res_id: str) -> "Bill":
"""Frees the room and computes nightly rates via RatePlan."""
Invariants: for every (type, night), reserved is at most the type's count; a reservation in CHECKED_IN has exactly one room and that room's occupied_by is the reservation; cancelled reservations have given their nights back exactly once.
4. Design patterns and the choices behind them
A per-night counter instead of interval overlap scans
The force: "is a double free from the 3rd to the 7th" must not scan every reservation. Counting reservations per (type, night) turns the question into nights lookups in a dictionary, the compact form of interval scheduling when the resource is a count rather than one named slot. The half-open range means a guest checking out on the 7th does not block one checking in on the 7th.
Atomic take under one lock
Checking all nights and then incrementing all nights is one critical section in Availability.take, which is what makes two guests racing for the last suite safe. Separating the check from the increment is the bug every reviewer looks for. The status moves in cancel, check_in and check_out are the same check-then-change, so they run under the hotel's own lock.
Rates as a plan, reservations as a state machine, and talking to neighbours only
Seasonal pricing is a rule object, RatePlan.rate(type, day), so the bill is a sum over nights and a new season is a new plan, not a new if. The reservation's status enum with explicit transitions is the light state form. And the hotel never reaches through a reservation into its room's type to compute anything; it asks the reservation, in keeping with the law of Demeter, which also keeps the class diagram honest about who knows whom.
5. Core flow and code
- The guest calls
search(range); the hotel returns the types for whichAvailability.is_freeholds. reservecallsAvailability.take; on success it creates aReservationinRESERVED.- On arrival,
check_infinds any unoccupiedRoomof the type, marks it occupied and moves the reservation toCHECKED_IN. check_outfrees the room, moves toCHECKED_OUT, and builds the bill fromRatePlan.ratefor each night.cancelfromRESERVEDgives the nights back to the ledger.
import itertools, threading
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Optional
class NoAvailability(Exception):
pass
@dataclass(frozen=True)
class RoomType:
name: str
base_rate: int
count: int
@dataclass
class Room:
number: str
room_type: str
occupied_by: Optional[str] = None
@dataclass
class Reservation:
id: str
guest: str
room_type: str
rng: DateRange
status: ResStatus = ResStatus.RESERVED
room: Optional[Room] = None
@dataclass(frozen=True)
class Bill:
reservation_id: str
nightly: tuple
@property
def total(self) -> int:
return sum(self.nightly)
class Availability:
def __init__(self, types: dict):
self.types = types
self.reserved: dict = defaultdict(int) # (type, day) -> count
self._lock = threading.Lock()
def is_free(self, room_type: str, rng: DateRange) -> bool:
cap = self.types[room_type].count
return all(self.reserved.get((room_type, d), 0) < cap for d in rng.days()) # a read adds no keys
def take(self, room_type: str, rng: DateRange) -> bool:
with self._lock:
if not self.is_free(room_type, rng):
return False
for d in rng.days():
self.reserved[(room_type, d)] += 1
return True
def give_back(self, room_type: str, rng: DateRange) -> None:
with self._lock:
for d in rng.days():
self.reserved[(room_type, d)] = max(0, self.reserved[(room_type, d)] - 1)
class SeasonalRatePlan:
def __init__(self, types: dict, peak_days: set, peak_multiplier: float = 1.5):
self.types, self.peak_days, self.mult = types, peak_days, peak_multiplier
def rate(self, room_type: str, day: int) -> int:
base = self.types[room_type].base_rate
return round(base * self.mult) if day in self.peak_days else base
class Hotel:
def __init__(self, types: list, rooms: list, rates: RatePlan):
self.types = {t.name: t for t in types}
self.rooms = {r.number: r for r in rooms}
self.availability = Availability(self.types)
self.rates = rates
self.reservations: dict = {}
self._ids = itertools.count(1)
self._lock = threading.Lock()
def search(self, rng: DateRange) -> list:
return [t for t in self.types.values() if self.availability.is_free(t.name, rng)]
def reserve(self, guest: str, room_type: str, rng: DateRange) -> Reservation:
if rng.nights() <= 0:
raise ValueError("check-out must be after check-in")
if not self.availability.take(room_type, rng):
raise NoAvailability(f"no {room_type} for {rng}")
res = Reservation(f"R{next(self._ids)}", guest, room_type, rng)
self.reservations[res.id] = res
return res
def cancel(self, res_id: str) -> None:
res = self.reservations[res_id]
with self._lock: # the nights go back exactly once
if res.status is ResStatus.RESERVED:
res.status = ResStatus.CANCELLED
self.availability.give_back(res.room_type, res.rng)
def check_in(self, res_id: str) -> Room:
res = self.reservations[res_id]
with self._lock: # two receptionists, one free room
if res.status is not ResStatus.RESERVED:
raise ValueError(f"cannot check in from {res.status.name}")
room = next((r for r in self.rooms.values() if r.room_type == res.room_type and r.occupied_by is None), None)
if room is None:
raise NoAvailability("no physical room free yet") # ledger says one will free up today
room.occupied_by = res.id
res.room, res.status = room, ResStatus.CHECKED_IN
return room
def check_out(self, res_id: str) -> Bill:
res = self.reservations[res_id]
with self._lock:
if res.status is not ResStatus.CHECKED_IN:
raise ValueError(f"cannot check out from {res.status.name}")
res.room.occupied_by = None
res.status = ResStatus.CHECKED_OUT
nightly = tuple(self.rates.rate(res.room_type, d) for d in res.rng.days())
return Bill(res.id, nightly)
The ledger and the physical rooms are deliberately two different things: the ledger guarantees that a room of the type will exist for every night, while check_in picks whichever physical room is free at the desk. That separation is what lets the hotel sell room types rather than room numbers, and it is the detail interviewers ask about when they say "what does a reservation actually hold".
6. Extensions, concurrency and what interviewers probe
- "Make availability check plus booking atomic." They are:
takedoes both under the ledger lock. Across processes this becomes a transaction or a conditional update per night; see distributed transactions. - "Allow 5 percent overbooking." Change the cap in
is_freetocount * 1.05via a policy on the type; because reservations hold types not rooms, nothing else changes. - "Late check-out and early check-in." The half-open day range handles same-day turnover; a late check-out that needs the night is
give_backplustakefor one more day, failing if the night is full. - "Different rates per season." A new
RatePlan; the bill code already sums per night. - Test first: the last room is given to exactly one of two concurrent reserves; cancelling returns the nights; a guest checking out on day 7 and another checking in on day 7 both fit in one room.
- Common mistake: modelling availability as reservations with overlap scans, or assigning a physical room at reservation time and losing the ability to overbook or reshuffle rooms at the desk.
02Where this question was reported
- Educative · Grokking LLD chapter 18 (breakout is food delivery)
- DesignGurus · DesignGurus case study
- dev.to · Educative top-10: rooms, booking, billing, check-in alerts
- LLD Canvas · Make availability check + booking write atomic
- Codemia · Codemia Medium
- GitHub · awesome-low-level-design, Medium
- YouTube · Video: 'Google Interview Question Solved| Low Level Design Complete Code | Hotel Management System | Part 2' (description attributes it to Amazon)