Movie Ticket Booking System

Cinemas, screens, shows, seat-holds, payments. The concurrency-with-holds problem in a recognisable shell.

System Intermediate
13 min read
ood case-study ticket-booking state-pattern strategy-pattern

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 Show has many ShowSeats, not the cinema’s Seats — 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^3 cinemas, ~10 screens each, ~6 shows per screen per day, ~200 seats per screen ⇒ ~10^7 show-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.statusAvailable → Held → Booked (with Held → Available on expiry). The Booking itself 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, not Seat. 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.
  • Hold is a first-class aggregate, not a transient field on Show. 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 synchronized on tryHold, 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.
  • Clock is injected. Test the expiry sweep without Thread.sleep.
  • HoldRegistry.expiredBefore(now) is the sweep’s whole interface. It returns only candidates; the sweep does the rest. Indexing the registry by expiresAt keeps it O(log n) per call.

Trade-offs and extensions#

Decisions explicitly made and what they cost:

DecisionWhyCost if requirements change
Per-show synchronized for tryHoldCorrectness 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 TTLLong 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 seatsCustomers 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-bandAvoids touching the request path; the sweep itself is short.If holds explode (say 10^6 pending), shard the registry; the sweeper trivially parallelises.
PaymentStrategy interfaceCard today; UPI tomorrow; processor migrations without editing BookingService.None — the right shape now.
In-memory stateNo 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 → Refunded on the seat; a RefundService that consults a refund policy (full / partial / none, by time-to-show). Strategy pattern on the policy.
  • Group bookings. A Hold already covers multi-seat. For “split payment across friends” add a SplitPayment strategy that aggregates several payment results into one.
  • Dynamic pricing. A Pricer strategy 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 SeatPreference filter 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-provided Idempotency-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, with Held → Available on expiry or release. Show the activity diagram.
  • “What if payment is slow and the hold expires while the gateway is responding?” — The confirm call checks the hold’s expiry against the clock before releasing seats to the customer; if expired, refund the payment if it succeeded, and surface a HoldExpiredException. The right answer also stamps an Idempotency-Key so a retry resolves cleanly.
  • “How do you stop two customers from grabbing the same row?” — Per-show synchronized on 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 Seat and ShowSeat?”Seat is a layout slot in the screen; ShowSeat is a per-show, status-bearing instance. The same A5 is Available for the 9pm show and Booked for the 6pm show — they’re different objects.
  • “Where would observers be useful?” — On BookingConfirmed and HoldExpired events: receipts via email, SMS for ticket pickup, analytics on conversion. The Notifier lets subscribers attach without BookingService knowing.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.