Design BookMyShow

Movie ticket booking: the entity ladder from City to Seat, seat-lock with TTL, and the double-booking race — the LLD where concurrency IS the question.

LLDOODconcurrencybooking

Scope it first

"Browse movies by city, pick a show, see the seat map, hold seats, pay, get a ticket. One city's catalog, ignore payments beyond success/failure callbacks, ignore pricing tiers — OK?" And know what you're walking into: every other classic hides its concurrency question at the end — BookMyShow is the concurrency question. Two users, one seat, same second; everything else is supporting cast.

Entities & relationships

The catalog is a ladder of containment — get the chain right and the rest follows:

The non-obvious modeling decision — the one graders watch for: a Seat is physical and permanent; its availability belongs to the Show. Screen 4's seat F7 exists once; whether it's free is a different fact for the 6 PM and 9 PM shows. So Show owns Map<Seat, SeatState> where SeatState = AVAILABLE | LOCKED | BOOKED. Hanging state on Seat itself is the classic mistake here.

BookingStatus: PENDING → CONFIRMED | EXPIRED | CANCELLED — a small state machine again, because payment isn't instant.

The flow: lock → pay → confirm

Booking can't be atomic — payment takes a minute and might fail — so the flow is two-phase with a TTL:

  1. Lock. User picks seats → atomically transition each from AVAILABLE → LOCKED(user, expiresAt = now + 7 min). Any seat unavailable → whole request fails (all-or-nothing; nobody wants half a family's seats).
  2. Pay. A PENDING Booking exists; user completes payment with the gateway.
  3. Confirm or release. Payment success → LOCKED → BOOKED, booking CONFIRMED. Failure or timeout → lock expires, seats return to AVAILABLE, booking EXPIRED.

The TTL is the design's quiet hero: no user action is ever required to free a seat — abandonment self-heals. Expiry via lazy check (isLocked() = state == LOCKED && now < expiresAt) plus a sweeper; lazy checking means correctness never depends on the sweeper being on time.

Drive a single seat through that machine. Run the happy path lock → pay, then try the abandonment path (lock, then expire — the TTL returning the seat for free), and the illegal pay before lock — which is refused, because you can't pay for a seat you never held:

Seat booking (lock → pay)time O(1) per eventspace O(states)
lockpayexpirerefundFreeLockedBooked
events:lockpayrefund

1/4Start in Free. Each event is handled by the current state — the State pattern moves this branching out of one giant switch and into the state objects themselves.

state = Free

Here's the whole two-phase flow with the race resolving correctly — two users, one pair of seats, 80 ms apart:

The race, and the only acceptable answer

Two users hit "lock F7" in the same 10 ms. A read-then-write (if seat is available → mark locked) lets both see AVAILABLE and both proceed — double-booking, the cinema equivalent of the parking lot's last spot and a balance double-spend. The invariant must be enforced at the point of mutation:

Python
class ShowSeating:
    def lock_seats(self, seats, user):
        with self.lock:                       # one mutex per SHOW — not global
            for s in seats:                   # check ALL first…
                if not self.is_available(s):
                    raise SeatUnavailable(s)
            for s in seats:                   # …then mutate (all-or-nothing)
                self.states[s] = Locked(user, now() + LOCK_TTL)
        return Booking(pending, seats, user)

In a database (the production reality), same idea, different spelling — one atomic conditional write:

UPDATE show_seats SET state = 'LOCKED', user_id = :u, expires_at = :t
WHERE show_id = :s AND seat_id IN (:seats)
  AND (state = 'AVAILABLE' OR (state = 'LOCKED' AND expires_at < now()));
-- affected rows < requested seats ⇒ roll back, tell the user

Lock granularity is the follow-up: a global lock serializes the whole country's bookings; per-show locking is natural (contention is per-show by definition) and shows you think about lock scope.

Where the patterns live

  • State — seat availability and booking lifecycle; illegal transitions (BOOKED → LOCKED) become unrepresentable.
  • Strategy — seat recommendation ("best available": center-out, contiguous-block-finding) and later pricing.
  • Observer — seat-map UIs subscribe to seat-state changes (everyone watching the map sees F7 grey out live); notification service subscribes to booking confirmations.
  • FacadeBookingService.book(show, seats, user) orchestrating lock → payment → confirm, so controllers never touch seat maps.
Say the invariant, then the mechanism

The sentence graders wait for: "The invariant is a seat is never LOCKED or BOOKED by two users for the same show; I enforce it with an atomic check-and-set under a per-show lock, with TTL so abandoned locks self-release." Mechanism without the invariant sounds memorized; invariant first proves you know why.

Think it through like the interview

Think it through: Design BookMyShowLLD Classic — concurrency0/5 stages

PROBLEMDesign movie-ticket booking: browse shows, view a seat map, hold seats, pay, get a ticket. Two users will inevitably grab the same seat in the same second.

  1. 1

    Identify what's REALLY being asked

    Every classic has one core test. What is BookMyShow actually grading?

  2. 2

    The entity ladder + the trap

    City → Cinema → Screen → Seat… so where does 'is F7 free?' live?

    unlocks after the stage above
  3. 3

    Design around the slow step

    Payment takes a minute and might fail. What does that force on the flow?

    unlocks after the stage above
  4. 4

    Kill the race at the mutation point

    Two lock requests, 10 ms apart, both read F7 as AVAILABLE. Where's the bug, and where's the fix?

    unlocks after the stage above
  5. 5

    Scale the answer

    Follow-up: blockbuster tickets open at 9 AM. The per-show lock melts. Now what?

    unlocks after the stage above

Walk a scenario

Asha and Rahul, both eyeing F7+F8 for the 9 PM show. Asha's lock request wins the mutex: both seats → LOCKED(asha), PENDING booking, 7-minute clock. Rahul's request enters the mutex 80 ms later, sees F7 LOCKED and unexpired → SeatUnavailable; UI offers the Strategy's next-best contiguous pair. Asha pays in 3 minutes → seats BOOKED, booking CONFIRMED, observers repaint the map. Had she abandoned: at minute 7 the seats lazily read as AVAILABLE again — no cleanup dependency, no stuck inventory.

Practice — level up

BookMyShow is reserve under contention with a TTL. Drill the allocation and the expiry separately, then together:

Practice ladder: Locking, TTL & reservations0/4 solved

Climb in order — every rung assumes the one above it. Solve on LeetCode, then tick it here; progress is saved on this device.

Warm-up — reserve a specific slot

  1. Reserve the next free seat, release on cancel — the allocation core.

Core — expiry & time-versioned state

  1. Tokens with a TTL — exactly the seat-lock that self-heals on timeout.

  2. State versioned by time — reason about lock expiry windows.

Stretch — paired entry/exit

  1. Check-in/check-out pairs and charge on completion.