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.
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
| Actor | Use cases |
|---|---|
| Customer | Browse shows, view seat map, hold seats, pay, receive confirmation, cancel booking |
| Theater Admin | Configure 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
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.
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
| From | To | Trigger | Side effect |
|---|---|---|---|
| — | SEATS_HELD | holdSeats() succeeds for all requested seats | holdExpiresAt set on each ShowSeat |
SEATS_HELD | CONFIRMED | Payment gateway callback: success | Each ShowSeat.confirm() called; booking marked paid |
SEATS_HELD | EXPIRED | Background job finds holdExpiresAt in the past | Each ShowSeat.release() called |
CONFIRMED | CANCELLED | User-initiated cancellation | Refund computed by policy; seats released for resale |
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
| Pattern | Where used | Why |
|---|---|---|
| Strategy | PricingStrategy per show (weekend, premium seat, holiday surcharge) | New pricing rules plug in without touching booking logic |
| State | BookingStatus transitions (SEATS_HELD → CONFIRMED / EXPIRED / CANCELLED) | Each state has distinct allowed transitions and side effects |
| Factory | SeatType-specific pricing/validation object creation | Decouples show setup from concrete seat-type logic |
| Observer | Notifying the seat map UI when a ShowSeat status changes | Real-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
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
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
| Decision | Trade-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 read | Scheduled 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 window | Long 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
ShowSeatinstead ofSeat? - Why does
holdSeatssort 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?