~learn.

LLD 0511 min+60 XP

Design a Movie Ticket Booking System.

Design seat holds and bookings for a cinema, then make them race

5 code

Lesson · 11 min

Fifty people tap the same two seats for the Friday night show. Exactly one of them gets a timer and a payment page. The rest are told no, at once, and nobody is ever charged for a seat they don't get.

Every movie app in India, BookMyShow and the cinema chains' own apps among them, works the same way from the outside: pick seats, see a countdown, pay before it runs out. That countdown is the whole design problem. Between "these seats look free" and "these seats are yours" sits a hold, and the machine-coding round is about getting the hold right: atomic across several seats, expiring on its own, and consistent with a payment that can arrive late or twice.

This lesson builds the classes and the concurrency in one process. The distributed version, with Redis holds and a payment saga across services, is the IRCTC Tatkal side quest; the seat-hold logic here is the same idea at the size of one cinema.

ExplainerThe whole design: Movie ticket booking

Seats are held all-or-nothing for 10 minutes behind a SeatLockProvider (a dict in the round, Redis SET NX PX in production), and a booking moves HELD to CONFIRMED, EXPIRED or CANCELLED. Fifty users race for A2 and A3 and one wins; payment pay_101 confirms after the hold is re-checked, a retried callback changes nothing, and a payment 601 seconds late is refunded and frees the seat. Per-seat locks are taken in sorted order to avoid deadlock.

How the round runs#

Interviewer

"Design the booking flow for a movie ticketing app. Users pick a show, pick seats, get a few minutes to pay, and get a booking. Two users must never get the same seat. Show me working code with concurrent users. Search, recommendations and the payment gateway itself are out of scope."

A good 90 minutes
  • 0–10 min —
    clarify: hold length, multi-seat holds, what happens to late payments, seat types and prices
  • 10–25 min —
    the catalogue classes, the Booking life cycle, and the SeatLockProvider seam
  • 25–50 min —
    the lock provider: all-or-nothing, TTL, lazy expiry, owner checks
  • 50–70 min —
    BookingService: hold, confirm on payment, cancel, availability
  • 70–80 min —
    a 50-thread race for two seats, then an expired hold
  • 80–90 min —
    follow-ups: Redis holds, seat maps, offers, cancellations and refunds

Requirements and clarifying questions#

You ask They answer What it changes
Can a user hold several seats? Yes, up to 10, and they must be held together. All-or-nothing locking across seats
How long is a hold? 10 minutes. A TTL on each hold; expired holds count as free
What if payment succeeds after the hold expired? Refund it; the seats may already be someone else's. confirm checks the hold and refunds on failure
Can the gateway call us twice for one payment? Yes, callbacks are retried. confirm must be idempotent
Seat types and prices? Regular and premium, priced per show. SeatType enum, a price table on Show
Cancellations of confirmed bookings? Follow-up. Nothing now
Your turn · checkpoint 1not answered

Why not just check "is it free?" and then book?

A user selects A2 and A3. The code checks both are free, then writes the booking. What goes wrong with 50 users, and what is the missing concept?

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

One strong answer

Between the check and the write, other users run the same check and see the same free seats: a check-then-act race, and several bookings for one seat. Even with a lock around check-and-write, you can't hold a lock for the 10 minutes a user takes to pay. The missing concept is a hold: an atomic, short-lived claim ("these seats belong to booking BK00001 until 10:42") taken in one step, checked again when payment arrives, and released or expired if payment never comes.

The classes#

DiagramMovie booking: the classes
%% title: Movie booking: the classes
classDiagram
  class Show {
    +show_id str
    +movie str
    +screen str
    +seats dict
    +prices dict
  }
  class Seat {
    +seat_id str
    +type SeatType
  }
  class Booking {
    +booking_id str
    +user str
    +seat_ids tuple
    +amount int
    +expires_at float
    +status BookingStatus
  }
  class BookingStatus {
    <<enumeration>>
    HELD
    CONFIRMED
    EXPIRED
    CANCELLED
  }
  class SeatLockProvider {
    <<interface>>
    +lock(show, seats, owner, ttl, now) bool
    +holds(show, seats, owner, now) bool
    +unlock(show, seats, owner)
    +is_held(show, seat, now) bool
  }
  class PaymentGateway {
    <<interface>>
    +refund(payment_ref, amount)
  }
  class BookingService {
    +hold(user, show, seats, now) Booking
    +confirm(booking_id, payment_ref, now) Booking
    +cancel_hold(booking_id)
    +available(show, now) list
  }
  Show "1" *-- "many" Seat
  Booking --> BookingStatus
  BookingService --> SeatLockProvider
  BookingService --> PaymentGateway
  BookingService "1" o-- "many" Booking
  SeatLockProvider <|.. InMemorySeatLocks
  PaymentGateway <|.. FakeGateway

Three kinds of state, kept apart on purpose:

  • The catalogue (Show, Seat, prices): read-mostly; frozen dataclasses.
  • Holds (who has claimed which seats until when): short-lived, behind SeatLockProvider. In one process it's a dict; across servers it's Redis with SET NX PX. The service doesn't care which.
  • Sales (which seats are sold for good) and bookings (the user's record): owned by BookingService.

The Booking moves through a small life cycle: HELD → CONFIRMED, or HELD → EXPIRED (paid too late, or never), or HELD → CANCELLED (the user backed out). Only a HELD booking can be confirmed.

The interfaces#

The catalogue and the booking record:

pythonSeatType · 48 lines
# booking.py, part 1: the catalogue (things that rarely change)
from __future__ import annotations

import itertools
import threading
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum


class SeatType(Enum):
    REGULAR = "regular"
    PREMIUM = "premium"


@dataclass(frozen=True)
class Seat:
    seat_id: str  # "A1"
    type: SeatType


@dataclass(frozen=True)
class Show:
    show_id: str
    movie: str
    screen: str
    starts_at: float
    seats: dict[str, Seat]
    prices: dict[SeatType, int]  # rupees


class BookingStatus(Enum):
    HELD = "held"
    CONFIRMED = "confirmed"
    EXPIRED = "expired"
    CANCELLED = "cancelled"


@dataclass
class Booking:
    booking_id: str
    user: str
    show_id: str
    seat_ids: tuple[str, ...]
    amount: int
    expires_at: float
    status: BookingStatus = BookingStatus.HELD
    payment_ref: str | None = None

The seam that decides who owns a seat right now, an in-memory implementation, and the payment gateway (only refund is ours to call; the charge happens on the gateway's page):

pythonSeatLockProvider · 64 lines
# part 2: the seam that decides who owns a seat right now
class SeatLockProvider(ABC):
    @abstractmethod
    def lock(self, show_id: str, seat_ids: list[str], owner: str, ttl: float, now: float) -> bool: ...

    @abstractmethod
    def holds(self, show_id: str, seat_ids: list[str], owner: str, now: float) -> bool: ...

    @abstractmethod
    def unlock(self, show_id: str, seat_ids: list[str], owner: str) -> None: ...

    @abstractmethod
    def is_held(self, show_id: str, seat_id: str, now: float) -> bool: ...


@dataclass
class _Hold:
    owner: str
    expires_at: float


class InMemorySeatLocks(SeatLockProvider):
    """All-or-nothing holds with a TTL; an expired hold is treated as free (lazy expiry)."""

    def __init__(self) -> None:
        self._holds: dict[tuple[str, str], _Hold] = {}
        self._mu = threading.Lock()

    def lock(self, show_id: str, seat_ids: list[str], owner: str, ttl: float, now: float) -> bool:
        with self._mu:
            for s in seat_ids:
                h = self._holds.get((show_id, s))
                if h and h.expires_at > now and h.owner != owner:
                    return False  # one seat taken: take none
            for s in seat_ids:
                self._holds[(show_id, s)] = _Hold(owner, now + ttl)
            return True

    def holds(self, show_id: str, seat_ids: list[str], owner: str, now: float) -> bool:
        with self._mu:
            return all((h := self._holds.get((show_id, s))) is not None and h.owner == owner and h.expires_at > now for s in seat_ids)

    def unlock(self, show_id: str, seat_ids: list[str], owner: str) -> None:
        with self._mu:
            for s in seat_ids:
                if (h := self._holds.get((show_id, s))) and h.owner == owner:
                    del self._holds[(show_id, s)]

    def is_held(self, show_id: str, seat_id: str, now: float) -> bool:
        with self._mu:
            return (h := self._holds.get((show_id, seat_id))) is not None and h.expires_at > now


class PaymentGateway(ABC):
    @abstractmethod
    def refund(self, payment_ref: str, amount: int) -> None: ...


class FakeGateway(PaymentGateway):
    def __init__(self) -> None:
        self.refunds: list[tuple[str, int]] = []

    def refund(self, payment_ref: str, amount: int) -> None:
        self.refunds.append((payment_ref, amount))

Two details in InMemorySeatLocks.lock carry the design. It checks every seat before taking any, so a request for A2 and A3 when A3 is held takes neither: no half-held pairs. And expiry is lazy: nothing sweeps expired holds; a hold whose expires_at has passed is simply treated as free the next time someone asks.

The patterns, and why#

Pattern Where Why it earns its place
Strategy / adapter SeatLockProvider In-memory for the round, Redis or a database row lock in production. The booking logic is identical.
State (as a status enum) BookingStatus Every operation first asks "what state is this booking in?" and only some transitions are legal.
Idempotency key the booking_id is the hold's owner, and the payment reference is checked on confirm A retried callback finds the booking already CONFIRMED with that reference and does nothing.
Facade BookingService The API layer calls three methods and never touches locks or the sold set directly.

Concurrency and edge cases#

The race for seats is closed by the lock provider: one mutex around "check all seats, then take all seats". With per-seat locks instead (needed at scale), always acquire them in sorted seat order: two users wanting A2+A3 and A3+A2 who lock in their own order can deadlock, each holding one seat and waiting for the other. hold() sorts the seat ids for that reason.

The race between a hold and a sale: hold() checks the sold set and takes the lock inside the service's mutex, and confirm() moves seats into the sold set under the same mutex, so a seat can't be both newly held and newly sold.

The race between payment and expiry is the one interviewers wait for. The user pays at 9 minutes 59 seconds; the gateway's callback arrives at 10 minutes 1 second. By then the hold has expired and someone else may hold the seats. confirm() checks the hold again, under the lock, at the moment the callback arrives; if it's gone, the booking becomes EXPIRED and the money is refunded. The seats were never sold twice and the money was never kept.

Edge cases
  • Duplicate seats in one request (A2, A2): deduplicate before locking
  • An unknown seat id: reject before touching any lock
  • The same user retries hold for the same seats: a new booking id is a new owner, so it fails while the first hold lives; return the existing hold instead if the product wants that
  • The gateway calls confirm twice: the second call sees CONFIRMED with the same payment reference and returns the booking
  • A callback for a cancelled hold: refund; never revive a cancelled booking
  • Availability is a snapshot: by the time the seat map renders, a seat may be gone; the hold is the only real check

The code#

The service:

pythonSeatUnavailable · 59 lines
# part 3: the service: hold, confirm, cancel
class SeatUnavailable(Exception): ...


class HoldExpired(Exception): ...


@dataclass
class BookingService:
    shows: dict[str, Show]
    locks: SeatLockProvider
    payments: PaymentGateway
    hold_ttl: float = 600.0  # 10 minutes to pay
    bookings: dict[str, Booking] = field(default_factory=dict)
    _sold: dict[str, set[str]] = field(default_factory=dict)  # show_id -> seats sold for good
    _ids: itertools.count = field(default_factory=lambda: itertools.count(1))
    _mu: threading.Lock = field(default_factory=threading.Lock)

    def hold(self, user: str, show_id: str, seat_ids: list[str], now: float) -> Booking:
        show = self.shows[show_id]
        seats = sorted(set(seat_ids))
        if not seats or any(s not in show.seats for s in seats):
            raise ValueError("unknown seat")
        with self._mu:  # the sold check and the lock must not interleave with a confirm
            if self._sold.get(show_id, set()) & set(seats):
                raise SeatUnavailable("already sold")
            booking_id = f"BK{next(self._ids):05d}"
            if not self.locks.lock(show_id, seats, booking_id, self.hold_ttl, now):
                raise SeatUnavailable("held by someone else")
        amount = sum(show.prices[show.seats[s].type] for s in seats)
        b = Booking(booking_id, user, show_id, tuple(seats), amount, now + self.hold_ttl)
        self.bookings[booking_id] = b
        return b

    def confirm(self, booking_id: str, payment_ref: str, now: float) -> Booking:
        """Called when the gateway reports a successful payment for this booking."""
        b = self.bookings[booking_id]
        with self._mu:  # two callbacks for one booking must not both pass the checks
            if b.status is BookingStatus.CONFIRMED and b.payment_ref == payment_ref:
                return b  # the gateway retried its callback: same booking, nothing new
            if b.status is BookingStatus.HELD and self.locks.holds(b.show_id, list(b.seat_ids), booking_id, now):
                self._sold.setdefault(b.show_id, set()).update(b.seat_ids)
                b.status, b.payment_ref = BookingStatus.CONFIRMED, payment_ref
                self.locks.unlock(b.show_id, list(b.seat_ids), booking_id)  # sold now; the hold is done
                return b
            if b.status is BookingStatus.HELD:
                b.status = BookingStatus.EXPIRED
        self.payments.refund(payment_ref, b.amount)  # money arrived for seats we no longer hold
        raise HoldExpired(f"{b.status.value}: refunded, pick seats again")

    def cancel_hold(self, booking_id: str) -> None:
        b = self.bookings[booking_id]
        if b.status is BookingStatus.HELD:
            b.status = BookingStatus.CANCELLED
            self.locks.unlock(b.show_id, list(b.seat_ids), booking_id)

    def available(self, show_id: str, now: float) -> list[str]:
        sold = self._sold.get(show_id, set())
        return [s for s in self.shows[show_id].seats if s not in sold and not self.locks.is_held(show_id, s, now)]

The race and the late payment:

pythontry_book · 33 lines
# part 4: a race for the same two seats, then an expired hold
if __name__ == "__main__":
    seats = {f"{r}{n}": Seat(f"{r}{n}", SeatType.PREMIUM if r == "A" else SeatType.REGULAR) for r in "AB" for n in range(1, 5)}
    show = Show("S1", "Sholay", "Screen 3", 0.0, seats, {SeatType.REGULAR: 200, SeatType.PREMIUM: 350})
    gateway = FakeGateway()
    svc = BookingService({"S1": show}, InMemorySeatLocks(), gateway)

    wins: list[Booking] = []
    losers: list[str] = []

    def try_book(user: str) -> None:
        try:
            wins.append(svc.hold(user, "S1", ["A2", "A3"], now=0.0))
        except SeatUnavailable:
            losers.append(user)

    ts = [threading.Thread(target=try_book, args=(f"user{i}",)) for i in range(50)]
    for t in ts:
        t.start()
    for t in ts:
        t.join()
    print(len(wins), "hold,", len(losers), "told no")  # 1 hold, 49 told no
    print("free while held:", svc.available("S1", now=60.0))  # A2, A3 are held
    b = svc.confirm(wins[0].booking_id, "pay_101", now=120.0)
    print(b.status.value, b.amount, b.payment_ref)  # confirmed 700 pay_101
    print(svc.confirm(b.booking_id, "pay_101", now=130.0).status.value)  # confirmed: the retry is a no-op

    late = svc.hold("riya", "S1", ["B1"], now=200.0)
    try:
        svc.confirm(late.booking_id, "pay_102", now=200.0 + 601)  # paid 1 s after the 10-minute hold
    except HoldExpired as e:
        print("riya:", e, gateway.refunds)  # expired: refunded ... [('pay_102', 200)]
    print("free:", svc.available("S1", now=900.0))  # B1 is free again; A2, A3 are sold
text6 lines
1 hold, 49 told no
free while held: ['A1', 'A4', 'B1', 'B2', 'B3', 'B4']
confirmed 700 pay_101
confirmed
riya: expired: refunded, pick seats again [('pay_102', 200)]
free: ['A1', 'A4', 'B1', 'B2', 'B3', 'B4']

Fifty threads, one hold. The two premium seats cost ₹350 each, ₹700 together. The retried callback changes nothing. Riya's payment arrives one second after her 10-minute hold and is refunded, and B1 is back on the seat map.

Extending it: the follow-ups#

  • "We have 30 servers." Implement SeatLockProvider with Redis: one key per seat, SET seat:{show}:{seat} {booking_id} NX PX 600000, taken for all seats in one Lua script so it stays all-or-nothing, and released only if the value is still yours. Or lock rows in the database with SELECT … FOR UPDATE SKIP LOCKED. BookingService doesn't change.
  • "Cancel a confirmed booking." A CONFIRMED → CANCELLED transition that removes the seats from the sold set and issues a refund under a cancellation policy, itself a strategy (full refund up to 4 hours before, 50% after).
  • "Offers and convenience fees." Pricing moves behind a PricingStrategy that takes the show, the seats and the user and returns the amount, with coupons as decorators around it.
  • "Don't leave single empty seats." A seat-selection rule checked in hold(): reject a selection that strands one seat between two taken ones. It's a validator, not a change to locking.
  • "Show live seat maps." Publish held, released and sold events; clients subscribe over a WebSocket. The hold is still the only source of truth.
✗ Pitfall

Common mistakes#

What costs candidates the round
  • Check-then-book with no hold, or a lock held for the whole payment
  • Locking seats one by one and leaving the first locked when the second fails
  • Per-seat locks in request order, which can deadlock
  • Trusting the hold at payment time without re-checking it, so a late payment books seats someone else now holds
  • Charging inside the lock, so a slow gateway blocks every other user of the show
  • No idempotency on confirm, so a retried callback creates a second booking
Quick check4 questions · +5 XP each

Movie booking

  1. Q1/4 A user asks for A2 and A3. A3 is held by someone else. What should lock() do?

    Answer & explanation

    Answer: B

    Groups book together. A partial hold strands A2 for 10 minutes for nobody. Check every seat first, then take them all, inside one lock.

  2. Q2/4 The payment callback arrives one second after the hold expired. What should confirm() do?

    Answer & explanation

    Answer: D

    After expiry another user may hold or have bought the seats. Re-check the hold at confirm time; if it's gone, refund. Every rupee ends in a booking or a refund.

  3. Q3/4 Two users lock per-seat mutexes in request order: one asks for A2 then A3, the other A3 then A2. What can happen?

    Answer & explanation

    Answer: A

    Classic lock-ordering deadlock. Acquire per-seat locks in one global order (sorted seat ids) and it can't happen.

  4. Q4/4 Why is the booking id used as the hold's owner instead of the user id?

    Answer & explanation

    Answer: C

    A user can have several attempts (two tabs, a retry after expiry). Owning the hold by booking id means each attempt's confirm, unlock and refund apply to exactly its own seats.

End of LLD 05 · Design a Movie Ticket Booking System
Finish LLD 05Stages clear as you read them (0/1 so far). Skipping ahead doesn't count: marking complete unlocks after ~2 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…