The 100 problems, solved / Booking, allocation and simulators / 057 · Design a hotel booking system

Design a hotel booking system

Room types, availability by date range, reservations and check-in.

Concepts this design leans on

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 with nights() and overlaps().
  • 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.
FacadeInventoryValues1..*1..*0..*0..1Hoteltypesroomsavailabilityreservationsratessearch(range) -> list[RoomType]reserve(guest, type, range) -> Reservationcancel(res_id)check_in(res_id) -> Roomcheck_out(res_id) -> BillAvailabilityreserved: dict[(type, day), int]lockis_free(type, range)take(type, range)give_back(type, range)Roomnumbertypeoccupied_by: str | NoneRoomTypenamebase_ratecountReservationidguesttyperange: DateRangestatusroom: Room | NoneDateRangecheck_in: intcheck_out: intnights()days()overlaps(other)RatePlanrate(type, day) -> int

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

  1. The guest calls search(range); the hotel returns the types for which Availability.is_free holds.
  2. reserve calls Availability.take; on success it creates a Reservation in RESERVED.
  3. On arrival, check_in finds any unoccupied Room of the type, marks it occupied and moves the reservation to CHECKED_IN.
  4. check_out frees the room, moves to CHECKED_OUT, and builds the bill from RatePlan.rate for each night.
  5. cancel from RESERVED gives 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: take does 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_free to count * 1.05 via 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_back plus take for 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)