06-lld-interview-problems

Design a Movie Ticket Booking System

A full low-level design for a BookMyShow-style movie ticket booking system: seat locking under concurrency, dynamic show pricing, and a booking state machine with TTL-based holds.

August 11, 2026
lldmovie-bookingseat-lockingconcurrencystate-machineTTL

Design a Movie Ticket Booking System

This is the interview problem where concurrency stops being optional. Every other requirement — browsing movies, picking a showtime, seeing a seat map — is straightforward CRUD. The entire design hinges on one question: when two users tap the same seat within the same second, how do you guarantee only one of them walks away with a ticket, without holding a database lock for the full length of a payment flow? This guide works the problem end-to-end using the six-step framework used throughout this phase: requirements → actors → class diagram → class design → patterns → algorithms/concurrency → trade-offs.


1. Requirements

Functional:

  • Browse movies by city, theater, and showtime.
  • View a theater's seat layout for a specific show and see which seats are available, held, or booked.
  • Select one or more seats and hold them for a payment window (e.g. 5 minutes).
  • Complete payment to convert a hold into a confirmed booking; an expired or abandoned hold releases the seats automatically.
  • Cancel a confirmed booking, subject to a refund policy based on time-to-showtime.
  • A theater has multiple screens, and each screen can have a different seat layout (regular, premium, recliner rows).

Non-functional:

  • No two confirmed bookings may ever include the same seat for the same show (correctness under concurrency is the core requirement).
  • Seat availability must reflect holds in near real-time — a seat "stuck" as held after a failed payment must free up promptly.
  • Popular shows (a new release, opening weekend) will see many concurrent users targeting the same small set of good seats — this is the expected peak load pattern, not an edge case.

2. Actors & Use Cases

ActorUse cases
CustomerBrowse shows, view seat map, hold seats, pay, receive confirmation, cancel booking
Theater AdminConfigure screens and seat layouts, create shows, set base pricing
Payment Gateway (external)Processes payment, notifies success/failure via callback
System (scheduled job)Expires stale seat holds, releases seats back to available

3. Class Diagram

💡

Notice ShowSeat — not Seat — is the entity that carries status, heldByBookingId, and holdExpiresAt. A Seat is a fixed physical location on a Screen; its availability is entirely per-show. Modeling status directly on Seat would incorrectly make a seat's booking state global across every show that screen ever plays, instead of scoped to one show's seat map.


4. Core Class Design (Java)

ShowSeatStatus and the seat hold itself

java
enum ShowSeatStatus { AVAILABLE, HELD, BOOKED }
 
final class ShowSeat {
    private final String showId;
    private final String seatId;
    private volatile ShowSeatStatus status = ShowSeatStatus.AVAILABLE;
    private volatile String heldByBookingId;
    private volatile Instant holdExpiresAt;
 
    ShowSeat(String showId, String seatId) {
        this.showId = showId;
        this.seatId = seatId;
    }
 
    synchronized boolean tryHold(String bookingId, Duration holdDuration) {
        if (status == ShowSeatStatus.AVAILABLE
                || (status == ShowSeatStatus.HELD && Instant.now().isAfter(holdExpiresAt))) {
            status = ShowSeatStatus.HELD;
            heldByBookingId = bookingId;
            holdExpiresAt = Instant.now().plus(holdDuration);
            return true;
        }
        return false; // already HELD by someone else (unexpired) or already BOOKED
    }
 
    synchronized void confirm(String bookingId) {
        if (status != ShowSeatStatus.HELD || !bookingId.equals(heldByBookingId)) {
            throw new IllegalStateException("Cannot confirm a seat this booking does not hold");
        }
        status = ShowSeatStatus.BOOKED;
    }
 
    synchronized void release(String bookingId) {
        if (status == ShowSeatStatus.HELD && bookingId.equals(heldByBookingId)) {
            status = ShowSeatStatus.AVAILABLE;
            heldByBookingId = null;
            holdExpiresAt = null;
        }
    }
}
⚠️

tryHold is synchronized per-ShowSeat instance — the lock granularity matters enormously here. Locking the entire Show (all seats) to hold a handful of seats would serialize every booking attempt for a popular showtime into a single queue. Locking per-seat lets 200 different customers hold 200 different seats simultaneously; only two customers racing for the same seat ever actually contend.

SeatLockManager — orchestrating a multi-seat hold atomically

A booking usually spans multiple seats. All-or-nothing holding matters: if a customer selects 4 seats and only 3 are available, holding those 3 and silently dropping the 4th is a worse experience than failing the whole request clearly.

java
final class SeatLockManager {
    private final Map<String, ShowSeat> showSeats; // keyed by seatId, scoped to one Show
 
    SeatLockManager(Map<String, ShowSeat> showSeats) {
        this.showSeats = showSeats;
    }
 
    List<String> holdSeats(List<String> seatIds, String bookingId, Duration holdDuration) {
        // Sort seat IDs to establish a consistent lock ORDER across all callers —
        // this is what prevents a classic deadlock (two bookings holding seats
        // in opposite order and each waiting on the other's seat).
        List<String> sorted = seatIds.stream().sorted().toList();
        List<String> held = new ArrayList<>();
 
        for (String seatId : sorted) {
            ShowSeat seat = showSeats.get(seatId);
            if (seat.tryHold(bookingId, holdDuration)) {
                held.add(seatId);
            } else {
                // Rollback: release everything we managed to hold so far
                held.forEach(id -> showSeats.get(id).release(bookingId));
                throw new SeatUnavailableException(seatId);
            }
        }
        return held;
    }
}
🚨

The sorted-order locking above isn't decorative — without it, Customer A holding [seat-12, seat-13] and Customer B holding [seat-13, seat-12] at the same instant can deadlock-adjacent livelock each other in a naive retry loop. Always acquire multi-resource locks in a single, globally consistent order.

Booking state machine

FromToTriggerSide effect
SEATS_HELDholdSeats() succeeds for all requested seatsholdExpiresAt set on each ShowSeat
SEATS_HELDCONFIRMEDPayment gateway callback: successEach ShowSeat.confirm() called; booking marked paid
SEATS_HELDEXPIREDBackground job finds holdExpiresAt in the pastEach ShowSeat.release() called
CONFIRMEDCANCELLEDUser-initiated cancellationRefund computed by policy; seats released for resale
java
final class BookingService {
    ShowSeatRepository showSeatRepo;
    SeatLockManager lockManager;
    PaymentGateway paymentGateway;
 
    Booking initiateBooking(String showId, String customerId, List<String> seatIds) {
        List<String> heldSeatIds = lockManager.holdSeats(seatIds, generateBookingId(), Duration.ofMinutes(5));
        Booking booking = new Booking(generateBookingId(), showId, customerId, heldSeatIds, BookingStatus.SEATS_HELD);
        return booking; // customer now has 5 minutes to pay
    }
 
    void onPaymentSuccess(String bookingId) {
        Booking booking = findBooking(bookingId);
        booking.getSeatIds().forEach(id -> showSeatRepo.find(id).confirm(bookingId));
        booking.setStatus(BookingStatus.CONFIRMED);
    }
 
    // Runs on a scheduled interval (e.g. every 30s)
    void expireStaleHolds() {
        showSeatRepo.findExpiredHolds(Instant.now())
            .forEach(seat -> seat.release(seat.getHeldByBookingId()));
    }
}

5. Design Patterns Applied

PatternWhere usedWhy
StrategyPricingStrategy per show (weekend, premium seat, holiday surcharge)New pricing rules plug in without touching booking logic
StateBookingStatus transitions (SEATS_HELDCONFIRMED / EXPIRED / CANCELLED)Each state has distinct allowed transitions and side effects
FactorySeatType-specific pricing/validation object creationDecouples show setup from concrete seat-type logic
ObserverNotifying the seat map UI when a ShowSeat status changesReal-time seat-map updates without polling

6. Key Algorithms, Concurrency & Edge Cases

Preventing double-booking under race conditions

The core guarantee — two bookings never both confirm the same seat — comes from ShowSeat.tryHold being synchronized and being the only entry point that transitions a seat out of AVAILABLE. There is no code path that marks a seat BOOKED without first passing through HELD under that same lock.

Dynamic pricing

java
interface PricingStrategy {
    Money priceFor(ShowSeat seat, Show show);
}
 
final class WeekendSurchargePricing implements PricingStrategy {
    private final PricingStrategy base;
    public Money priceFor(ShowSeat seat, Show show) {
        Money price = base.priceFor(seat, show);
        boolean isWeekend = show.getStartTime().getDayOfWeek() == DayOfWeek.SATURDAY
                || show.getStartTime().getDayOfWeek() == DayOfWeek.SUNDAY;
        return isWeekend ? price.multiply(1.2) : price;
    }
}

Refund calculation

java
Money calculateRefund(Booking booking, Instant now) {
    Duration untilShow = Duration.between(now, booking.getShow().getStartTime());
    if (untilShow.toHours() >= 24) return booking.getTotalAmount();               // full refund
    if (untilShow.toHours() >= 2)  return booking.getTotalAmount().multiply(0.5); // 50% refund
    return Money.ZERO;                                                            // no refund inside 2h
}

Adjacent-seat validation is a UX requirement, not a correctness one: don't reject a hold request just because seats aren't adjacent — validate and warn in the UI before the hold call, so the backend stays simple and permissive.


7. Trade-offs & Extensions

DecisionTrade-off
Per-ShowSeat synchronized lock (in-memory)Simple and fast for a single-instance service; a multi-instance deployment needs a distributed lock (Redis SETNX with TTL) instead — the TTL-hold semantics translate directly
Scheduled job for hold expiry vs. lazy expiry check on readScheduled job keeps availability data eventually-consistent within the poll interval; lazy expiry (checking holdExpiresAt on every read) gives instant consistency but adds a check to every hot-path read
5-minute hold windowLong enough for payment, short enough to keep inventory liquid during high demand — tune based on observed payment completion times

Extensions: waitlist for sold-out shows notifying users if a hold expires unclaimed; group booking discounts; a "best available seats" auto-suggest that picks the closest-to-center block of N adjacent free seats.


Interview Questions

  • Walk through exactly how you prevent two users from both confirming the same seat.
  • Why is seat status modeled on ShowSeat instead of Seat?
  • Why does holdSeats sort seat IDs before acquiring locks?
  • How would this design change if the booking service ran on multiple instances behind a load balancer?
  • What happens if the payment gateway callback for a successful payment arrives after the hold has already expired and the seat was resold?
  • How would you implement a "best available adjacent seats" recommendation?