The 100 problems, solved / Booking, allocation and simulators / 056 · Design a movie ticket booking system

Design a movie ticket booking system

Shows, seats, holds that expire, payment and two users racing for one seat.

Concepts this design leans on

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 a SeatInventory.
  • SeatInventory: per seat, a SeatState (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.
FacadePer showRecords1..*1..*0..*0..*BookingServiceshowsholdsbookingspaymentclockseat_map(show_id)hold(show_id, seats, customer) -> Holdconfirm(hold_id) -> Bookingrelease(hold_id)cancel(booking_id)Showidmoviehallstarts_atinventory: SeatInventorySeatInventoryseats: dict[str, SeatState]lock: Locktry_hold(seats, hold_id, until) -> boolextend(seats, hold_id, until) -> boolsell(seats, hold_id, booking_id)release(seats, hold_id)view(now) -> dictSeatStatestatus: SeatStatusowner: str | Noneuntil: int | NoneHoldidshow_idseatscustomerexpires_atBookingidshow_idseatscustomerstatusClocknow() -> int

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

  1. The customer calls hold(show_id, seats, customer); the service mints a hold id and an expiry from the clock.
  2. SeatInventory.try_hold takes the show lock, verifies every seat is available (or held but expired), marks them held with the hold id and expiry, and returns True; otherwise it changes nothing.
  3. The service records the Hold and returns it.
  4. confirm(hold_id) calls extend, which fails if the hold has expired and otherwise keeps the seats for PAY_SECONDS; it then calls the gateway with the hold id as idempotency key, and on success calls sell which flips the seats to sold with the new booking id.
  5. 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 gets None. Say that the lock is per show, so a sold-out blockbuster does not slow other shows.
  • "The customer pays at the last second." confirm reads the clock once. extend renews 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. sell re-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 confirm does not charge twice; across processes this becomes the idempotency discussion.
  • "Discounts, pricing tiers, multiple halls." Price per seat category on the Show and 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