Design a Movie Ticket Booking System

Design the classes behind a cinema booking app. A city has several cinemas, each with screens, and every screen has a fixed seat map with regular and premium seats. A movie plays as a show: one screen, one start time, its own prices.

A customer opens a show, sees which seats are free, picks a few, and gets a few minutes to pay while those seats are reserved for them. If they pay, they get a booking; if they walk away or the card is declined, the seats go back on sale.

The hard part is fairness under contention: on opening night, two people tap the same seat in the same instant, and exactly one of them may get it. In 45 minutes, model the domain, walk through hold, pay and confirm, and show in code how you stop a seat from being sold twice.

Use the LLD board for your class diagram and the LLD code tab to write and run your classes.

Design Path · Low-Level Design · problem 16 of 18

Ask before you design. Try answering each yourself, then compare.

  1. Is the seat map fixed per screen, or can each show change it?
    Typical answer

    Fixed per screen. A show references a screen and adds its own prices per seat type.

  2. How long is a seat reserved while the user pays?
    Typical answer

    A configurable hold, five minutes by default. After that the seats return to sale automatically.

  3. Must a multi-seat selection be all-or-nothing?
    Typical answer

    Yes. A family picking four seats should get all four or a clear error, never two of them.

  4. Is this a single process or many servers?
    Typical answer

    Design the in-memory core for one process with threads, then explain what changes with several servers and a shared database.

  5. Who handles payment?
    Typical answer

    An external gateway behind an interface. It can decline, be slow, or succeed after our hold has expired.

  6. Do we need cancellations and refunds?
    Typical answer

    Mention cancellation as a follow-up; the core is hold, pay, confirm.

Separate what never changes (screen and seat map) from what is per show (prices, which seats are sold).
A seat in a show has three states. What are they, and which ones expire on their own?
Checking that a seat is free and then reserving it must look like one step to every other thread. What is the smallest scope you can lock?
Payment is slow and can fail. Should you hold a lock while waiting for it? What if the hold expires mid-payment?

Requirements

Functional

  • List shows and the free seats for a show.
  • Hold one or more seats for a user for a limited time, all-or-nothing.
  • Check out a hold: charge the user, then confirm the booking.
  • Release seats when a hold expires, the user abandons it, or payment is declined.

Non-functional

  • A seat is never sold twice, even with hundreds of concurrent requests on one show.
  • Hold and availability calls finish in under 50 ms in memory; payment may take seconds.
  • Contention on one popular show must not slow other shows down.

Out of scope

  • Movie search, reviews and recommendations.
  • Dynamic pricing and discount codes (mention as an extension).
  • Distributed deployment details beyond a short discussion.

Class design

Static catalogue. A Screen owns an immutable map of Seats, each tagged with a SeatType. A Show points at a screen and carries the per-show state: prices by seat type, the set of booked seat ids, the live holds and a lock. Keeping the mutable state on the show means two different shows never contend with each other.

Holds. HoldManager is the only class that changes seat state. Under the show lock it computes the taken seats (booked plus unexpired holds), rejects the request if any requested seat is taken, and otherwise records a Hold with an expiry time from an injected Clock. Expired holds are dropped lazily whenever the show is read, so no background sweeper is needed and tests can fast-forward time.

Checkout. BookingService.checkout confirms the hold is live, charges the PaymentGateway outside any lock, then asks HoldManager.commit to move the seats into booked. If the hold lapsed during payment, the commit fails and the charge is refunded. A declined card releases the hold at once.

Extensibility. New gateways implement the interface; pricing rules can move into a strategy; a database-backed store can replace the in-memory maps without touching the service's flow.

Open the LLD board and press Reference to see the class diagram.

Patterns used

  • Strategy: PaymentGateway hides the provider, so tests use a fake and production can swap card, wallet or UPI providers.
  • Facade: BookingService gives the API one entry point and hides the hold, payment and commit steps behind it.
  • State (lightweight): a seat in a show moves free -> held -> booked, and a hold can lapse back to free; HoldManager owns every transition.
  • Dependency injection: the Clock and gateway are passed in, which makes expiry and failure paths testable.

Reference implementation

import threading
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Set


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


@dataclass(frozen=True)
class Seat:
    seat_id: str
    seat_type: SeatType


@dataclass
class Screen:
    name: str
    seats: Dict[str, Seat]


@dataclass
class Show:
    show_id: str
    movie: str
    screen: Screen
    prices: Dict[SeatType, int]
    booked: Set[str] = field(default_factory=set)
    holds: Dict[str, "Hold"] = field(default_factory=dict)
    lock: threading.Lock = field(default_factory=threading.Lock, repr=False)

    def total(self, seat_ids: List[str]) -> int:
        return sum(self.prices[self.screen.seats[s].seat_type] for s in seat_ids)


@dataclass
class Hold:
    hold_id: str
    user: str
    seat_ids: List[str]
    expires_at: float


@dataclass
class Booking:
    booking_id: str
    show_id: str
    user: str
    seat_ids: List[str]
    amount: int


class Clock:
    """Injectable time source so tests and demos can fast-forward."""
    def __init__(self):
        self.now = 0.0

    def advance(self, seconds: float):
        self.now += seconds


class HoldManager:
    """Owns temporary seat holds. Every read or change of a show's seats happens under that show's lock."""
    def __init__(self, clock: Clock, ttl_seconds: int = 300):
        self.clock, self.ttl = clock, ttl_seconds

    def _taken(self, show: Show) -> Set[str]:
        """Seats that are booked or under a live hold. Caller holds show.lock."""
        for hid in [h.hold_id for h in show.holds.values() if h.expires_at <= self.clock.now]:
            del show.holds[hid]                       # lazy expiry, no sweeper thread needed
        return show.booked | {s for h in show.holds.values() for s in h.seat_ids}

    def available(self, show: Show) -> List[str]:
        with show.lock:
            taken = self._taken(show)
            return sorted(s for s in show.screen.seats if s not in taken)

    def hold(self, show: Show, user: str, seat_ids: List[str]) -> Hold:
        """All-or-nothing: hold every requested seat or none of them."""
        with show.lock:                               # check-and-set must be atomic
            taken = self._taken(show)
            clash = [s for s in seat_ids if s in taken or s not in show.screen.seats]
            if clash:
                raise ValueError(f"seats not available: {clash}")
            h = Hold(uuid.uuid4().hex[:6], user, list(seat_ids), self.clock.now + self.ttl)
            show.holds[h.hold_id] = h
            return h

    def get_active(self, show: Show, hold_id: str) -> Hold:
        with show.lock:
            self._taken(show)
            if hold_id not in show.holds:
                raise ValueError("hold expired or unknown")
            return show.holds[hold_id]

    def commit(self, show: Show, hold_id: str):
        """Turn a live hold into booked seats; fails if it expired meanwhile."""
        with show.lock:
            self._taken(show)
            h = show.holds.pop(hold_id, None)
            if not h:
                raise ValueError("hold expired or unknown")
            show.booked.update(h.seat_ids)

    def release(self, show: Show, hold_id: str):
        with show.lock:
            show.holds.pop(hold_id, None)


class PaymentGateway(ABC):
    @abstractmethod
    def charge(self, user: str, amount: int) -> bool: ...

    @abstractmethod
    def refund(self, user: str, amount: int): ...


class FakeCardGateway(PaymentGateway):
    def __init__(self, declined_users=()):
        self.declined = set(declined_users)

    def charge(self, user, amount):
        return user not in self.declined

    def refund(self, user, amount):
        print(f"  refunded {amount} to {user}")


class BookingService:
    def __init__(self, holds: HoldManager, payments: PaymentGateway):
        self.holds, self.payments = holds, payments
        self.shows: Dict[str, Show] = {}
        self.bookings: Dict[str, Booking] = {}

    def add_show(self, show: Show):
        self.shows[show.show_id] = show

    def available_seats(self, show_id: str) -> List[str]:
        return self.holds.available(self.shows[show_id])

    def hold_seats(self, show_id: str, user: str, seat_ids: List[str]) -> Hold:
        return self.holds.hold(self.shows[show_id], user, seat_ids)

    def checkout(self, show_id: str, hold_id: str) -> Booking:
        """Charge for a live hold, then commit it. Refund if the hold lapsed during payment."""
        show = self.shows[show_id]
        h = self.holds.get_active(show, hold_id)
        amount = show.total(h.seat_ids)
        if not self.payments.charge(h.user, amount):  # never call the gateway while holding a lock
            self.holds.release(show, hold_id)
            raise ValueError("payment declined; seats released")
        try:
            self.holds.commit(show, hold_id)
        except ValueError:
            self.payments.refund(h.user, amount)      # hold lapsed while paying
            raise
        b = Booking(uuid.uuid4().hex[:6], show_id, h.user, h.seat_ids, amount)
        self.bookings[b.booking_id] = b
        return b


if __name__ == "__main__":
    seats = {f"{r}{n}": Seat(f"{r}{n}", SeatType.PREMIUM if r == "C" else SeatType.REGULAR)
             for r in "ABC" for n in range(1, 4)}
    clock = Clock()
    svc = BookingService(HoldManager(clock, ttl_seconds=300), FakeCardGateway(declined_users={"carol"}))
    svc.add_show(Show("s1", "Dune", Screen("Screen 1", seats), {SeatType.REGULAR: 12, SeatType.PREMIUM: 18}))

    results, start = {}, threading.Barrier(2)
    def pick(user):
        start.wait()
        try:
            results[user] = svc.hold_seats("s1", user, ["C2"]).hold_id
        except ValueError as e:
            results[user] = str(e)
    threads = [threading.Thread(target=pick, args=(u,)) for u in ("alice", "bob")]
    [t.start() for t in threads]
    [t.join() for t in threads]
    print("race for C2:", results)
    winner = next(u for u, r in results.items() if not r.startswith("seats"))
    print(f"{winner} books:", svc.checkout("s1", results[winner]))

    h = svc.hold_seats("s1", "dave", ["A1", "A2"])
    print("dave holds A1,A2; available:", svc.available_seats("s1"))
    clock.advance(301)
    print("6 minutes later, available:", svc.available_seats("s1"))
    try:
        svc.checkout("s1", h.hold_id)
    except ValueError as e:
        print("dave checkout:", e)

    h = svc.hold_seats("s1", "carol", ["B1"])
    try:
        svc.checkout("s1", h.hold_id)
    except ValueError as e:
        print("carol checkout:", e, "| B1 free again:", "B1" in svc.available_seats("s1"))

Trade-offs

  • A lock per show gives simple correctness but serialises all buyers of one show; per-seat locks add concurrency but risk deadlocks on multi-seat holds unless you lock in sorted order.
  • Lazy expiry needs no background thread but leaves stale holds in memory until the show is next read.
  • Charging before commit risks a refund when the hold lapses; committing before charging risks selling seats that are never paid for.
  • A short hold TTL frees seats quickly but frustrates slow payers; a long one does the reverse.

What a strong answer shows

  • Separates immutable layout (screen, seats) from per-show mutable state.
  • Makes check-and-reserve atomic and scopes the lock to one show.
  • Gives holds an expiry and explains how expired holds are reclaimed.
  • Keeps the slow payment call outside the critical section and handles a hold that expires mid-payment.
  • Makes multi-seat holds all-or-nothing.
  • Explains how the same guarantee holds across servers (conditional update or unique constraint in the database).

Mistakes to avoid

  • Checking availability and reserving in two separate steps with no lock, which lets two users win the same seat.
  • One global lock for the whole system, so a blockbuster premiere freezes every other show.
  • Calling the payment gateway while holding the seat lock.
  • Holds with no expiry, so abandoned carts lock seats forever.
  • Storing seat status on the Seat object itself, which breaks as soon as a screen hosts a second show.

Follow-ups to expect

  • How does this work with ten app servers? (Per-seat rows with a conditional update like UPDATE ... WHERE status='free', or a unique constraint on (show, seat).)
  • How would you add cancellations? (A booking state machine with a refund call; cancelled seats return to free.)
  • How do you stop one user from hoarding seats? (Cap seats per hold and live holds per user.)
  • How would you show seat maps updating live? (Publish seat-state changes per show to subscribed clients.)

Practice next

Next Post Previous Post
No Comment
Add Comment
comment url