Movie ticket booking

Design movie ticket booking

BookMyShow-style LLD: search and browse, Showtime owns reservations as seat state, per-showtime locking so concurrent books have one winner, cancel by confirmation ID, then checkout holds and dynamic showtimes as extensions.

What you’re designing

01Search

Movies by title

02Browse

Theater schedule

03Seats

Pick A5, A6…

04Race

Exactly one books

A movie ticket booking system (BookMyShow / Fandango shape) lets users find movies, pick a theater and showtime, choose seats from a map, and reserve them. The hard part isn’t the catalog — it’s per-seat availability under concurrency so two people never own the same chair.
Movie booking flow
Browse → book (v1) or hold→confirm (extension) → cancel releases seats.

Analogy: theater seat chart with a clipboard

Movie seat hold analogy
Same clipboard story as BookMyShow — simpler instant-book v1.

Think of an usher with a seat chart. Instant book = pencil the seats in immediately. The hold extension = light pencil for five minutes, ink when payment clears.

Clarifying questions → locked requirements

  • Search? → Simple case-insensitive title substring — no Elasticsearch.
  • Seats? → User picks specific seats; multiple seats per reservation.
  • Scale of venues? → Multiple theaters, multiple screens.
  • Layouts? → Standardize: rows A–Z, seats 0–20 on every screen.
  • Manage reservations? → Cancel only (rebook for changes).
  • Pricing / payment? → Out of scope; payment always succeeds.
  • Concurrency? → Same seat, two bookers → exactly one succeeds.

Entities: what becomes a class

Movie booking entities
BookingSystem orchestrates; Showtime owns reservations; Seat/Screen stay strings.
  • BookingSystem — orchestrator: theaters, search, browse, book, cancel.
  • Movie — id + title; searchable glue across showtimes.
  • Theater — named location; owns showtimes list.
  • Showtime — bookable unit; tracks reservations (= seat state) + concurrency.
  • Reservation — confirmationId, seatIds, back-ref to Showtime.
  • Seat — string like "A5", not a class.
  • Screen — string label on Showtime ("Screen 3"), not a class.
Heuristic: promote nouns that connect entities, get queried, or own behavior. Demote labels with no state. Movie connects showtimes across theaters → class. Screen only tells the customer which room → field.

Class design

# Constants
SEAT_LAYOUT = [f"{row}{n}" for row in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" for n in range(21)]
# 26 × 21 = 546 seats per showtime


class BookingSystem:
    # theaters + indexes (built at construction / updated on add)
    + search_movies(title) -> list[Showtime]
    + get_showtimes_at_theater(theater) -> list[Showtime]
    + book(showtime_id, seat_ids) -> Reservation
    + cancel_reservation(confirmation_id) -> None


class Theater:
    - id, name, showtimes: list[Showtime]
    + get_showtimes() / get_showtimes_for_movie(movie)


class Showtime:
    - id, theater, movie, datetime, screen_label
    - reservations: list[Reservation]   # source of truth for seat state
    + is_available(seat_id) -> bool
    + get_available_seats() -> list[str]
    + book(reservation) -> None         # atomic; may raise
    + cancel(reservation) -> None


class Movie:
    - id, title


class Reservation:
    - confirmation_id, showtime, seat_ids: list[str]
    # no cancel() here — Showtime mutates its own list
// Constants
static final List<String> SEAT_LAYOUT = /* A0..Z20 → 546 seats */ List.of();

class BookingSystem {
    // theaters + indexes (built at construction / updated on add)
    List<Showtime> searchMovies(String title) { /* ... */ return List.of(); }
    List<Showtime> getShowtimesAtTheater(Theater theater) { /* ... */ return List.of(); }
    Reservation book(String showtimeId, List<String> seatIds) { /* ... */ return null; }
    void cancelReservation(String confirmationId) { /* ... */ }
}

class Theater {
    String id, name;
    List<Showtime> showtimes;
    List<Showtime> getShowtimes() { return showtimes; }
    List<Showtime> getShowtimesForMovie(Movie movie) { /* ... */ return List.of(); }
}

class Showtime {
    String id, screenLabel;
    Theater theater;
    Movie movie;
    Instant datetime;
    List<Reservation> reservations; // source of truth for seat state
    boolean isAvailable(String seatId) { /* ... */ return false; }
    List<String> getAvailableSeats() { /* ... */ return List.of(); }
    void book(Reservation reservation) { /* atomic; may throw */ }
    void cancel(Reservation reservation) { /* ... */ }
}

class Movie { String id, title; }

class Reservation {
    String confirmationId;
    Showtime showtime;
    List<String> seatIds;
    // no cancel() here — Showtime mutates its own list
}

Implementation highlights

In a live interview, implement Showtime.book / cancel fully and sketch BookingSystem orchestration. Search/browse are O(n) scans or prebuilt indexes — describe verbally.
Indexes on BookingSystem (build once from theaters): movies_by_id, showtimes_by_movie_id, showtimes_by_id, plus reservations_by_id filled on book for O(1) cancel routing.
def book(self, showtime_id: str, seat_ids: list[str]) -> Reservation:
    if not showtime_id or not seat_ids:
        raise ValueError("invalid booking request")
    showtime = self.showtimes_by_id.get(showtime_id)
    if showtime is None:
        raise KeyError("showtime not found")

    reservation = Reservation(
        confirmation_id=new_id(),
        showtime=showtime,
        seat_ids=list(seat_ids),
    )
    showtime.book(reservation)  # raises if any seat taken
    self.reservations_by_id[reservation.confirmation_id] = reservation
    return reservation


def cancel_reservation(self, confirmation_id: str) -> None:
    reservation = self.reservations_by_id.get(confirmation_id)
    if reservation is None:
        raise KeyError("reservation not found")
    reservation.showtime.cancel(reservation)
    del self.reservations_by_id[confirmation_id]
Reservation book(String showtimeId, List<String> seatIds) {
    if (showtimeId == null || seatIds == null || seatIds.isEmpty())
        throw new IllegalArgumentException("invalid booking request");
    Showtime showtime = showtimesById.get(showtimeId);
    if (showtime == null) throw new NoSuchElementException("showtime not found");

    Reservation reservation = new Reservation(newId(), showtime, new ArrayList<>(seatIds));
    showtime.book(reservation); // raises if any seat taken
    reservationsById.put(reservation.confirmationId, reservation);
    return reservation;
}

void cancelReservation(String confirmationId) {
    Reservation reservation = reservationsById.get(confirmationId);
    if (reservation == null) throw new NoSuchElementException("reservation not found");
    reservation.showtime.cancel(reservation);
    reservationsById.remove(confirmationId);
}
from threading import Lock


class Showtime:
    def __init__(self, ...):
        self.reservations: list[Reservation] = []
        self._lock = Lock()

    def is_available(self, seat_id: str) -> bool:
        return all(seat_id not in r.seat_ids for r in self.reservations)

    def get_available_seats(self) -> list[str]:
        booked = {s for r in self.reservations for s in r.seat_ids}
        return [s for s in SEAT_LAYOUT if s not in booked]

    def book(self, reservation: Reservation) -> None:
        with self._lock:
            for seat in reservation.seat_ids:
                if seat not in SEAT_LAYOUT:
                    raise ValueError(f"invalid seat {seat}")
                if not self.is_available(seat):
                    raise RuntimeError(f"seat unavailable {seat}")
            self.reservations.append(reservation)  # all-or-nothing

    def cancel(self, reservation: Reservation) -> None:
        with self._lock:
            self.reservations.remove(reservation)
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;

class Showtime {
    List<Reservation> reservations = new ArrayList<>();
    private final ReentrantLock lock = new ReentrantLock();

    Showtime(/* ... */) { }

    boolean isAvailable(String seatId) {
        for (Reservation r : reservations)
            if (r.seatIds.contains(seatId)) return false;
        return true;
    }

    List<String> getAvailableSeats() {
        Set<String> booked = new HashSet<>();
        for (Reservation r : reservations) booked.addAll(r.seatIds);
        List<String> out = new ArrayList<>();
        for (String s : SEAT_LAYOUT) if (!booked.contains(s)) out.add(s);
        return out;
    }

    void book(Reservation reservation) {
        lock.lock();
        try {
            for (String seat : reservation.seatIds) {
                if (!SEAT_LAYOUT.contains(seat))
                    throw new IllegalArgumentException("invalid seat " + seat);
                if (!isAvailable(seat))
                    throw new RuntimeException("seat unavailable " + seat);
            }
            reservations.add(reservation); // all-or-nothing
        } finally {
            lock.unlock();
        }
    }

    void cancel(Reservation reservation) {
        lock.lock();
        try {
            reservations.remove(reservation);
        } finally {
            lock.unlock();
        }
    }
}

Verification traces

  • Happy book — book A5,A6 → reservation stored; reservations_by_id registered; seats gone from available.
  • Concurrent same seat — two threads book A5; lock serializes; second raises; exactly one winner (R6).
  • Cancel — cancel by confirmation ID → Showtime removes reservation → A5,A6 free again; routing index cleaned.
  • Partial multi-seat fail — request A5,A6,A7 when A6 taken → exception before append; A5 never claimed (all-or-nothing).
  • Happy: User holds A1+A2 → LOCKED with TTL → payment ok → BOOKED.
  • Failure: Hold overlapping already LOCKED seats → reject entire hold; payment fail → seats AVAILABLE.
  • Concurrency: Two holds on A1 — showtime lock makes transitions serial; loser sees reject.

Extensibility

Dynamic showtimes. Constructor indexes assume a static catalog. Add add_showtime(theater, showtime) that updates every index the constructor built. Remove only if no active reservations; then clean movie indexes if nothing else shows that title.
Checkout holds (common follow-up). Real UX has a gap between seat pick and payment. Split book into hold → confirm:
# Showtime gains:
# holds: dict[hold_id, SeatHold]  # seat_ids, expires_at

def is_available(self, seat_id: str) -> bool:
    if any(seat_id in r.seat_ids for r in self.reservations):
        return False
    now = time.time()
    for hold in self.holds.values():
        if hold.expires_at > now and seat_id in hold.seat_ids:
            return False
    return True

def hold_seats(self, seat_ids: list[str], timeout_s: float = 300) -> str:
    with self._lock:
        for s in seat_ids:
            if not self.is_available(s):
                raise RuntimeError(s)
        hid = new_id()
        self.holds[hid] = SeatHold(seat_ids, time.time() + timeout_s)
        return hid

def confirm_hold(self, hold_id: str, reservation: Reservation) -> None:
    with self._lock:
        hold = self.holds.get(hold_id)
        if hold is None or time.time() > hold.expires_at:
            self.holds.pop(hold_id, None)
            raise RuntimeError("hold missing/expired")
        del self.holds[hold_id]
        self.reservations.append(reservation)

# Background: cleanup_expired_holds() under the same lock
// Showtime gains:
// Map<String, SeatHold> holds;  // seatIds, expiresAt

boolean isAvailable(String seatId) {
    for (Reservation r : reservations)
        if (r.seatIds.contains(seatId)) return false;
    long now = System.currentTimeMillis();
    for (SeatHold hold : holds.values()) {
        if (hold.expiresAt > now && hold.seatIds.contains(seatId)) return false;
    }
    return true;
}

String holdSeats(List<String> seatIds, long timeoutMs) {
    lock.lock();
    try {
        for (String s : seatIds)
            if (!isAvailable(s)) throw new RuntimeException(s);
        String hid = newId();
        holds.put(hid, new SeatHold(seatIds, System.currentTimeMillis() + timeoutMs));
        return hid;
    } finally {
        lock.unlock();
    }
}

void confirmHold(String holdId, Reservation reservation) {
    lock.lock();
    try {
        SeatHold hold = holds.get(holdId);
        if (hold == null || System.currentTimeMillis() > hold.expiresAt) {
            holds.remove(holdId);
            throw new RuntimeException("hold missing/expired");
        }
        holds.remove(holdId);
        reservations.add(reservation);
    } finally {
        lock.unlock();
    }
}
// Background: cleanupExpiredHolds() under the same lock

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.

  • Jumping to microservices / payment gateway design instead of seat state ownership.
  • Letting UI “selected seats” be the source of truth without server-side hold.
  • No distinction between browse and reserve — inventory lies under load.
  • Check-then-act on seat.is_free without a lock or atomic state transition.
  • Omitting showtime as the aggregate that owns the seat map.
  • Confirming booking before payment stub fails — seats stuck forever.

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. Clarify search vs book, seat map per showtime, hold TTL, payment stub, cancellation.
  2. v1: Movie/Showtime/Seat; reserve holds seats; confirm after payment stub; release on fail/expiry.
  3. Showtime owns seats in AVAILABLE/LOCKED/BOOKED.
  4. API: search, get_seats(show_id), hold(seats, user), confirm(hold_id), release.
  5. hold is atomic for the seat set — all or nothing.
  6. Trace: two users hold overlapping seats — one wins; payment fail releases.
  7. Concurrency: lock per showtime (start coarse) around seat transitions.
  8. Extensions: pricing tiers, couple seats as constraints, waitlist.

Extra verification traces

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

def hold(self, seat_ids, user_id, ttl):
    with self.lock:
        if any(self.seats[s].state != "AVAILABLE" for s in seat_ids):
            raise RuntimeError("unavailable")
        for s in seat_ids:
            self.seats[s].lock(user_id, self.now() + ttl)
        return Hold(seat_ids, user_id)
Hold hold(List<String> seatIds, String userId, Duration ttl) {
    lock.lock();
    try {
        for (String s : seatIds)
            if (!"AVAILABLE".equals(seats.get(s).state))
                throw new RuntimeException("unavailable");
        for (String s : seatIds)
            seats.get(s).lock(userId, now().plus(ttl));
        return new Hold(seatIds, userId);
    } finally {
        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.

  • Partner cinemas? — CinemaAdapter for seat maps; Showtime still owns booking states.
  • Dynamic pricing? — PriceStrategy(show, seat) at confirm time; seat state machine unchanged.
  • Group booking contiguous? — Validator on hold checks adjacency before locking.
  • Distributed shows DB? — Per-show row lock or conditional update on seat version.

Complete solution: Showtime with lock

from threading import Lock
import uuid

SEAT_LAYOUT = [f"{r}{n}" for r in "ABCDE" for n in range(1, 11)]

class Reservation:
    def __init__(self, seats: list[str], showtime: "Showtime"):
        self.id = str(uuid.uuid4())
        self.seats = list(seats)
        self.showtime = showtime

class Showtime:
    def __init__(self, sid: str):
        self.id = sid
        self.reservations: list[Reservation] = []
        self._lock = Lock()

    def _booked(self) -> set[str]:
        return {s for r in self.reservations for s in r.seats}

    def book(self, seat_ids: list[str]) -> Reservation:
        with self._lock:
            booked = self._booked()
            for s in seat_ids:
                if s not in SEAT_LAYOUT or s in booked:
                    raise RuntimeError(f"unavailable {s}")
            r = Reservation(seat_ids, self)
            self.reservations.append(r)
            return r

    def cancel(self, reservation: Reservation) -> None:
        with self._lock:
            self.reservations.remove(reservation)

# All-or-nothing: raise before append if any seat taken.
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.UUID;

class Reservation {
    String id;
    List<String> seats;
    Showtime showtime;
    Reservation(List<String> seats, Showtime showtime) {
        this.id = UUID.randomUUID().toString();
        this.seats = new ArrayList<>(seats);
        this.showtime = showtime;
    }
}

class Showtime {
    static final List<String> SEAT_LAYOUT = /* A1..E10 */ List.of();
    String id;
    List<Reservation> reservations = new ArrayList<>();
    private final ReentrantLock lock = new ReentrantLock();

    Showtime(String sid) { this.id = sid; }

    Set<String> booked() {
        Set<String> out = new HashSet<>();
        for (Reservation r : reservations) out.addAll(r.seats);
        return out;
    }

    Reservation book(List<String> seatIds) {
        lock.lock();
        try {
            Set<String> booked = booked();
            for (String s : seatIds)
                if (!SEAT_LAYOUT.contains(s) || booked.contains(s))
                    throw new RuntimeException("unavailable " + s);
            Reservation r = new Reservation(seatIds, this);
            reservations.add(r);
            return r;
        } finally {
            lock.unlock();
        }
    }

    void cancel(Reservation reservation) {
        lock.lock();
        try {
            reservations.remove(reservation);
        } finally {
            lock.unlock();
        }
    }
}
// All-or-nothing: throw before add if any seat taken.

Concurrency cases

  • Same seat, two books — lock serializes; second raises.
  • Multi-seat partial — A5 free, A6 taken: fail entire cart; A5 must not stay claimed.
  • Different showtimes — different locks → true parallelism.
  • Hold extension — hold/confirm/cancel all under the same show lock; never hold across payment I/O.
# Alice book(A5)              Bob book(A5)
# acquire                    wait
# A5 free → append
# release
#                            acquire
#                            A5 booked → raise
#                            release
// Alice book(A5)              Bob book(A5)
// acquire                    wait
// A5 free → append
// release
//                            acquire
//                            A5 booked → throw
//                            release

← Lattice