Design a movie ticket booking system
Shows, seats, holds that expire, payment and two users racing for one seat.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
- Locks and mutexes — Making a critical section one-at-a-time, and what it costs.
- Time, TTLs and expiry inside objects — Injecting a clock, expiring entries lazily or with a timer wheel.
- From requirements to classes — Nouns, verbs and the use-case walk that finds the classes.
- Optimistic concurrency and versions — Check-then-write with a version number instead of a lock.
- Facade — One simple front door to a complicated subsystem.
01The design, in the six-step framework
1. Requirements and use cases
The booking flow is the interview: holding seats for a few minutes while the customer pays, and refusing the same seat to a second customer who clicks at the same moment.
- A customer browses shows (a movie in a hall at a time) and sees the seat map with available, held and sold seats.
- A customer holds a set of seats for a show; the hold expires after a timeout (say 10 minutes) if not paid.
- A customer pays within the hold and gets a confirmed booking; or releases the hold.
- Two customers holding the same seat at the same instant: exactly one succeeds.
- A customer cancels a confirmed booking before the show and the seats return to available.
Out of scope: search by city, pricing tiers, the payment gateway itself (modelled as a call that returns success or failure). Constraints: a hold on k seats is atomic (all or none); expiry must be handled without a background thread that races the payer; the seat map read must be cheap because it is the most frequent call.
2. Core entities and relationships
Movie,Hall: catalogue data; a hall has a seat layout.Show: one movie in one hall at one time; it owns aSeatInventory.SeatInventory: per seat, aSeatState(available, held with a hold id and expiry, sold with a booking id); all state changes go through it under one lock.Hold: an immutable record of seats, customer, show and expiry instant.Booking: confirmed seats with a status enum (confirmed, cancelled).BookingService: the facade customers use; it coordinates inventory, holds, payment and bookings.Clock: injected so expiry can be tested without sleeping.
A show composes its inventory and the inventory composes the seat states: the seats of a show have no life outside it. The service aggregates shows (the catalogue outlives any booking service instance) and composes holds and bookings, which it creates and retires.
3. Interfaces and key methods
from dataclasses import dataclass
from enum import Enum, auto
from typing import Optional, Protocol
class SeatStatus(Enum):
AVAILABLE = auto()
HELD = auto()
SOLD = auto()
@dataclass
class SeatState:
status: SeatStatus = SeatStatus.AVAILABLE
owner: Optional[str] = None # hold id or booking id
until: Optional[int] = None # hold expiry instant
class Clock(Protocol):
def now(self) -> int: ...
class PaymentGateway(Protocol):
def charge(self, customer: str, amount: int, idempotency_key: str) -> bool: ...
class SeatInventory:
def try_hold(self, seats: list, hold_id: str, until: int, now: int) -> bool:
"""Atomic: all seats must be AVAILABLE or expired-HELD, else nothing changes."""
def extend(self, seats: list, hold_id: str, until: int, now: int) -> bool:
"""Keep a live hold until `until`; False and no change if any seat is not held by hold_id."""
def sell(self, seats: list, hold_id: str, booking_id: str, now: int) -> None:
"""Only seats currently held by hold_id and not expired; raises otherwise."""
def release(self, seats: list, hold_id: str) -> None:
"""Idempotent; ignores seats no longer held by hold_id."""
def view(self, now: int) -> dict:
"""Seat id to effective status, treating expired holds as AVAILABLE."""
Invariants: a seat is SOLD only via sell from a live hold owned by the caller; an expired hold is treated as available by every reader and writer, so there is no gap between "expired" and "cleaned up"; a hold id is minted once and the payment call carries it as the idempotency key.
4. Design patterns and the choices behind them
Lazy expiry with an injected clock
The force: holds must expire, and a background sweeper that frees seats can race the customer who pays at second 599. Storing until on the seat and checking it on every read and write (lazy expiry) makes the rule a pure function of the current time, and injecting a Clock makes the expiry test a one-liner. A sweeper can be added for memory hygiene later; correctness never depends on it.
One lock per show, atomic multi-seat hold
The force: two customers click the same seat. try_hold checks every requested seat and marks them all under the inventory's lock, so the second caller sees the first's hold and gets False. That is a plain mutex scoped to one show, so shows do not contend with each other. The alternative, optimistic versioning per seat, works too and is the right answer across processes with a database; in one process a short critical section is simpler and the interviewer will accept either if you can say why.
Explicit states, and a facade on top
Seat and booking statuses are enums with a small set of legal moves, the lightweight state approach; the customer touches only BookingService, a facade that hides inventory, holds and payment behind five verbs that match the use cases.
5. Core flow and code
- The customer calls
hold(show_id, seats, customer); the service mints a hold id and an expiry from the clock. SeatInventory.try_holdtakes the show lock, verifies every seat is available (or held but expired), marks them held with the hold id and expiry, and returnsTrue; otherwise it changes nothing.- The service records the
Holdand returns it. confirm(hold_id)callsextend, which fails if the hold has expired and otherwise keeps the seats forPAY_SECONDS; it then calls the gateway with the hold id as idempotency key, and on success callssellwhich flips the seats to sold with the new booking id.- If the gateway fails, or the customer calls
release, the seats go back to available only if still owned by that hold.
import itertools, threading
from dataclasses import dataclass
from enum import Enum, auto
from typing import Optional
class BookingStatus(Enum):
CONFIRMED = auto()
CANCELLED = auto()
@dataclass(frozen=True)
class Hold:
id: str
show_id: str
seats: tuple
customer: str
expires_at: int
@dataclass
class Booking:
id: str
show_id: str
seats: tuple
customer: str
status: BookingStatus = BookingStatus.CONFIRMED
class SeatInventory:
def __init__(self, seat_ids: list):
self.seats = {s: SeatState() for s in seat_ids}
self._lock = threading.Lock()
def _free(self, st: SeatState, now: int) -> bool:
return st.status is SeatStatus.AVAILABLE or (st.status is SeatStatus.HELD and st.until <= now)
def try_hold(self, seats: list, hold_id: str, until: int, now: int) -> bool:
with self._lock:
if any(s not in self.seats or not self._free(self.seats[s], now) for s in seats):
return False
for s in seats:
self.seats[s] = SeatState(SeatStatus.HELD, hold_id, until)
return True
def _live(self, st: SeatState, hold_id: str, now: int) -> bool:
return st.status is SeatStatus.HELD and st.owner == hold_id and st.until > now
def extend(self, seats: list, hold_id: str, until: int, now: int) -> bool:
with self._lock:
if not all(self._live(self.seats[s], hold_id, now) for s in seats):
return False
for s in seats:
self.seats[s] = SeatState(SeatStatus.HELD, hold_id, max(until, self.seats[s].until))
return True
def sell(self, seats: list, hold_id: str, booking_id: str, now: int) -> None:
with self._lock:
for s in seats:
if not self._live(self.seats[s], hold_id, now):
raise ValueError(f"seat {s} is not held by {hold_id}")
for s in seats:
self.seats[s] = SeatState(SeatStatus.SOLD, booking_id, None)
def release(self, seats: list, owner: str) -> None:
with self._lock:
for s in seats:
if self.seats[s].owner == owner:
self.seats[s] = SeatState()
def view(self, now: int) -> dict:
with self._lock:
return {s: (SeatStatus.AVAILABLE if self._free(st, now) else st.status) for s, st in self.seats.items()}
@dataclass
class Show:
id: str
movie: str
hall: str
starts_at: int
inventory: SeatInventory
price: int
class BookingService:
HOLD_SECONDS = 600
PAY_SECONDS = 120 # longer than the gateway's own timeout
def __init__(self, shows: list, payment: PaymentGateway, clock: Clock):
self.shows = {s.id: s for s in shows}
self.payment, self.clock = payment, clock
self.holds: dict = {}
self.bookings: dict = {}
self._ids = itertools.count(1)
def seat_map(self, show_id: str) -> dict:
return self.shows[show_id].inventory.view(self.clock.now())
def hold(self, show_id: str, seats: list, customer: str) -> Optional[Hold]:
if not seats or len(set(seats)) != len(seats):
return None
now = self.clock.now()
hold = Hold(f"H{next(self._ids)}", show_id, tuple(seats), customer, now + self.HOLD_SECONDS)
if not self.shows[show_id].inventory.try_hold(seats, hold.id, hold.expires_at, now):
return None
self.holds[hold.id] = hold
return hold
def confirm(self, hold_id: str) -> Booking:
hold = self.holds[hold_id]
now = self.clock.now()
show = self.shows[hold.show_id]
if not show.inventory.extend(list(hold.seats), hold.id, now + self.PAY_SECONDS, now):
raise ValueError("hold expired") # nobody can take the seats while the gateway answers
if not self.payment.charge(hold.customer, show.price * len(hold.seats), idempotency_key=hold.id):
self.release(hold_id)
raise ValueError("payment failed")
booking = Booking(f"B{next(self._ids)}", hold.show_id, hold.seats, hold.customer)
show.inventory.sell(list(hold.seats), hold.id, booking.id, now)
self.bookings[booking.id] = booking
del self.holds[hold_id]
return booking
def release(self, hold_id: str) -> None:
hold = self.holds.pop(hold_id, None)
if hold is not None:
self.shows[hold.show_id].inventory.release(list(hold.seats), hold.id)
def cancel(self, booking_id: str) -> None:
b = self.bookings[booking_id]
if b.status is BookingStatus.CONFIRMED:
if self.clock.now() >= self.shows[b.show_id].starts_at:
raise ValueError("the show has started")
b.status = BookingStatus.CANCELLED
self.shows[b.show_id].inventory.release(list(b.seats), b.id)
Note that release on the inventory is keyed by owner, so a stale release from a hold that already expired and was re-held by someone else is a no-op, and cancelling a booking reuses the same method with the booking id as owner.
6. Extensions, concurrency and what interviewers probe
- "Two users click the same seat." Both call
try_hold; the lock serialises them; the second finds the seat held and getsNone. Say that the lock is per show, so a sold-out blockbuster does not slow other shows. - "The customer pays at the last second."
confirmreads the clock once.extendrenews the live hold under the lock before the gateway is called, so a charge that runs past the expiry cannot hand the seats to someone else and leave the customer charged with no seat.sellre-checks ownership and expiry under the lock, and no sweeper can race it because there is no sweeper. - "Payment succeeded but the process crashed before sell." The idempotency key is the hold id, so re-running
confirmdoes not charge twice; across processes this becomes the idempotency discussion. - "Discounts, pricing tiers, multiple halls." Price per seat category on the
Showand a pricing policy object; the hold-and-sell core is untouched. - Test first: hold then hold again fails; hold, advance the fake clock past expiry, hold again succeeds; confirm after expiry raises and the seats are free.
- Common mistake: checking availability and then marking seats in two separate lock sections, which lets both customers pass the check.
02Where this question was reported
- Hello Interview · Hello Interview breakdown (Medium, premium)
- Educative · Grokking LLD chapter 14
- DesignGurus · DesignGurus case study (Booking and Inventory)
- ByteByteGo · ByteByteGo OOD chapter 5
- dev.to · Educative top-10: search by city, multi-hall, seat booking, discounts
- LLD Mastery · Google-tagged (Easy); concurrent seat allocation, locking
- LLD Canvas · Seat locks with short expiry
- Codemia · Codemia Medium
- GitHub · awesome-low-level-design, Hard
- systemdesign.academy · systemdesign.academy solved
- thita.ai · thita.ai LLD sheet, foundational
- GitHub · awesome-low-level-design, Medium