BookMyShow LLD deep dive

Design BookMyShow

BookMyShow LLD: City→Cinema→Screen→Show hierarchy, three-state seats (AVAILABLE → LOCKED → BOOKED) with TTL holds, payment stub, booking confirmation, per-show locking, and pricing as an extension.

What this prompt is really asking

BookMyShow is the product-shaped movie (and events) booking problem: pick a city, find a cinema and show, select seats on a map, hold them while you pay, then confirm. The engineering core is still scarce seats under concurrency — with an explicit payment gap that forces a temporary LOCKED state.
You’ll hear: “Design BookMyShow” or “Design a movie ticket booking system.” Same family as our movie booking walkthrough, but this deep dive leads with the product hierarchy and hold → pay → confirm as v1 — not as a follow-up.
01Clarify

Cities · holds · pay

02Model

Show owns seats

03Code

lock + confirm

04Extend

pricing · events

BookMyShow seat pipeline
Select → LOCK (TTL) → pay → BOOKED, or expire / fail → AVAILABLE.

Analogy: restaurant reservation timer

Hold like restaurant reservation
Hold → pay → seat, or release on no-show.

BookMyShow seats are restaurant tables: you reserve for 10 minutes while you pay. If you don’t show, the host releases the table. Never freeze the whole dining room (global lock) while someone argues with the credit-card machine — hold the seats, release the lock, then pay.

Clarifying questions → locked requirements

  • You: Movies only, or events/sports too? They: Movies for v1; events share the same seat-hold pattern later.
  • You: Search by city + movie + date? They: Yes — list cinemas/shows; then seat map for one show.
  • You: Instant book or hold-then-pay? They: Hold ~5–10 min; pay; confirm or release.
  • You: Same seat, two users? They: Exactly one wins the lock.
  • You: Payment gateway? They: Stub — success/fail callback; no real PSP.
  • You: Variable layouts / seat categories? They: Fixed layout OK for v1; categories/pricing as extension.

Entities: hierarchy + three-state seats

BookMyShow entities
Show owns inventory; BookingService runs hold → pay → confirm.
  • City / Cinema / Screen — location hierarchy. Screen owns a seat layout (list of seat ids or Seat objects with row/col).
  • Movie — catalog title; many Shows reference it.
  • Show — the bookable inventory unit: movie + screen + start time + per-seat status map + lock. Not the Cinema.
  • SeatStatus — AVAILABLE | LOCKED | BOOKED (optional BLOCKED for admin).
  • SeatLock — hold_id, user_id, seat_ids, expires_at.
  • Booking — id, show, user, seats, status (PENDING / CONFIRMED / CANCELLED), amount, payment_id.
  • BookingService — lock_seats, confirm_payment, cancel, search.
  • PaymentGateway (interface) — charge(booking_id, amount) -> PaymentResult stub.

Class design from the requirements

from enum import Enum, auto
from dataclasses import dataclass, field
from threading import Lock
from typing import Optional
import time

class SeatStatus(Enum):
    AVAILABLE = auto()
    LOCKED = auto()
    BOOKED = auto()

class BookingStatus(Enum):
    PENDING = auto()
    CONFIRMED = auto()
    CANCELLED = auto()

@dataclass
class SeatLock:
    hold_id: str
    user_id: str
    seat_ids: list[str]
    expires_at: float

@dataclass
class Booking:
    id: str
    show_id: str
    user_id: str
    seat_ids: list[str]
    amount: float
    status: BookingStatus = BookingStatus.PENDING
    hold_id: Optional[str] = None
    payment_id: Optional[str] = None

class Show:
    # One screening — owns seat inventory + concurrency
    def __init__(self, show_id: str, movie_id: str, screen_id: str,
                 start_ts: float, seat_ids: list[str]):
        self.id = show_id
        self.movie_id, self.screen_id, self.start_ts = movie_id, screen_id, start_ts
        self.seats: dict[str, SeatStatus] = {s: SeatStatus.AVAILABLE for s in seat_ids}
        self.locks: dict[str, SeatLock] = {}  # hold_id → lock
        self._lock = Lock()

    def lock_seats(self, user_id: str, seat_ids: list[str],
                   ttl_s: float = 600) -> str: ...
    def confirm_seats(self, hold_id: str) -> None: ...
    def release_hold(self, hold_id: str) -> None: ...

class Cinema:
    def __init__(self, cinema_id: str, city_id: str, name: str):
        self.id, self.city_id, self.name = cinema_id, city_id, name
        self.screens: dict[str, list[str]] = {}  # screen_id → seat layout
        self.shows: list[Show] = []

class BookingService:
    def __init__(self, payment: "PaymentGateway"):
        self.cities: dict[str, list[Cinema]] = {}
        self.shows: dict[str, Show] = {}
        self.bookings: dict[str, Booking] = {}
        self.payment = payment

    def search_shows(self, city_id: str, movie_id: str, day: str) -> list[Show]: ...
    def create_booking(self, user_id: str, show_id: str,
                       seat_ids: list[str]) -> Booking: ...
    def complete_payment(self, booking_id: str, success: bool) -> Booking: ...
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;

enum SeatStatus { AVAILABLE, LOCKED, BOOKED }
enum BookingStatus { PENDING, CONFIRMED, CANCELLED }

class SeatLock {
    String holdId, userId;
    List<String> seatIds;
    double expiresAt;
    SeatLock(String holdId, String userId, List<String> seatIds, double expiresAt) {
        this.holdId = holdId; this.userId = userId;
        this.seatIds = seatIds; this.expiresAt = expiresAt;
    }
}

class Booking {
    String id, showId, userId;
    List<String> seatIds;
    double amount;
    BookingStatus status = BookingStatus.PENDING;
    String holdId, paymentId; // nullable
}

class Show {
    // One screening — owns seat inventory + concurrency
    String id, movieId, screenId;
    double startTs;
    Map<String, SeatStatus> seats = new HashMap<>();
    Map<String, SeatLock> locks = new HashMap<>(); // holdId → lock
    private final ReentrantLock lock = new ReentrantLock();

    Show(String showId, String movieId, String screenId, double startTs, List<String> seatIds) {
        this.id = showId; this.movieId = movieId; this.screenId = screenId; this.startTs = startTs;
        for (String s : seatIds) seats.put(s, SeatStatus.AVAILABLE);
    }

    String lockSeats(String userId, List<String> seatIds, double ttlS) { /* ... */ return null; }
    void confirmSeats(String holdId) { /* ... */ }
    void releaseHold(String holdId) { /* ... */ }
}

class Cinema {
    String id, cityId, name;
    Map<String, List<String>> screens = new HashMap<>(); // screenId → seat layout
    List<Show> shows = new ArrayList<>();
}

class BookingService {
    Map<String, List<Cinema>> cities = new HashMap<>();
    Map<String, Show> shows = new HashMap<>();
    Map<String, Booking> bookings = new HashMap<>();
    PaymentGateway payment;

    BookingService(PaymentGateway payment) { this.payment = payment; }

    List<Show> searchShows(String cityId, String movieId, String day) { /* ... */ return List.of(); }
    Booking createBooking(String userId, String showId, List<String> seatIds) { /* ... */ return null; }
    Booking completePayment(String bookingId, boolean success) { /* ... */ return null; }
}

Implementation highlights

Spend interview time on Show.lock_seats / confirm_seats / release_hold and the BookingService payment orchestration.
def _purge_expired(self, now: float) -> None:
    expired = [hid for hid, h in self.locks.items() if h.expires_at <= now]
    for hid in expired:
        hold = self.locks.pop(hid)
        for s in hold.seat_ids:
            if self.seats[s] is SeatStatus.LOCKED:
                self.seats[s] = SeatStatus.AVAILABLE

def lock_seats(self, user_id: str, seat_ids: list[str],
               ttl_s: float = 600) -> str:
    with self._lock:
        now = time.time()
        self._purge_expired(now)
        for s in seat_ids:
            if s not in self.seats or self.seats[s] is not SeatStatus.AVAILABLE:
                raise RuntimeError(f"unavailable: {s}")
        hold_id = new_id()
        for s in seat_ids:
            self.seats[s] = SeatStatus.LOCKED
        self.locks[hold_id] = SeatLock(hold_id, user_id, list(seat_ids),
                                       now + ttl_s)
        return hold_id

def confirm_seats(self, hold_id: str) -> None:
    with self._lock:
        now = time.time()
        hold = self.locks.get(hold_id)
        if hold is None or hold.expires_at <= now:
            self.locks.pop(hold_id, None)
            raise RuntimeError("hold missing/expired")
        for s in hold.seat_ids:
            self.seats[s] = SeatStatus.BOOKED
        del self.locks[hold_id]

def release_hold(self, hold_id: str) -> None:
    with self._lock:
        hold = self.locks.pop(hold_id, None)
        if hold is None:
            return
        for s in hold.seat_ids:
            if self.seats[s] is SeatStatus.LOCKED:
                self.seats[s] = SeatStatus.AVAILABLE
void purgeExpired(double now) {
    List<String> expired = new ArrayList<>();
    for (var e : locks.entrySet())
        if (e.getValue().expiresAt <= now) expired.add(e.getKey());
    for (String hid : expired) {
        SeatLock hold = locks.remove(hid);
        for (String s : hold.seatIds)
            if (seats.get(s) == SeatStatus.LOCKED) seats.put(s, SeatStatus.AVAILABLE);
    }
}

String lockSeats(String userId, List<String> seatIds, double ttlS) {
    lock.lock();
    try {
        double now = System.currentTimeMillis() / 1000.0;
        purgeExpired(now);
        for (String s : seatIds)
            if (!seats.containsKey(s) || seats.get(s) != SeatStatus.AVAILABLE)
                throw new RuntimeException("unavailable: " + s);
        String holdId = newId();
        for (String s : seatIds) seats.put(s, SeatStatus.LOCKED);
        locks.put(holdId, new SeatLock(holdId, userId, new ArrayList<>(seatIds), now + ttlS));
        return holdId;
    } finally {
        lock.unlock();
    }
}

void confirmSeats(String holdId) {
    lock.lock();
    try {
        double now = System.currentTimeMillis() / 1000.0;
        SeatLock hold = locks.get(holdId);
        if (hold == null || hold.expiresAt <= now) {
            locks.remove(holdId);
            throw new RuntimeException("hold missing/expired");
        }
        for (String s : hold.seatIds) seats.put(s, SeatStatus.BOOKED);
        locks.remove(holdId);
    } finally {
        lock.unlock();
    }
}

void releaseHold(String holdId) {
    lock.lock();
    try {
        SeatLock hold = locks.remove(holdId);
        if (hold == null) return;
        for (String s : hold.seatIds)
            if (seats.get(s) == SeatStatus.LOCKED) seats.put(s, SeatStatus.AVAILABLE);
    } finally {
        lock.unlock();
    }
}
def create_booking(self, user_id: str, show_id: str,
                   seat_ids: list[str]) -> Booking:
    show = self.shows[show_id]
    hold_id = show.lock_seats(user_id, seat_ids)
    amount = self._price(show, seat_ids)  # flat rate v1
    booking = Booking(
        id=new_id(), show_id=show_id, user_id=user_id,
        seat_ids=list(seat_ids), amount=amount, hold_id=hold_id,
    )
    self.bookings[booking.id] = booking
    return booking  # client now calls payment

def complete_payment(self, booking_id: str, success: bool) -> Booking:
    booking = self.bookings[booking_id]
    show = self.shows[booking.show_id]
    if booking.status is not BookingStatus.PENDING:
        raise RuntimeError("not pending")
    if success:
        show.confirm_seats(booking.hold_id)
        booking.status = BookingStatus.CONFIRMED
        booking.payment_id = new_id()
    else:
        show.release_hold(booking.hold_id)
        booking.status = BookingStatus.CANCELLED
    return booking

# Alternative: BookingService.checkout() calls payment.charge() inline,
# then complete_payment — keep charge outside the Show lock.
Booking createBooking(String userId, String showId, List<String> seatIds) {
    Show show = shows.get(showId);
    String holdId = show.lockSeats(userId, seatIds);
    double amount = price(show, seatIds); // flat rate v1
    Booking booking = new Booking();
    booking.id = newId(); booking.showId = showId; booking.userId = userId;
    booking.seatIds = new ArrayList<>(seatIds); booking.amount = amount; booking.holdId = holdId;
    bookings.put(booking.id, booking);
    return booking; // client now calls payment
}

Booking completePayment(String bookingId, boolean success) {
    Booking booking = bookings.get(bookingId);
    Show show = shows.get(booking.showId);
    if (booking.status != BookingStatus.PENDING) throw new RuntimeException("not pending");
    if (success) {
        show.confirmSeats(booking.holdId);
        booking.status = BookingStatus.CONFIRMED;
        booking.paymentId = newId();
    } else {
        show.releaseHold(booking.holdId);
        booking.status = BookingStatus.CANCELLED;
    }
    return booking;
}
// Alternative: checkout() calls payment.charge() inline,
// then completePayment — keep charge outside the Show lock.

Verification traces

  • Happy path — lock A5,A6 → Booking PENDING → payment OK → seats BOOKED, booking CONFIRMED.
  • Concurrent same seat — two lock_seats(A5); lock serializes; second raises; exactly one hold.
  • Payment fail — release_hold → seats AVAILABLE; booking CANCELLED.
  • TTL expiry — purge before next lock → stale LOCKED becomes AVAILABLE; confirm on expired hold fails.
  • Double confirm — second complete_payment on CONFIRMED rejected.
  • Happy: Pick show → lock seats → pay → BOOKED tickets issued.
  • Failure: TTL expires before pay → seats AVAILABLE; pay fails → release.
  • Concurrency: Two users lock same seat — only first transition AVAILABLE→LOCKED wins.

Extensibility follow-ups

  • PricingStrategy — price(show, seat) -> money; category (REGULAR/PREMIUM), weekday vs weekend. Don’t bake amounts into SeatStatus.
  • Cancel / refund — CONFIRMED → CANCELLED; seats AVAILABLE if before show start; refund via PaymentGateway (idempotent).
  • Seat categories / BLOCKED — layout metadata on Screen; BLOCKED never offered in the map.
  • Events / sports — same Show + seat-hold machine; swap Movie for Event catalog.
  • Scale hint — Redis SET NX PX per show:seat for distributed holds; durable Booking in DB; waiting room for tentpole on-sales. Say it; don’t implement Redis on the whiteboard.

Common interview pitfalls

These mistakes show up constantly on this prompt. Name the trap, then show the fix in your design — don’t wait for the interviewer to catch you.

  • City→Cinema→Hall→Show hierarchy missing — flat “movie list” that can’t price by show.
  • Same pitfalls as movie booking: no LOCKED TTL, payment after BOOKED, race on seats.
  • Overbuilding events/sports before seats for movies work.
  • Search that mutates inventory.
  • God class BookMyShowApp doing payment, search, and seat mutation.
  • Ignoring that show identity includes screen + time, not just movie id.

Interview script (say this)

Read this once out loud before a mock. It’s the spine of a strong answer — not a script to recite robotically.

  1. BookMyShow is product-shaped booking: city, cinema, show, seats, payment stub.
  2. Hierarchy: City→Cinema→Screen→Show; Show owns seat states AVAILABLE→LOCKED→BOOKED.
  3. APIs: browse shows, get seat map, lock seats with TTL, confirm with payment port, cancel.
  4. Payment is an interface — success confirms, failure releases locks.
  5. I’ll trace double booking on the same seat and expired lock reclaim.
  6. Concurrency: lock at Show for seat transitions.
  7. Staff: add events by new Show subtype or category without rewriting seat FSM.

Extra verification traces

Walk these three traces on the board. If you can narrate them cleanly, your implementation section usually follows.

def confirm(self, lock_id, payment):
    with self.show.lock:
        hold = self.holds[lock_id]
        if hold.expired(self.now()):
            self.release(hold)
            raise RuntimeError("expired")
        payment.charge(hold.amount)  # port
        for s in hold.seats:
            self.seats[s].book()
        return Ticket.from_hold(hold)
Ticket confirm(String lockId, PaymentGateway payment) {
    show.lock.lock();
    try {
        Hold hold = holds.get(lockId);
        if (hold.expired(now())) {
            release(hold);
            throw new RuntimeException("expired");
        }
        payment.charge(hold.amount); // port
        for (String s : hold.seats) seats.get(s).book();
        return Ticket.fromHold(hold);
    } finally {
        show.lock.unlock();
    }
}

Staff-level follow-ups

At staff+, they twist the prompt. Answer in one sentence that names the seam — don’t redesign the whole board.

  • Events & sports? — Generalize Show→EventOccurrence; seat map may become sectioned VenueMap.
  • Gift cards? — PaymentPort composition — charge splits across instruments.
  • Fraud holds? — User risk score shortens TTL / challenges before lock.
  • Fan-out search? — Read models for browse; write path remains Show.seat FSM.

Complete solution: lock → pay → confirm

Integrates TTL holds with payment outside the Show lock — the complete BookMyShow concurrency story.
from enum import Enum, auto
from threading import Lock
from dataclasses import dataclass
from typing import Optional
import time, uuid

class SeatStatus(Enum):
    AVAILABLE = auto()
    LOCKED = auto()
    BOOKED = auto()

@dataclass
class Hold:
    id: str
    user_id: str
    seats: list[str]
    expires_at: float

class Show:
    def __init__(self, seat_ids: list[str]):
        self.seats = {s: SeatStatus.AVAILABLE for s in seat_ids}
        self.holds: dict[str, Hold] = {}
        self._lock = Lock()

    def _purge(self, now: float) -> None:
        for hid, h in list(self.holds.items()):
            if h.expires_at <= now:
                for s in h.seats:
                    if self.seats[s] is SeatStatus.LOCKED:
                        self.seats[s] = SeatStatus.AVAILABLE
                del self.holds[hid]

    def lock_seats(self, user_id: str, seats: list[str], ttl: float = 600) -> str:
        with self._lock:
            now = time.time()
            self._purge(now)
            for s in seats:
                if self.seats.get(s) is not SeatStatus.AVAILABLE:
                    raise RuntimeError(s)
            hid = str(uuid.uuid4())
            for s in seats:
                self.seats[s] = SeatStatus.LOCKED
            self.holds[hid] = Hold(hid, user_id, list(seats), now + ttl)
            return hid

    def confirm(self, hold_id: str) -> None:
        with self._lock:
            now = time.time()
            h = self.holds.get(hold_id)
            if not h or h.expires_at <= now:
                self.holds.pop(hold_id, None)
                raise RuntimeError("expired")
            for s in h.seats:
                self.seats[s] = SeatStatus.BOOKED
            del self.holds[hold_id]

    def release(self, hold_id: str) -> None:
        with self._lock:
            h = self.holds.pop(hold_id, None)
            if not h:
                return
            for s in h.seats:
                if self.seats[s] is SeatStatus.LOCKED:
                    self.seats[s] = SeatStatus.AVAILABLE

# BookingService.create_booking → lock_seats (ms)
# then payment WITHOUT show lock
# then confirm or release
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.UUID;

enum SeatStatus { AVAILABLE, LOCKED, BOOKED }

class Hold {
    String id, userId;
    List<String> seats;
    double expiresAt;
    Hold(String id, String userId, List<String> seats, double expiresAt) {
        this.id = id; this.userId = userId; this.seats = seats; this.expiresAt = expiresAt;
    }
}

class Show {
    Map<String, SeatStatus> seats = new HashMap<>();
    Map<String, Hold> holds = new HashMap<>();
    private final ReentrantLock lock = new ReentrantLock();

    Show(List<String> seatIds) {
        for (String s : seatIds) seats.put(s, SeatStatus.AVAILABLE);
    }

    void purge(double now) {
        for (var e : new ArrayList<>(holds.entrySet())) {
            if (e.getValue().expiresAt <= now) {
                for (String s : e.getValue().seats)
                    if (seats.get(s) == SeatStatus.LOCKED) seats.put(s, SeatStatus.AVAILABLE);
                holds.remove(e.getKey());
            }
        }
    }

    String lockSeats(String userId, List<String> seatsToLock, double ttl) {
        lock.lock();
        try {
            double now = System.currentTimeMillis() / 1000.0;
            purge(now);
            for (String s : seatsToLock)
                if (seats.get(s) != SeatStatus.AVAILABLE) throw new RuntimeException(s);
            String hid = UUID.randomUUID().toString();
            for (String s : seatsToLock) seats.put(s, SeatStatus.LOCKED);
            holds.put(hid, new Hold(hid, userId, new ArrayList<>(seatsToLock), now + ttl));
            return hid;
        } finally {
            lock.unlock();
        }
    }

    void confirm(String holdId) {
        lock.lock();
        try {
            double now = System.currentTimeMillis() / 1000.0;
            Hold h = holds.get(holdId);
            if (h == null || h.expiresAt <= now) {
                holds.remove(holdId);
                throw new RuntimeException("expired");
            }
            for (String s : h.seats) seats.put(s, SeatStatus.BOOKED);
            holds.remove(holdId);
        } finally {
            lock.unlock();
        }
    }

    void release(String holdId) {
        lock.lock();
        try {
            Hold h = holds.remove(holdId);
            if (h == null) return;
            for (String s : h.seats)
                if (seats.get(s) == SeatStatus.LOCKED) seats.put(s, SeatStatus.AVAILABLE);
        } finally {
            lock.unlock();
        }
    }
}
// BookingService.createBooking → lockSeats (ms)
// then payment WITHOUT show lock
// then confirm or release

Concurrency cases

  • Double lock same seats — second lock_seats fails.
  • Confirm after TTL — purge/confirm sees expired → fail; seats AVAILABLE for others.
  • Pay success + confirm race with expire job — both under show lock; confirm wins only if hold still present.
  • Never call payment inside with self._lock.

← Lattice