Movie Ticket Booking System
Cinemas, screens, shows, seat-holds, payments. The concurrency-with-holds problem in a recognisable shell.
Context#
A movie ticket booking system lets a customer pick a city, a movie, a show (a specific screening on a specific screen at a specific time), and a set of seats; hold those seats for a few minutes while the customer pays; and confirm or release the booking depending on whether payment succeeds in time. Multiple cinemas, multiple screens per cinema, multiple shows per screen per day, hundreds of seats per screen, thousands of concurrent customers on a popular Friday release.
The problem is a near-canonical OOD prompt because the surface entities (Cinema, Movie, Show, Seat) are obvious to draw and the interesting decisions all live below them: the seat-hold protocol (what state is a held-but-not-paid seat in? when does the hold expire? who releases it?), the state machine on a Booking (Held → Confirmed | Released), and the payment strategy (any payment processor without the booking knowing which).
The interviewer’s hidden objectives, in roughly the order they will be tested:
- Can you clarify scope — single-city vs. multi-region, seat selection vs. unreserved seating — without spinning out?
- Can you identify the entities and the asymmetry between them (a
Showhas manyShowSeats, not the cinema’sSeats — the per-show seat is the bookable unit)? - Can you design the seat-hold without locking the entire show’s seat set?
- Can you separate payment from booking so the booking does not switch on processor type?
- Can you defend trade-offs when the interviewer pushes (group bookings, seat preferences, dynamic pricing, refunds)?
Requirements (functional and non-functional)#
Clarifying in the room is the most points-bearing part. The scope below is the one most interviewers expect; anything outside it should be flagged out-of-scope so you can finish.
Functional — in scope.
- Browse cities → cinemas → movies → shows. A movie may run at many cinemas; a show is fixed to one (cinema, screen, start-time, movie).
- Select seats for a show; the system shows current availability per seat.
- Hold the selected seats for a TTL (typically 5–10 minutes) while the customer pays.
- Confirm the booking on successful payment; release the hold on failure or timeout.
- Generate a ticket with a booking reference; one booking can include multiple seats.
- Payment by card, wallet, or UPI; the booking core is agnostic to the processor.
Functional — out of scope (called out explicitly). Refunds and cancellations, loyalty points, food-and-beverage add-ons, dynamic pricing based on demand, group invitations, ticket transfers, recommended-seat algorithms. Acknowledge them so the interviewer knows you saw them.
Non-functional.
10^3cinemas,~10screens each,~6shows per screen per day,~200seats per screen ⇒~10^7show-seats per day at peak.- Latency target: seat-availability query under 100 ms; hold-and-pay end-to-end under a few seconds.
- Concurrency: a popular show may attract thousands of simultaneous customers; no two bookings may confirm the same seat.
- Holds must auto-expire — a payment failure or a customer who abandons the flow must not block a seat forever.
Use case diagram#
┌────────────────┐ │ Customer │ └────────┬───────┘ │ ┌──────────────────┼──────────────────┐ ▼ ▼ ▼ [browse shows] [hold seats] [pay] │ │ ▼ ▼ ┌─────────────────────────────────────┐ │ Movie Booking System │ └────────┬────────────────────────────┘ ▼ ┌──────────────────────────────────────────┐ │ [confirm booking] [release expired hold]│ └──────────────────────────────────────────┘ ▲ ┌────────┴────────┐ │ Cinema Admin │ … schedule shows, manage screens └─────────────────┘Customer is the primary actor; admin is secondary. The interesting use cases are hold seats and release expired hold — the two halves of the seat-hold protocol.
Class diagram#
┌───────────────────────────────┐ │ BookingService │ ├───────────────────────────────┤ │ holds : HoldRegistry │ │ payment : PaymentStrategy │ ◇── Strategy │ clock : Clock │ ├───────────────────────────────┤ │ hold(showId, seats, customer) │ │ confirm(holdId, paymentRef) │ │ release(holdId) │ │ tickExpiredHolds() │ └─────────────┬─────────────────┘ │ mutates ▼ ┌───────────────────────────────┐ │ Show │ ├───────────────────────────────┤ │ id, movie, screen, startsAt │ │ seats : Map<SeatId, ShowSeat> │ ├───────────────────────────────┤ │ seatStatus(seatId) : Status │ │ tryHold(seats, holdId) : bool │ │ confirm(seats) │ │ release(seats) │ └───────────────────────────────┘
┌───────────────┐ ┌────────────────┐ ┌─────────────────────────┐ │ Cinema │ │ Screen │ │ ShowSeat │ ├───────────────┤ ├────────────────┤ ├─────────────────────────┤ │ id, city │ │ id, layout │ │ id (row, col) │ │ screens │ │ rows, cols │ │ category : SeatCategory │ └───────────────┘ └────────────────┘ │ status : SeatStatus │ │ holdId : String? │ └─────────────────────────┘
┌─────────────────────────┐ ┌──────────────────────────────┐ │ Hold │ │ Booking │ ├─────────────────────────┤ ├──────────────────────────────┤ │ id, customer, show │ │ id, customer, show, seats │ │ seatIds : List<SeatId> │ │ amount : Money │ │ expiresAt : Instant │ │ status : BookingStatus │ │ status : HoldStatus │ └──────────────────────────────┘ └─────────────────────────┘
┌─────────────────────────┐ │ PaymentStrategy │◁── CardPayment, UpiPayment, WalletPayment └─────────────────────────┘Two patterns are doing the load-bearing work:
- State pattern on
ShowSeat.status—Available → Held → Booked(withHeld → Availableon expiry). TheBookingitself also has a state machine —Pending → Confirmed | Released. - Strategy pattern on
PaymentStrategy— Card / UPI / Wallet. The booking does not switch on processor.
What is not in the diagram and that is deliberate:
ShowSeat, notSeat. The cinema’s seat layout is a template; the bookable, status-bearing unit is the show-seat (a different per-show instance). One seat in cinema-space, many show-seats in booking-space.Holdis a first-class aggregate, not a transient field onShow. Auto-expiry needs a place to live, and a sweeper that can iterate holds without scanning every show is essential.
Sequence diagram (key flows)#
The hold-and-pay flow:
Customer BookingService Show Hold PaymentStrategy Notifier │ hold(show, seats) │ │ │ │ │──────────────►│ │ │ │ │ │ │ tryHold(seats, holdId) │ │ │ │ │──────────────►│ │ │ │ │ │ all available, mark Held │ │ │ │ │◄──────────────│ │ │ │ │ │ create Hold(expiresAt = now + 7m) │ │ │ │───────────────────────────────►│ │ │ │ hold │ │ │ │ │ │◄──────────────│ │ │ │ │ │ pay(holdId, cardDetails) │ │ │ │ │──────────────►│ │ │ │ │ │ │ pay(amount) │ │ │ │────────────────────────────────────────────────►│ │ │ │ paymentRef │ │ │ │◄────────────────────────────────────────────────│ │ │ │ confirm(seats) │ │ │ │──────────────►│ │ │ │ │ │ persist Booking │ │ │ publish(BookingConfirmed) ──────────────────────────────────────► │ │ booking │ │ │ │ │ │◄──────────────│ │ │ │ │The expiry sweep flow, run periodically:
Scheduler BookingService HoldRegistry Show │ tickExpiredHolds()│ │ │ │──────────────────►│ │ │ │ │ expiredBefore(now) │ │ │───────────────►│ │ │ │ list of Hold │ │ │ │◄───────────────│ │ │ │ for each: release(seats) on its show │ │────────────────────────────────►│ │ │ hold.status = Expired │The sweep is the only way a Held seat returns to Available if payment never arrives. Note it does not need to scan every show — the HoldRegistry is indexed by expiresAt (a sorted set or a min-heap).
Activity diagram (for non-trivial state)#
Two state machines run in parallel: one on the seat, one on the hold. The seat’s view:
┌────────────┐ │ Available │ └─────┬──────┘ │ tryHold(...) ▼ ┌────────────┐ ┌───────│ Held │──── confirm(...) ────► ┌────────┐ │ └─────┬──────┘ │ Booked │ │ │ expiry / release └────────┘ │ ▼ └──────► (back to Available)The Hold aggregate’s view:
┌──────────┐ │ start │ └────┬─────┘ ▼ ┌────────────┐ ┌────────│ Pending │──── confirm() ────► ┌────────────┐ │ └─────┬──────┘ │ Confirmed │ │ │ now > expiresAt └────────────┘ │ ▼ │ ┌────────────┐ │ │ Expired │ │ └────────────┘ │ └─── release() ───► ┌────────────┐ │ Cancelled │ └────────────┘Expired and Cancelled both transition the seat back to Available; the distinction is for audit and analytics, not for behaviour.
Java implementation#
A representative slice; the rest is mechanical.
public enum SeatCategory { REGULAR, PREMIUM, RECLINER }public enum SeatStatus { AVAILABLE, HELD, BOOKED }public enum HoldStatus { PENDING, CONFIRMED, EXPIRED, CANCELLED }
public final class ShowSeat { private final SeatId id; private final SeatCategory category; private SeatStatus status = SeatStatus.AVAILABLE; private String holdId;
public ShowSeat(SeatId id, SeatCategory category) { this.id = id; this.category = category; } public SeatId id() { return id; } public SeatStatus status() { return status; } public SeatCategory category() { return category; }
synchronized boolean tryHold(String holdId) { if (status != SeatStatus.AVAILABLE) return false; this.status = SeatStatus.HELD; this.holdId = holdId; return true; } synchronized void confirm() { if (status != SeatStatus.HELD) throw new IllegalStateException(); this.status = SeatStatus.BOOKED; } synchronized void release() { if (status != SeatStatus.HELD) return; this.status = SeatStatus.AVAILABLE; this.holdId = null; }}
public final class Show { private final String id; private final Movie movie; private final Screen screen; private final Instant startsAt; private final Map<SeatId, ShowSeat> seats;
public Show(String id, Movie movie, Screen screen, Instant startsAt, Map<SeatId, ShowSeat> seats) { this.id = id; this.movie = movie; this.screen = screen; this.startsAt = startsAt; this.seats = seats; }
/** All-or-nothing hold across the requested seats. */ public synchronized boolean tryHold(List<SeatId> seatIds, String holdId) { List<ShowSeat> acquired = new ArrayList<>(seatIds.size()); for (SeatId id : seatIds) { ShowSeat s = seats.get(id); if (s == null || !s.tryHold(holdId)) { acquired.forEach(ShowSeat::release); // roll back partial holds return false; } acquired.add(s); } return true; } public synchronized void confirm(List<SeatId> seatIds) { for (SeatId id : seatIds) seats.get(id).confirm(); } public synchronized void release(List<SeatId> seatIds) { for (SeatId id : seatIds) seats.get(id).release(); }}
public final class Hold { private final String id; private final Customer customer; private final Show show; private final List<SeatId> seatIds; private final Instant expiresAt; private HoldStatus status = HoldStatus.PENDING;
public Hold(String id, Customer c, Show show, List<SeatId> seatIds, Instant expiresAt) { this.id = id; this.customer = c; this.show = show; this.seatIds = seatIds; this.expiresAt = expiresAt; } public String id() { return id; } public Show show() { return show; } public List<SeatId> seatIds() { return seatIds; } public Instant expiresAt() { return expiresAt; } public HoldStatus status() { return status; } public void markConfirmed() { this.status = HoldStatus.CONFIRMED; } public void markExpired() { this.status = HoldStatus.EXPIRED; } public void markCancelled() { this.status = HoldStatus.CANCELLED; }}
public interface PaymentStrategy { PaymentResult pay(Money amount, PaymentDetails details);}
public final class BookingService { private static final Duration HOLD_TTL = Duration.ofMinutes(7);
private final HoldRegistry holds; private final PaymentStrategy payment; private final Clock clock; private final Notifier notifier;
public BookingService(HoldRegistry h, PaymentStrategy p, Clock c, Notifier n) { this.holds = h; this.payment = p; this.clock = c; this.notifier = n; }
public Hold hold(Show show, List<SeatId> seats, Customer customer) { String holdId = UUID.randomUUID().toString(); if (!show.tryHold(seats, holdId)) throw new SeatsUnavailableException(seats); Hold hold = new Hold(holdId, customer, show, seats, clock.instant().plus(HOLD_TTL)); holds.put(hold); return hold; }
public Booking confirm(String holdId, PaymentDetails details, Money amount) { Hold hold = holds.get(holdId).orElseThrow(NoSuchHoldException::new); if (clock.instant().isAfter(hold.expiresAt())) throw new HoldExpiredException(holdId); PaymentResult r = payment.pay(amount, details); if (!r.succeeded()) { hold.show().release(hold.seatIds()); hold.markCancelled(); throw new PaymentFailedException(r.reason()); } hold.show().confirm(hold.seatIds()); hold.markConfirmed(); Booking b = new Booking(UUID.randomUUID().toString(), hold.customer(), hold.show(), hold.seatIds(), amount, r.reference()); notifier.publish(new BookingConfirmed(b)); return b; }
/** Called by a scheduled task; not on the request path. */ public void tickExpiredHolds() { for (Hold h : holds.expiredBefore(clock.instant())) { if (h.status() != HoldStatus.PENDING) continue; h.show().release(h.seatIds()); h.markExpired(); } }}Notes the interviewer will look for:
- Per-show
synchronizedontryHold, not a global lock. Two different shows of the same movie at the same minute do not contend with each other. - Roll back partial holds. The all-or-nothing semantics is enforced inside
Show.tryHold; a caller cannot leak half a held set. Clockis injected. Test the expiry sweep withoutThread.sleep.HoldRegistry.expiredBefore(now)is the sweep’s whole interface. It returns only candidates; the sweep does the rest. Indexing the registry byexpiresAtkeeps it O(log n) per call.
Trade-offs and extensions#
Decisions explicitly made and what they cost:
| Decision | Why | Cost if requirements change |
|---|---|---|
Per-show synchronized for tryHold | Correctness first; per-show throughput is well within the latency budget. | Hot blockbusters with 5–10k concurrent holds need finer locking — per-row, or optimistic concurrency on each seat. |
| 7-minute hold TTL | Long enough for a real payment flow, short enough that abandoned seats return quickly. | A 3D/IMAX checkout with multiple add-ons may need longer; the TTL is a config value. |
| All-or-nothing hold across the requested seats | Customers expect “you got all 4 or none” — no half-cancellations. | A “best-effort” mode for unreserved seating is a different aggregate. |
| Sweeper as a periodic task, not in-band | Avoids touching the request path; the sweep itself is short. | If holds explode (say 10^6 pending), shard the registry; the sweeper trivially parallelises. |
PaymentStrategy interface | Card today; UPI tomorrow; processor migrations without editing BookingService. | None — the right shape now. |
| In-memory state | No persistence requirement was given. | Adding storage means a repository per aggregate (HoldRepository, BookingRepository, ShowRepository); the interfaces are clean places to do it. |
Likely follow-up extensions and the shape of the answer:
- Refunds / cancellations. A new transition
Booked → Refundedon the seat; aRefundServicethat consults a refund policy (full / partial / none, by time-to-show). Strategy pattern on the policy. - Group bookings. A
Holdalready covers multi-seat. For “split payment across friends” add aSplitPaymentstrategy that aggregates several payment results into one. - Dynamic pricing. A
Pricerstrategy consulted at hold-time. The amount lives on the hold, not the seat, so the price snapshot is honoured even if the strategy mutates between hold and confirm. - Seat preferences. A
SeatPreferencefilter passed to seat-suggestion calls; the booking core does not change. - Idempotency. A client retry of
confirm(holdId)must not double-book or double-charge. Stamp each booking with a client-providedIdempotency-Key; second call returns the first result.
Mock interview follow-ups#
Questions interviewers reach for and the briefest correct answer:
- “What’s the seat’s state machine?” —
Available → Held → Booked, withHeld → Availableon expiry or release. Show the activity diagram. - “What if payment is slow and the hold expires while the gateway is responding?” — The
confirmcall checks the hold’s expiry against the clock before releasing seats to the customer; if expired, refund the payment if it succeeded, and surface aHoldExpiredException. The right answer also stamps anIdempotency-Keyso a retry resolves cleanly. - “How do you stop two customers from grabbing the same row?” — Per-show
synchronizedon the hold method, plus all-or-nothing semantics so one customer wins the whole row, not half of it. - “How do you handle the Friday-release thundering herd?” — Per-show locks scale to a few thousand concurrent customers; beyond that, partition the show’s seats into row-groups, each with its own lock. The sweep stays untouched.
- “What’s the difference between
SeatandShowSeat?” —Seatis a layout slot in the screen;ShowSeatis a per-show, status-bearing instance. The same A5 isAvailablefor the 9pm show andBookedfor the 6pm show — they’re different objects. - “Where would observers be useful?” — On
BookingConfirmedandHoldExpiredevents: receipts via email, SMS for ticket pickup, analytics on conversion. TheNotifierlets subscribers attach withoutBookingServiceknowing.
Related#
- Parking Lot — the sibling case study; same shape (multi-aggregate, Strategy + state).
- State Pattern —
ShowSeat.statusandHold.statusare the textbook use. - Strategy Pattern —
PaymentStrategyhere is the textbook use. - Observer Pattern — the answer to the “where would observers be useful?” follow-up.
- Approaching the OOD Interview — the meta-script that produced this writeup’s structure.