17. Movie Ticket Booking System Advanced (Concurrency Showcase)

A. Realistic Interview Prompt

"Design a movie ticket booking system like BookMyShow. Users can browse shows, select seats, hold them briefly while paying, and confirm a booking. Thousands of users may try to book seats for a popular show at the same time."

B. Difficulty

Advanced — the concurrency showcase of this document's second half. The entity model is not the hard part; the hard part is that every popular-show demo scenario is a high-contention multi-seat race, and the interview is really evaluating whether the candidate's locking design survives that contention without deadlocking, double-booking, or leaving seats stuck in limbo.

C. What This Problem Tests

D. Requirements

Functional

  1. Browse shows for a movie by city/theater/time
  2. View a show's seat map with real-time availability
  3. Select one or more seats and hold them temporarily while completing payment
  4. Confirm a booking on successful payment; release the hold on failure or timeout
  5. Price seats by category (Silver/Gold/Premium) and show (peak/off-peak)

Non-Functional

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1How long should a seat hold last before it expires and releases automatically?Sets the TTL for the soft lock and whether a background sweep or lazy expiry check is neededDesign of SeatLock and its expiry mechanism
2Can a user select and hold multiple seats in one transaction?Introduces the multi-resource deadlock question, not just single-seat contentionLocking order strategy in the booking flow
3Is payment synchronous (blocking) or asynchronous (webhook-confirmed later)?Determines whether the hard lock commits inline or on a later, possibly-retried callbackWhether payment confirmation needs an idempotency key
4Should users be waitlisted for a sold-out show and notified on a cancellation?Adds an Observer-driven notification path on top of the core booking flowPresence and design of a waitlist Observer

F. Assumptions

G. Actors

ActorResponsibility
UserBrowses shows, selects seats, pays, confirms booking
Payment GatewayExternal system that authorizes payment and calls back with the result
BookingService (facade)Top-level API coordinating seat selection, holds, and confirmation

H. Core Domain Entities

ClassResponsibilityKey AttributesKey MethodsDoes NOT Handle
SeatOne physical seat's static attributes in a screenseatNumber, category—Any per-show status — delegates to ShowSeat
ShowOne scheduled screening of a movie in a screenmovie, screen, startTime—Individual seat status directly
ShowSeatOne seat's status for one specific show, and the State-machine ownerseat, show, statuslock(), confirm(), release()Payment processing or pricing math
ShowSeatState (interface)One lifecycle state's allowed transitions—onLock(), onConfirm(), onExpire()—
SeatLockA TTL-bound soft hold on a set of ShowSeats for one userseats, userId, expiresAtisExpired()—
BookingOne booking attempt spanning hold → payment → confirmationuser, show, seats, status, totalPriceconfirm(), cancel()Seat-level status directly — delegates to each ShowSeat
PricingStrategy (interface)One price-calculation rule—calculatePrice(seat, show)—
WaitlistNotifierObserver subject notifying waitlisted users when a seat frees up—notifyWaiting(showSeat)—
BookingService (facade)Top-level orchestration—holdSeats(), confirmBooking(), cancelBooking()—

I. Class Relationships

J. Class Diagram

1

*

*

Seat

seatNumber

category

ShowSeat

status

+lock()

+confirm()

+release()

«interface»

ShowSeatState

+onLock()

+onConfirm()

+onExpire()

SeatLock

userId

expiresAt

+isExpired()

Booking

status

totalPrice

+confirm()

+cancel()

«interface»

PricingStrategy

+calculatePrice(seat, show)

BookingService

+holdSeats()

+confirmBooking()

+cancelBooking()

View Mermaid source
classDiagram
  class Seat {
    seatNumber
    category
  }
  class ShowSeat {
    status
    +lock()
    +confirm()
    +release()
  }
  class ShowSeatState {
    <<interface>>
    +onLock()
    +onConfirm()
    +onExpire()
  }
  class SeatLock {
    userId
    expiresAt
    +isExpired()
  }
  class Booking {
    status
    totalPrice
    +confirm()
    +cancel()
  }
  class PricingStrategy {
    <<interface>>
    +calculatePrice(seat, show)
  }
  class BookingService {
    +holdSeats()
    +confirmBooking()
    +cancelBooking()
  }
  Seat "1" --> "*" ShowSeat
  ShowSeat --> ShowSeatState
  ShowSeat --> SeatLock
  Booking --> "*" ShowSeat
  Booking --> PricingStrategy
  BookingService --> Booking

K. Responsibility Mapping

ClassResponsibilityDoes NOT Handle
ShowSeatPer-show status and legal state transitionsPayment, pricing math
SeatLockTTL-bound soft hold and expiry checkDeciding what happens after expiry — that's Booking/BookingService
BookingCoordinating a multi-seat hold-to-confirm lifecycleIndividual seat state transitions themselves
PricingStrategy implementationsOne price formulaBooking commitment itself
BookingServiceTop-level orchestration, locking orderState-transition legality, price math

L. SOLID Analysis

PrincipleHow It's AppliedNaive Violation
SRPShowSeat owns status; SeatLock owns TTL/expiry; PricingStrategy owns pricing; Booking owns multi-seat coordinationOne Booking class directly flipping seat booleans, running a timer, and computing price inline
OCPA new pricing rule is a new PricingStrategy; a new seat lifecycle state is a new ShowSeatState implementationA giant switch on a status enum scattered across every method that touches seat state
LSPEvery ShowSeatState honestly rejects transitions it doesn't support (e.g. BOOKED.onLock() throws/rejects) rather than silently no-op-ingA state that silently allows an illegal transition "just to be safe," masking a bug instead of surfacing it
ISPPricingStrategy stays a single-method interfaceA combined "SeatPolicy" interface mixing pricing with cancellation-fee rules
DIPBooking depends on the PricingStrategy abstraction, not a hardcoded category-rate tableBooking.calculateTotal() embedding a rate table directly

M. Design Patterns

PatternProblem It SolvesWhere It AppearsWhen NOT to Use
StateA seat's legal transitions genuinely depend on its current status, not just external checksShowSeatState (AVAILABLE/LOCKED/BOOKED)Skip if the round only ever needs a boolean booked flag with no hold/expiry concept
StrategyPrice varies by seat category and show timing, and changes more often than the booking flowPricingStrategySkip if every seat in a show is one flat price
ObserverWaitlist notification on cancellation shouldn't require the cancel path to know who's waitingWaitlistNotifierSkip if waitlisting is out of scope for the round

Explicitly avoided: a single boolean "booked" flag per seat shared across all shows — see I for why per-show status is the entire point of this problem.

N. What Is Likely to Change?

BehaviorCurrent DesignAnticipated ChangeProtecting Abstraction
Pricing rulesCategory-and-show-timing-basedDynamic demand pricing, group discounts, promo codesPricingStrategy interface
Hold TTLFixed windowVariable TTL by seat scarcity or user tierSeatLock's expiry field, no change to the state machine itself
Seat lifecycleAVAILABLE/LOCKED/BOOKEDAdd a CANCELLED-pending-refund state distinct from AVAILABLENew ShowSeatState implementation, additive

O. Design Evolution

What stays stable: BookingService's public hold/confirm/cancel API. New states and pricing rules are additive.

P. API / Interface Design

interface PricingStrategy {
  Money calculatePrice(Seat seat, Show show);
}

class BookingService {
  SeatLock holdSeats(User user, Show show, List<SeatId> seatIds);
  Booking confirmBooking(String lockId, PaymentResult result);  // idempotency-keyed
  void cancelBooking(String bookingId);
}

Ownership/validation notes: holdSeats() acquires locks on every requested ShowSeat in one atomic step (all-or-nothing — see W for lock ordering), and confirmBooking() is keyed by an idempotency key derived from the payment provider's transaction id, so a retried webhook call cannot double-confirm or double-charge.

Q. Pseudocode — Critical Logic

BookingService.holdSeats(user, show, seatIds):
  orderedIds = sort(seatIds)          // canonical order — see W
  locks = []
  try:
    for id in orderedIds:
      lock(showSeatFor(id))
      locks.append(showSeatFor(id))
      if showSeatFor(id).status != AVAILABLE: rollback(locks); reject
    for seat in locks: seat.status = LOCKED
    seatLock = new SeatLock(user, locks, expiresAt = now + HOLD_TTL)
    return seatLock
  finally:
    for seat in locks: unlock(seat)

BookingService.confirmBooking(lockId, paymentResult, idempotencyKey):
  if alreadyProcessed(idempotencyKey): return existingBookingFor(idempotencyKey)  // idempotent retry
  seatLock = lockFor(lockId)
  if seatLock.isExpired(): reject
  if paymentResult.success:
    for seat in seatLock.seats: seat.status = BOOKED
    booking = new Booking(seatLock, CONFIRMED)
  else:
    for seat in seatLock.seats: seat.status = AVAILABLE
    waitlistNotifier.notifyWaiting(seatLock.seats)
  recordProcessed(idempotencyKey, booking)
  return booking

R. End-to-End Use Case

userA.holdSeats(show, [A4, A5]) → both locked atomically in sorted order → SeatLock (TTL 8 min)
  │
  ▼
userA pays within the window
  │  payment webhook fires → confirmBooking(lockId, success, idempotencyKey)
  │  A4, A5 → BOOKED; Booking CONFIRMED
  ▼
Same webhook retried by the payment provider due to a network blip
  │  idempotencyKey already processed → same Booking returned, no double-booking, no double-charge

S. Data Structures

StructureUsed ForWhyAlternative & Trade-off
Hash map of seatId → ShowSeat per showO(1) seat status lookup during hold/confirmSeat-map rendering and locking both need fast point lookups, not range queriesA 2D grid array also works if the seat map is genuinely rectangular
Sorted order of seat ids before lockingDeadlock-free multi-seat lockingGuarantees every concurrent booking attempt acquires shared seats in the same global orderWithout this, two users booking overlapping seat sets in opposite orders can deadlock — see W
Idempotency-key store (processed payment confirmations)Safe webhook retriesO(1) check for "have I already handled this exact confirmation"A TTL-bounded cache is sufficient since providers don't retry indefinitely

T. Complexity Analysis

OperationTimeSpace
holdSeats() for k seatsO(k log k) for the sort + O(k) for the locksO(k)
confirmBooking()O(k) for k held seats + O(1) idempotency checkO(1)
Seat-map render for a showO(seats in the screen)O(seats in the screen)

U. Testing Strategy

V. Interviewer Follow-Ups

Follow-UpTestingDesign ChangeClasses ChangedPattern/Trade-off
"What if two users try to lock overlapping seats in opposite order — A4-then-A5 vs. A5-then-A4?"Whether lock acquisition order was ever left to caller discretionEnforce sorted-id lock ordering inside holdSeats() itself, not left to the callerBookingService onlyThis is the File System (Problem 9) canonical-ordering deadlock lesson, reapplied
"Scale to a flash-sale-level popular show — thousands of concurrent hold attempts on the same 200 seats."Whether locking was assumed to be in-process rather than distributedMove seat locks to a distributed lock service (e.g. Redis with TTL) so multiple app servers coordinate correctlySeatLock's underlying implementation, not its interfaceLLD→HLD boundary — see Y
"Add a waitlist for sold-out shows."Whether cancellation was already decoupled from "who cares"WaitlistNotifier subscribes to seat-release eventsNew Observer wiring onlyReuses the Library/Pub-Sub Observer shape

W. Concurrency

Shared mutable state: each ShowSeat's status for a popular show, under genuinely high simultaneous contention — this is the highest-contention scenario in the entire document.

X. Persistence

Bookings and confirmed seat status must survive a restart without question — a booking that vanishes after payment is a trust-destroying failure. Seat holds are a middle ground: they could technically be rebuilt from a fast in-memory/distributed cache (with the TTL as the natural expiry), but the confirmed Booking record itself is always durable, repository-backed data.

Y. Scaling Boundary

LayerWhat Belongs Here
LLDSeat/ShowSeat model, state machine, canonical lock ordering, TTL holds, idempotent confirmation
HLDDistributed locking across app servers (Redis/Zookeeper), flash-sale traffic shaping/queueing, multi-region show-inventory replication
Explicitly out of scopeDistributed-systems infrastructure detail unless the round explicitly pivots to HLD

Z. Failure Scenarios

AA. Common Bad Designs

AB. Design Trade-Offs

DecisionOption AOption BRecommendation
Lock scope for multi-seat holdsLock seats individually in sorted orderLock the entire show for the duration of any hold attemptSorted individual locks — locking the whole show serializes unrelated bookings on unrelated seats, destroying throughput for a popular show
Ambiguous payment result handlingAuto-release the seat after a short grace timeoutKeep the seat locked indefinitely pending manual reconciliationA bounded grace timeout with a reconciliation job as backstop — indefinite locking on an ambiguous result is a worse failure mode than occasionally releasing a seat that later turns out to have been paid for (refundable) than double-selling one that wasn't (not always reversible)

AC. Interviewer Evaluation

Strong signal: splitting Seat/ShowSeat unprompted; proposing a TTL-bound hold before being asked how checkout abandonment is handled; independently identifying the multi-seat deadlock risk and fixing it with canonical lock ordering; flagging idempotency for payment confirmation without being prompted.

Red flag: one booked flag per seat; per-seat locking with no defined acquisition order; no hold expiry; treating payment confirmation as safe to run twice.

AD. Interview Simulation

Interviewer: Design a movie ticket booking system for a popular show with thousands of concurrent users.
Candidate: I'd split the physical Seat from ShowSeat, since booking status only makes sense relative to one specific show — the same seat is free for one showtime and booked for another. Each ShowSeat runs a small state machine: available, locked, booked.
Interviewer: What happens between selecting a seat and actually paying for it?
Candidate: Selecting seats acquires a TTL-bound SeatLock — a soft hold, not a booking. If payment succeeds within the window, the hold converts to a permanent BOOKED status; if it fails or the TTL expires, the seats revert to available automatically.
Interviewer: What if a user tries to book two seats, and another user is simultaneously trying to book an overlapping pair in the opposite order?
Candidate: That's a real deadlock risk if each request locks seats in whatever order the user clicked them. I'd sort the requested seat ids and always acquire locks in that canonical sorted order — the same fix I'd use for the file-lock-ordering problem — so every concurrent request converges on the same global order and none of them can circularly wait on each other.
Interviewer: The payment provider retries its confirmation webhook due to a network blip. What happens?
Candidate: confirmBooking() is keyed by an idempotency key derived from the payment transaction id. A retried call with the same key returns the already-created booking instead of creating a second one or charging twice.

AE. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
GeeksforGeeks — Design BookMyShowRequirements-and-entity walkthrough covering movies, shows, seats, and bookingsClear breakdown of the browsing/search side of the problem (city → theater → show)Seat locking is generally described at a conceptual level without a concrete deadlock-free multi-seat locking strategy or explicit idempotency handling for payment retriesGood for the browsing/search entity model; this document's canonical lock ordering and idempotent confirmation are the concurrency depth worth adding on top
AlgoMaster — Design BookMyShowStructured walkthrough including a seat-locking discussion and class diagramsExplicitly discusses temporary seat locking during checkout, which most treatments skipDoes not typically dive into the specific multi-seat deadlock scenario or a canonical-ordering fix — usually stops at single-seat lockingStrong starting point for the TTL-hold concept; this document extends it to the multi-seat deadlock case and payment-retry idempotency

AF. Interview Cheat Sheet

ProblemMovie Ticket Booking System (BookMyShow-style)
DifficultyAdvanced (concurrency showcase)
Core requirementsShow browsing, seat-map viewing, temporary hold, payment-gated confirmation, category/show-based pricing
Key clarificationsHold TTL length? Multi-seat holds allowed? Synchronous or webhook-based payment? Waitlisting needed?
Core entitiesSeat, Show, ShowSeat, ShowSeatState, SeatLock, Booking, PricingStrategy, WaitlistNotifier, BookingService
Key relationshipsSeat/ShowSeat split; State machine for seat lifecycle; TTL soft lock → hard commit on payment; Strategy for pricing; Observer for waitlist
Key patternsState, Strategy, Observer
Most important decisionSplitting per-show seat status from the physical seat, and locking multiple seats in canonical order
Likely-changing behaviorPricing rules, hold TTL, additional seat lifecycle states
Concurrency concernMulti-seat deadlock (fixed by canonical lock ordering) and idempotent payment confirmation under retries
Top follow-upsOpposite-order concurrent multi-seat locks, flash-sale-scale distributed locking, waitlist notifications
Common mistakesOne booked flag per seat across all shows; unordered multi-seat locking; no hold TTL; non-idempotent payment confirmation
What NOT to over-engineerDon't design the distributed-lock infrastructure itself unless the round explicitly pivots to HLD

AG. Full Implementation

A complete, compilable reference implementation of every class in the diagram above (composition, inheritance, interface realization, and dependency-injected strategies all preserved per section I), with a runnable demo exercising the primary flow — in Java, C++, JavaScript, and TypeScript.

import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * Problem 17: Movie Ticket Booking System (Concurrency Showcase)
 *
 * Key design decisions faithfully reproduced from the class diagram:
 *  - Seat (static attrs) vs ShowSeat (per-show status) split.
 *  - ShowSeatState hierarchy encodes AVAILABLE -> LOCKED -> BOOKED legality, and LOCKED/BOOKED -> AVAILABLE on expire/release.
 *  - SeatLock is a TTL-bound soft hold; only a successful payment converts it into a Booking confirmation.
 *  - PricingStrategy is injected (Strategy); WaitlistNotifier is an Observer subject.
 *  - BookingService is the facade; holdSeats() locks seats in canonical (sorted) order to avoid deadlock
 *    against another user holding an overlapping seat set.
 */
public class p17 {

    enum SeatCategory { SILVER, GOLD, PREMIUM }
    enum ShowSeatStatus { AVAILABLE, LOCKED, BOOKED }
    enum BookingStatus { HOLD, CONFIRMED, CANCELLED }

    static final class User {
        final String id, name;
        User(String id, String name) { this.id = id; this.name = name; }
    }

    static final class PaymentResult {
        final boolean success;
        PaymentResult(boolean success) { this.success = success; }
    }

    // ---------- Seat / Show ----------

    static final class Seat {
        final String seatNumber;
        final SeatCategory category;
        Seat(String seatNumber, SeatCategory category) { this.seatNumber = seatNumber; this.category = category; }
    }

    static final class Movie {
        final String id, title;
        final int durationMinutes;
        Movie(String id, String title, int durationMinutes) { this.id = id; this.title = title; this.durationMinutes = durationMinutes; }
    }

    static final class Screen {
        final String id, name;
        final List<Seat> seats;
        Screen(String id, String name, List<Seat> seats) { this.id = id; this.name = name; this.seats = seats; }
    }

    static final class Show {
        final String id;
        final Movie movie;
        final Screen screen;
        final LocalDateTime startTime;
        private final Map<String, ShowSeat> showSeats = new LinkedHashMap<>();

        Show(String id, Movie movie, Screen screen, LocalDateTime startTime) {
            this.id = id;
            this.movie = movie;
            this.screen = screen;
            this.startTime = startTime;
            // One ShowSeat per physical seat -- the same Seat is independently bookable per Show.
            for (Seat seat : screen.seats) showSeats.put(seat.seatNumber, new ShowSeat(seat, this));
        }

        ShowSeat getShowSeat(String seatNumber) {
            ShowSeat seat = showSeats.get(seatNumber);
            if (seat == null) throw new NoSuchElementException("No such seat: " + seatNumber);
            return seat;
        }
    }

    // ---------- ShowSeatState (State pattern) ----------

    interface ShowSeatState {
        void onLock(ShowSeat seat);
        void onConfirm(ShowSeat seat);
        void onExpire(ShowSeat seat);
    }

    static final class AvailableState implements ShowSeatState {
        static final AvailableState INSTANCE = new AvailableState();
        public void onLock(ShowSeat seat) { seat.setState(ShowSeatStatus.LOCKED, LockedState.INSTANCE); }
        public void onConfirm(ShowSeat seat) { throw new IllegalStateException("Cannot confirm a seat that was never locked"); }
        public void onExpire(ShowSeat seat) { /* already available; no-op */ }
    }

    static final class LockedState implements ShowSeatState {
        static final LockedState INSTANCE = new LockedState();
        public void onLock(ShowSeat seat) { throw new IllegalStateException("Seat already locked"); }
        public void onConfirm(ShowSeat seat) { seat.setState(ShowSeatStatus.BOOKED, BookedState.INSTANCE); }
        public void onExpire(ShowSeat seat) { seat.setState(ShowSeatStatus.AVAILABLE, AvailableState.INSTANCE); }
    }

    static final class BookedState implements ShowSeatState {
        static final BookedState INSTANCE = new BookedState();
        public void onLock(ShowSeat seat) { throw new IllegalStateException("Seat already booked"); }
        public void onConfirm(ShowSeat seat) { throw new IllegalStateException("Seat already booked"); }
        // Freeing a booked seat (e.g. booking cancellation) returns it to the pool.
        public void onExpire(ShowSeat seat) { seat.setState(ShowSeatStatus.AVAILABLE, AvailableState.INSTANCE); }
    }

    // ---------- ShowSeat ----------

    static final class ShowSeat {
        final Seat seat;
        final Show show;
        private volatile ShowSeatStatus status = ShowSeatStatus.AVAILABLE;
        private ShowSeatState state = AvailableState.INSTANCE;
        private final Lock lock = new ReentrantLock();

        ShowSeat(Seat seat, Show show) { this.seat = seat; this.show = show; }

        void setState(ShowSeatStatus status, ShowSeatState state) { this.status = status; this.state = state; }
        ShowSeatStatus getStatus() { return status; }

        /** Soft-lock this seat for a pending hold. Throws if not currently AVAILABLE. */
        void lock() {
            lock.lock();
            try { state.onLock(this); } finally { lock.unlock(); }
        }

        /** Hard-commit: LOCKED -> BOOKED. */
        void confirm() {
            lock.lock();
            try { state.onConfirm(this); } finally { lock.unlock(); }
        }

        /** Free the seat back to AVAILABLE -- used for hold expiry, payment failure, or booking cancellation. */
        void release() {
            lock.lock();
            try { state.onExpire(this); } finally { lock.unlock(); }
        }
    }

    // ---------- SeatLock ----------

    static final class SeatLock {
        final String id;
        final User user;
        final List<ShowSeat> seats;
        final long expiresAt; // epoch millis

        SeatLock(String id, User user, List<ShowSeat> seats, long expiresAt) {
            this.id = id; this.user = user; this.seats = seats; this.expiresAt = expiresAt;
        }

        boolean isExpired() { return System.currentTimeMillis() > expiresAt; }
    }

    // ---------- PricingStrategy ----------

    interface PricingStrategy {
        double calculatePrice(Seat seat, Show show);
    }

    static final class CategoryTimeBasedPricingStrategy implements PricingStrategy {
        private final Map<SeatCategory, Double> baseRates = Map.of(
                SeatCategory.SILVER, 100.0, SeatCategory.GOLD, 180.0, SeatCategory.PREMIUM, 280.0);

        public double calculatePrice(Seat seat, Show show) {
            double base = baseRates.get(seat.category);
            int hour = show.startTime.getHour();
            boolean peak = hour >= 18 && hour < 22; // evening peak window
            return peak ? base * 1.3 : base;
        }
    }

    // ---------- Booking ----------

    static final class Booking {
        final String id;
        final User user;
        final Show show;
        final List<ShowSeat> seats;
        BookingStatus status;
        final double totalPrice;

        Booking(String id, User user, Show show, List<ShowSeat> seats, PricingStrategy pricingStrategy) {
            this.id = id; this.user = user; this.show = show; this.seats = seats;
            this.status = BookingStatus.HOLD;
            double total = 0;
            for (ShowSeat s : seats) total += pricingStrategy.calculatePrice(s.seat, show);
            this.totalPrice = total;
        }

        void confirm() {
            for (ShowSeat s : seats) s.confirm();
            status = BookingStatus.CONFIRMED;
        }

        void cancel() {
            for (ShowSeat s : seats) s.release();
            status = BookingStatus.CANCELLED;
        }
    }

    // ---------- WaitlistNotifier (Observer) ----------

    interface WaitlistObserver {
        void onSeatAvailable(ShowSeat seat);
    }

    static final class WaitlistNotifier {
        private final List<WaitlistObserver> observers = new ArrayList<>();
        void subscribe(WaitlistObserver observer) { observers.add(observer); }
        void notifyWaiting(List<ShowSeat> freedSeats) {
            for (ShowSeat seat : freedSeats) for (WaitlistObserver o : observers) o.onSeatAvailable(seat);
        }
    }

    static final class ConsoleWaitlistNotifier implements WaitlistObserver {
        public void onSeatAvailable(ShowSeat seat) {
            System.out.printf("[waitlist] Seat %s on show %s is available again%n", seat.seat.seatNumber, seat.show.id);
        }
    }

    // ---------- BookingService (facade) ----------

    static final class BookingService {
        private static final long HOLD_TTL_MILLIS = 2000;
        private final PricingStrategy pricingStrategy;
        private final WaitlistNotifier waitlistNotifier;
        private final Map<String, SeatLock> activeLocks = new HashMap<>();
        private final Map<String, Booking> bookings = new HashMap<>();
        private final Map<String, Booking> idempotencyRecords = new HashMap<>();
        private int lockSeq = 0, bookingSeq = 0;

        BookingService(PricingStrategy pricingStrategy, WaitlistNotifier waitlistNotifier) {
            this.pricingStrategy = pricingStrategy;
            this.waitlistNotifier = waitlistNotifier;
        }

        /** Locks seats in canonical (sorted) order so two overlapping multi-seat holds never deadlock. */
        synchronized SeatLock holdSeats(User user, Show show, List<String> seatNumbers) {
            List<String> ordered = new ArrayList<>(seatNumbers);
            Collections.sort(ordered);
            List<ShowSeat> locked = new ArrayList<>();
            try {
                for (String id : ordered) {
                    ShowSeat seat = show.getShowSeat(id);
                    seat.lock(); // throws IllegalStateException if not AVAILABLE
                    locked.add(seat);
                }
            } catch (IllegalStateException rejected) {
                for (ShowSeat seat : locked) seat.release(); // rollback partial holds
                throw new IllegalStateException("Could not hold all requested seats: " + rejected.getMessage());
            }
            String lockId = "LOCK-" + (++lockSeq);
            SeatLock seatLock = new SeatLock(lockId, user, locked, System.currentTimeMillis() + HOLD_TTL_MILLIS);
            activeLocks.put(lockId, seatLock);
            return seatLock;
        }

        synchronized Booking confirmBooking(String lockId, PaymentResult paymentResult, String idempotencyKey) {
            if (idempotencyRecords.containsKey(idempotencyKey)) return idempotencyRecords.get(idempotencyKey); // idempotent retry

            SeatLock seatLock = activeLocks.get(lockId);
            if (seatLock == null) throw new NoSuchElementException("Unknown or already-consumed lock " + lockId);
            if (seatLock.isExpired()) {
                activeLocks.remove(lockId);
                for (ShowSeat seat : seatLock.seats) seat.release();
                throw new IllegalStateException("Seat hold expired for lock " + lockId);
            }

            Booking booking = new Booking("BKG-" + (++bookingSeq), seatLock.user, seatLock.seats.get(0).show, seatLock.seats, pricingStrategy);
            if (paymentResult.success) {
                booking.confirm();
            } else {
                booking.cancel();
                waitlistNotifier.notifyWaiting(seatLock.seats);
            }
            activeLocks.remove(lockId);
            bookings.put(booking.id, booking);
            idempotencyRecords.put(idempotencyKey, booking);
            return booking;
        }

        synchronized void cancelBooking(String bookingId) {
            Booking booking = bookings.get(bookingId);
            if (booking == null) throw new NoSuchElementException("Unknown booking " + bookingId);
            booking.cancel();
            waitlistNotifier.notifyWaiting(booking.seats);
        }
    }

    // ---------- Demo ----------

    public static void main(String[] args) throws InterruptedException {
        List<Seat> seats = List.of(
                new Seat("A1", SeatCategory.PREMIUM), new Seat("A2", SeatCategory.PREMIUM),
                new Seat("B1", SeatCategory.GOLD), new Seat("B2", SeatCategory.GOLD),
                new Seat("C1", SeatCategory.SILVER), new Seat("C2", SeatCategory.SILVER));
        Screen screen = new Screen("SCR-1", "Screen 1", seats);
        Movie movie = new Movie("MOV-1", "Interstellar", 169);
        Show show = new Show("SHOW-1", movie, screen, LocalDateTime.of(2026, 10, 2, 19, 0)); // peak hour

        WaitlistNotifier waitlistNotifier = new WaitlistNotifier();
        waitlistNotifier.subscribe(new ConsoleWaitlistNotifier());
        BookingService bookingService = new BookingService(new CategoryTimeBasedPricingStrategy(), waitlistNotifier);

        User alice = new User("u1", "Alice");
        User bob = new User("u2", "Bob");

        // Concurrency demo: Alice holds {A1,A2}, Bob holds {A2,B1} -- overlapping sets, sorted-order locking prevents deadlock.
        final SeatLock[] results = new SeatLock[2];
        Thread t1 = new Thread(() -> {
            try { results[0] = bookingService.holdSeats(alice, show, List.of("A1", "A2")); } catch (Exception ignored) { }
        });
        Thread t2 = new Thread(() -> {
            try { results[1] = bookingService.holdSeats(bob, show, List.of("A2", "B1")); } catch (Exception e) {
                System.out.println("Bob's hold rejected (expected, contends on A2): " + e.getMessage());
            }
        });
        t1.start(); t2.start();
        t1.join(); t2.join();

        SeatLock aliceLock = results[0];
        System.out.println("Alice locked seats: " + aliceLock.seats.size() + " totalPrice will be computed at confirm");

        Booking booking = bookingService.confirmBooking(aliceLock.id, new PaymentResult(true), "idem-key-1");
        System.out.printf("Booking %s CONFIRMED, total=%.2f%n", booking.id, booking.totalPrice);

        // Idempotent retry with the same key returns the same booking, no double charge/booking.
        Booking retry = bookingService.confirmBooking(aliceLock.id, new PaymentResult(true), "idem-key-1");
        System.out.println("Idempotent retry returns same booking: " + (retry == booking));

        // Bob can now hold A2 since Alice's booking only used A1 -- retry with a valid, non-conflicting set.
        SeatLock bobLock = bookingService.holdSeats(bob, show, List.of("B1", "B2"));
        Booking failedPayment = bookingService.confirmBooking(bobLock.id, new PaymentResult(false), "idem-key-2");
        System.out.println("Bob's failed-payment booking status: " + failedPayment.status + " (seats released, waitlist notified)");

        // Cancel Alice's confirmed booking, freeing A1 and A2 back to the pool.
        bookingService.cancelBooking(booking.id);
        System.out.println("A1 status after cancellation: " + show.getShowSeat("A1").getStatus());
        System.out.println("A2 status after cancellation: " + show.getShowSeat("A2").getStatus());

        // TTL expiry demo: hold C1, let it expire, then confirming should be rejected.
        SeatLock shortLock = bookingService.holdSeats(alice, show, List.of("C1"));
        Thread.sleep(2100);
        try {
            bookingService.confirmBooking(shortLock.id, new PaymentResult(true), "idem-key-3");
        } catch (IllegalStateException e) {
            System.out.println("Expired hold correctly rejected: " + e.getMessage());
        }
        System.out.println("C1 status after expiry: " + show.getShowSeat("C1").getStatus());
    }
}