Courses › Low-Level Design › Problem Walkthroughs

Design Movie Ticket Booking

Show seats, expiring holds, all-or-nothing seat claims and per-show locking.

A movie ticket booking system (think BookMyShow or Fandango) is where LLD meets concurrency. The model is straightforward: theaters, screens, shows and seats. The real question is "two people click the same seat at the same moment. What happens?" A strong answer uses temporary seat holds with an expiry and an atomic way to claim several seats at once.

Requirements

  • Browse movies and shows by city and date.
  • View the seat map for a show, and select one or more seats.
  • Seats are held for a few minutes while the user pays. Then the booking is confirmed, or the hold expires.
  • Two users can never book the same seat.
  • Out of scope unless asked: payments themselves (treat them as an external gateway), pricing rules beyond seat categories.

Model

Movie          id, title, duration
Theater        id, city, screens
Screen         id, seats (layout)
Seat           id, row, number, category (REGULAR, PREMIUM)
Show           id, movie, screen, start_time, show_seats
ShowSeat       seat, price, status (AVAILABLE, HELD, BOOKED), held_by, hold_expires_at
Booking        id, user, show, seats, amount, status (PENDING, CONFIRMED, CANCELLED, EXPIRED)
BookingService hold(user, show, seat_ids), confirm(booking_id, payment_ref), cancel(...)

[!TIP] Seat vs. ShowSeat A Seat is the physical chair and belongs to the screen. A ShowSeat is that chair for one particular show, with its own price and status. Booking state lives on ShowSeat. This type vs. instance split is the modeling insight interviewers look for.

Seat states

(AVAILABLE) -> [hold] -> (HELD) -> [payment succeeds] -> (BOOKED)
(HELD) -> [hold expires / user cancels / payment fails] -> (AVAILABLE)

All-or-nothing holds

A user selecting seats A5–A7 wants all three or none. So check every seat first, then claim them all, inside one critical section for that show.

import threading
import time
import uuid
from enum import Enum


class SeatStatus(Enum):
    AVAILABLE = "available"
    HELD = "held"
    BOOKED = "booked"


class SeatUnavailable(Exception):
    pass


class ShowSeat:
    def __init__(self, seat_id, price):
        self.seat_id, self.price = seat_id, price
        self.status = SeatStatus.AVAILABLE
        self.booking_id = None
        self.hold_expires_at = 0.0


class Show:
    def __init__(self, show_id, seats):
        self.id = show_id
        self.seats = {s.seat_id: s for s in seats}
        self.lock = threading.Lock()          # one lock per show: shows never contend with each other


class BookingService:
    HOLD_SECONDS = 300

    def __init__(self, clock=time.monotonic):
        self.clock = clock
        self.bookings = {}

    def _is_free(self, seat, now):
        if seat.status is SeatStatus.AVAILABLE:
            return True
        return seat.status is SeatStatus.HELD and seat.hold_expires_at <= now   # lazy expiry

    def hold(self, user, show, seat_ids):
        now = self.clock()
        with show.lock:
            seats = [show.seats[s] for s in seat_ids]
            taken = [s.seat_id for s in seats if not self._is_free(s, now)]
            if taken:
                raise SeatUnavailable(taken)          # nothing has changed yet
            booking_id = str(uuid.uuid4())
            for s in seats:
                s.status = SeatStatus.HELD
                s.booking_id = booking_id
                s.hold_expires_at = now + self.HOLD_SECONDS
            self.bookings[booking_id] = {"user": user, "show": show, "seats": seat_ids,
                                         "amount": sum(s.price for s in seats), "status": "PENDING"}
            return booking_id

    def confirm(self, booking_id, payment_ref):
        b = self.bookings[booking_id]
        show, now = b["show"], self.clock()
        with show.lock:
            seats = [show.seats[s] for s in b["seats"]]
            if any(s.booking_id != booking_id or s.status is not SeatStatus.HELD
                   or s.hold_expires_at <= now for s in seats):
                b["status"] = "EXPIRED"               # payment must be refunded
                raise SeatUnavailable("hold expired")
            for s in seats:
                s.status = SeatStatus.BOOKED
            b["status"], b["payment_ref"] = "CONFIRMED", payment_ref
svc = BookingService()
show = Show("s1", [ShowSeat(f"A{i}", 1200) for i in range(1, 11)])
first = svc.hold("ana", show, ["A5", "A6"])
try:
    svc.hold("bo", show, ["A6", "A7"])
except SeatUnavailable as e:
    assert e.args[0] == ["A6"]
svc.confirm(first, payment_ref="pay_123")

Design decisions to explain

  • Lock per show, not a global lock. Bookings for different shows never block each other. For an even finer grain you could lock per seat, but then you must acquire the locks in sorted seat order to avoid deadlock. The per-show lock is simpler and is rarely a bottleneck.
  • Lazy expiry. A held seat whose time has run out counts as free when someone checks it. There's no need for a timer thread to release it first. A background sweep can still clean up stale bookings for reporting.
  • Validate, then mutate. If any seat is taken, return the full list of taken seats and change nothing.
  • Confirm re-checks ownership. A user who paid after their hold expired must not get the seat if someone else holds it now. The booking goes to EXPIRED and the payment is refunded.

[!INTERVIEW] What if the payment succeeds right as the hold expires? This is the classic edge case. Options: give confirmations a short grace period, extend the hold when the user enters the payment page, or confirm first and refund if the seat was lost. Pick one, and say that money correctness (always refund on failure) matters more than squeezing in the sale.

Scaling the same design

In a real system the ShowSeat rows live in a database. Then the per-show lock becomes either:

  • Conditional updates: UPDATE show_seats SET status='HELD', booking_id=? WHERE show_id=? AND seat_id IN (...) AND (status='AVAILABLE' OR hold_expires_at < now()), and check that the number of rows updated equals the number of seats requested. Run it in a transaction.
  • Or a Redis hold per seat (SET seat:{show}:{id} booking NX EX 300) in front of the database.

See Design Ticketmaster and Dealing with Contention.

[!TIP] Practice Movie Ticket Booking System · Car Rental Booking · Meeting Room Scheduler · Concurrency & Thread Safety