Design a Movie Ticket Booking System
Model cinemas, shows and per-show seats, then build all-or-nothing seat holds with expiry, safe concurrent booking and an idempotent payment flow.
Requirements
This is the "hard" cousin of the parking lot. The modelling is moderate; the real test is the booking flow: thousands of people open the same popular show, pick overlapping seats, and some of them abandon checkout or fail payment. The design must never sell a seat twice and must not lock seats forever.
Functional
- Users browse movies by city and see the shows (movie + screen + start time) at each cinema.
- Users see the seat map of a show with each seat's state: available, held or booked.
- A user selects one or more seats; the system holds them for a few minutes (all or nothing) while the user pays.
- On successful payment the booking is confirmed and the seats become booked. On failure, cancellation or hold timeout, the seats return to the pool.
- Price depends on seat type (regular, premium, recliner) and on show time (weekend surcharge).
- Users can cancel a confirmed booking and receive a refund.
Non-functional
- No double booking, ever, under heavy concurrency.
- Abandoned holds must free seats automatically (typical timeout: 5 to 10 minutes).
- Payment retries (double clicks, network retries) must not charge twice.
- Pricing and payment providers should be pluggable.
Out of scope (confirm first)
- Search ranking, recommendations, food and beverages, coupons, user accounts and notifications.
Draw a clear line between catalog data (movies, cinemas, screens, seats), which rarely changes and can be cached, and inventory data (the state of each seat for one show), which is hot, contended and must be strongly consistent. Interviewers love hearing this distinction.
Core Entities
The key insight is that a Seat is physical (row C, seat 4, a recliner) and is reused by every show on that screen, while its availability belongs to one show. So we introduce ShowSeat: one per seat per show.
| Entity | Responsibility | Key fields |
|---|---|---|
Movie |
Catalog item | id, title, durationMinutes |
Cinema |
A venue in a city with several screens | id, name, city, screens |
Screen |
An auditorium with a fixed seat layout | id, name, seats |
Seat |
Physical seat (immutable) | id, row, number, type |
Show |
One screening; owns the inventory and its lock | id, movie, cinema, screen, startTime, seats, lock |
ShowSeat |
State of one seat for one show | seat, state, bookingId, holdExpiresAt |
Booking |
A user's order; moves through a state machine | id, userId, show, seatIds, amountCents, status, paymentId |
PricingStrategy |
Price for one seat at one show | interface |
PaymentGateway |
Charge and refund, idempotent by key | interface |
BookingService |
Facade: search, hold, pay, cancel | shows, bookings, clock, hold TTL |
SeatType, SeatState (AVAILABLE, HELD, BOOKED) and BookingStatus (PENDING, CONFIRMED, CANCELLED, EXPIRED, FAILED) are enums.
Class Diagram
classDiagram
class Movie {
+String id
+String title
+int durationMinutes
}
class Cinema {
+String id
+String city
+List~Screen~ screens
}
class Screen {
+String id
+List~Seat~ seats
}
class Seat {
+String id
+SeatType type
}
class Show {
-LocalDateTime startTime
-Map seats
-ReentrantLock lock
+hold(List, String, Instant, Duration) boolean
+confirm(List, String, Instant) boolean
+release(List, String) void
+availableSeatIds(Instant) List
}
class ShowSeat {
-SeatState state
-String bookingId
-Instant holdExpiresAt
+isFree(Instant) boolean
}
class Booking {
-String id
-long amountCents
-BookingStatus status
+moveTo(BookingStatus) void
}
class PricingStrategy {
<<interface>>
+priceCents(Show, Seat) long
}
class WeekendSurgePricing
class PaymentGateway {
<<interface>>
+charge(String, long, String) PaymentResult
+refund(String) void
}
class MockPaymentGateway
class BookingService {
+findShows(String, String) List
+holdSeats(String, String, List) Booking
+pay(String, String) Booking
+cancel(String) Booking
}
Cinema "1" *-- "many" Screen
Screen "1" *-- "many" Seat
Show --> Movie
Show --> Cinema
Show --> Screen
Show "1" *-- "many" ShowSeat
ShowSeat --> Seat
Booking --> Show
BookingService "1" o-- "many" Show
BookingService "1" o-- "many" Booking
BookingService --> PricingStrategy
BookingService --> PaymentGateway
PricingStrategy <|.. WeekendSurgePricing
PaymentGateway <|.. MockPaymentGateway
Design Patterns Used
| Pattern | Where | Why it is justified |
|---|---|---|
| State machine | BookingStatus.canMoveTo() |
Bookings have strict legal transitions (PENDING -> CONFIRMED, never EXPIRED -> CONFIRMED). Encoding them in one table makes illegal moves fail loudly |
| Strategy | PricingStrategy |
Weekday, weekend, surge, matinee and member pricing vary independently of booking logic |
| Adapter / Strategy | PaymentGateway |
Each real provider (cards, UPI, wallets) is wrapped behind one interface |
| Facade | BookingService |
Clients call four verbs; the service coordinates show inventory, bookings and payments |
Dependency injection of Clock |
BookingService |
Hold expiry is time-based; an injected clock makes it testable without sleep() |
Expiry is lazy: a held seat whose holdExpiresAt has passed is simply treated as free by ShowSeat.isFree(now). No background thread has to run on time for correctness. A periodic sweeper is still useful to mark stale bookings EXPIRED and update counters, but it is an optimisation, not a correctness requirement.
Java Implementation
The code compiles as a single Main.java (Java 17+). A small MutableClock lets the demo jump forward in time to show hold expiry deterministically. Prices are in integer cents.
import java.time.*; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.ReentrantLock; enum SeatType { REGULAR, PREMIUM, RECLINER } enum SeatState { AVAILABLE, HELD, BOOKED } enum BookingStatus { PENDING, CONFIRMED, CANCELLED, EXPIRED, FAILED; boolean canMoveTo(BookingStatus next) { return switch (this) { case PENDING -> next == CONFIRMED || next == CANCELLED || next == EXPIRED || next == FAILED; case CONFIRMED -> next == CANCELLED; default -> false; // terminal states }; } } record Movie(String id, String title, int durationMinutes) {} record Seat(String id, int row, int number, SeatType type) {} record Screen(String id, String name, List<Seat> seats) {} record Cinema(String id, String name, String city, List<Screen> screens) {} class SeatUnavailableException extends RuntimeException { SeatUnavailableException(String msg) { super(msg); } } // Mutable per-show state; always accessed under the owning Show's lock. class ShowSeat { final Seat seat; SeatState state = SeatState.AVAILABLE; String bookingId; Instant holdExpiresAt; ShowSeat(Seat seat) { this.seat = seat; } boolean isFree(Instant now) { return state == SeatState.AVAILABLE || (state == SeatState.HELD && !now.isBefore(holdExpiresAt)); } boolean isHeldBy(String id, Instant now) { return state == SeatState.HELD && id.equals(bookingId) && now.isBefore(holdExpiresAt); } void reset() { state = SeatState.AVAILABLE; bookingId = null; holdExpiresAt = null; } } class Show { private final String id; private final Movie movie; private final Cinema cinema; private final Screen screen; private final LocalDateTime startTime; private final Map<String, ShowSeat> seats = new LinkedHashMap<>(); private final ReentrantLock lock = new ReentrantLock(); Show(String id, Movie movie, Cinema cinema, Screen screen, LocalDateTime startTime) { this.id = id; this.movie = movie; this.cinema = cinema; this.screen = screen; this.startTime = startTime; screen.seats().forEach(s -> seats.put(s.id(), new ShowSeat(s))); } String getId() { return id; } Movie getMovie() { return movie; } Cinema getCinema() { return cinema; } LocalDateTime getStartTime() { return startTime; } Seat seat(String seatId) { ShowSeat ss = seats.get(seatId); if (ss == null) throw new IllegalArgumentException("No seat " + seatId); return ss.seat; } // All-or-nothing: either every requested seat becomes HELD for this booking, or none does. boolean hold(List<String> seatIds, String bookingId, Instant now, Duration ttl) { lock.lock(); try { for (String sid : seatIds) { if (!seats.get(sid).isFree(now)) return false; } for (String sid : seatIds) { ShowSeat ss = seats.get(sid); ss.state = SeatState.HELD; ss.bookingId = bookingId; ss.holdExpiresAt = now.plus(ttl); } return true; } finally { lock.unlock(); } } boolean isHeldBy(List<String> seatIds, String bookingId, Instant now) { lock.lock(); try { return seatIds.stream().allMatch(sid -> seats.get(sid).isHeldBy(bookingId, now)); } finally { lock.unlock(); } } // Converts a still-valid hold into a sale; fails if any seat's hold expired or was taken over. boolean confirm(List<String> seatIds, String bookingId, Instant now) { lock.lock(); try { if (!seatIds.stream().allMatch(sid -> seats.get(sid).isHeldBy(bookingId, now))) return false; for (String sid : seatIds) { ShowSeat ss = seats.get(sid); ss.state = SeatState.BOOKED; ss.holdExpiresAt = null; } return true; } finally { lock.unlock(); } } // Frees only the seats this booking still owns (a held seat may already belong to someone else). void release(List<String> seatIds, String bookingId) { lock.lock(); try { for (String sid : seatIds) { ShowSeat ss = seats.get(sid); if (bookingId.equals(ss.bookingId)) ss.reset(); } } finally { lock.unlock(); } } List<String> availableSeatIds(Instant now) { lock.lock(); try { return seats.values().stream().filter(ss -> ss.isFree(now)).map(ss -> ss.seat.id()).toList(); } finally { lock.unlock(); } } } class Booking { private final String id; private final String userId; private final Show show; private final List<String> seatIds; private final long amountCents; private BookingStatus status = BookingStatus.PENDING; private String paymentId; Booking(String id, String userId, Show show, List<String> seatIds, long amountCents) { this.id = id; this.userId = userId; this.show = show; this.seatIds = List.copyOf(seatIds); this.amountCents = amountCents; } String getId() { return id; } String getUserId() { return userId; } Show getShow() { return show; } List<String> getSeatIds() { return seatIds; } long getAmountCents() { return amountCents; } synchronized BookingStatus getStatus() { return status; } synchronized String getPaymentId() { return paymentId; } synchronized void setPaymentId(String p) { paymentId = p; } synchronized void moveTo(BookingStatus next) { if (!status.canMoveTo(next)) throw new IllegalStateException(id + ": " + status + " -> " + next + " not allowed"); status = next; } @Override public synchronized String toString() { return id + "[" + userId + " " + seatIds + " $" + String.format("%.2f", amountCents / 100.0) + " " + status + "]"; } } interface PricingStrategy { long priceCents(Show show, Seat seat); } class WeekendSurgePricing implements PricingStrategy { private final Map<SeatType, Long> base = new EnumMap<>(Map.of( SeatType.REGULAR, 1000L, SeatType.PREMIUM, 1500L, SeatType.RECLINER, 2500L)); public long priceCents(Show show, Seat seat) { DayOfWeek day = show.getStartTime().getDayOfWeek(); boolean weekend = day == DayOfWeek.FRIDAY || day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY; long price = base.get(seat.type()); return weekend ? price * 120 / 100 : price; } } record PaymentResult(boolean success, String paymentId, String message) {} interface PaymentGateway { PaymentResult charge(String idempotencyKey, long amountCents, String cardToken); void refund(String paymentId); } class MockPaymentGateway implements PaymentGateway { private final Map<String, PaymentResult> byKey = new ConcurrentHashMap<>(); private final AtomicLong charges = new AtomicLong(); private final AtomicLong sequence = new AtomicLong(); // Same idempotency key -> same result, and the card is charged only once. public PaymentResult charge(String key, long amountCents, String cardToken) { return byKey.computeIfAbsent(key, k -> { if (cardToken.contains("declined")) return new PaymentResult(false, null, "card declined"); charges.incrementAndGet(); return new PaymentResult(true, "PAY" + sequence.incrementAndGet(), "ok"); }); } public void refund(String paymentId) { System.out.println(" refunded " + paymentId); } long chargeCount() { return charges.get(); } } class BookingService { private final Clock clock; private final Duration holdTtl; private final PricingStrategy pricing; private final PaymentGateway gateway; private final Map<String, Show> shows = new ConcurrentHashMap<>(); private final Map<String, Booking> bookings = new ConcurrentHashMap<>(); private final AtomicLong sequence = new AtomicLong(); BookingService(Clock clock, Duration holdTtl, PricingStrategy pricing, PaymentGateway gateway) { this.clock = clock; this.holdTtl = holdTtl; this.pricing = pricing; this.gateway = gateway; } void addShow(Show show) { shows.put(show.getId(), show); } List<Show> findShows(String city, String title) { return shows.values().stream() .filter(s -> s.getCinema().city().equalsIgnoreCase(city) && s.getMovie().title().equalsIgnoreCase(title)) .sorted(Comparator.comparing(Show::getStartTime)).toList(); } List<String> availableSeats(String showId) { return show(showId).availableSeatIds(clock.instant()); } Booking holdSeats(String userId, String showId, List<String> requested) { Show show = show(showId); List<String> seatIds = requested.stream().distinct().sorted().toList(); long amount = seatIds.stream().mapToLong(id -> pricing.priceCents(show, show.seat(id))).sum(); String bookingId = "BK" + sequence.incrementAndGet(); if (!show.hold(seatIds, bookingId, clock.instant(), holdTtl)) { throw new SeatUnavailableException("Seats " + seatIds + " are not all available"); } Booking booking = new Booking(bookingId, userId, show, seatIds, amount); bookings.put(bookingId, booking); return booking; } Booking pay(String bookingId, String cardToken) { Booking b = booking(bookingId); synchronized (b) { // serialises double clicks on the same booking if (b.getStatus() == BookingStatus.CONFIRMED) return b; // idempotent retry if (b.getStatus() != BookingStatus.PENDING) throw new IllegalStateException("cannot pay a " + b.getStatus() + " booking"); Show show = b.getShow(); if (!show.isHeldBy(b.getSeatIds(), bookingId, clock.instant())) { show.release(b.getSeatIds(), bookingId); b.moveTo(BookingStatus.EXPIRED); throw new IllegalStateException(bookingId + " hold expired; please select seats again"); } PaymentResult result = gateway.charge(bookingId, b.getAmountCents(), cardToken); if (!result.success()) { show.release(b.getSeatIds(), bookingId); b.moveTo(BookingStatus.FAILED); return b; } b.setPaymentId(result.paymentId()); if (!show.confirm(b.getSeatIds(), bookingId, clock.instant())) { gateway.refund(result.paymentId()); // hold lapsed while the card was being charged show.release(b.getSeatIds(), bookingId); b.moveTo(BookingStatus.EXPIRED); throw new IllegalStateException(bookingId + " hold expired during payment; refunded"); } b.moveTo(BookingStatus.CONFIRMED); return b; } } Booking cancel(String bookingId) { Booking b = booking(bookingId); synchronized (b) { if (b.getStatus() == BookingStatus.CONFIRMED) gateway.refund(b.getPaymentId()); b.moveTo(BookingStatus.CANCELLED); b.getShow().release(b.getSeatIds(), bookingId); return b; } } private Show show(String id) { Show s = shows.get(id); if (s == null) throw new IllegalArgumentException("Unknown show " + id); return s; } private Booking booking(String id) { Booking b = bookings.get(id); if (b == null) throw new IllegalArgumentException("Unknown booking " + id); return b; } } class MutableClock extends Clock { private volatile Instant now; private final ZoneId zone; MutableClock(Instant start, ZoneId zone) { this.now = start; this.zone = zone; } void advance(Duration d) { now = now.plus(d); } public ZoneId getZone() { return zone; } public Clock withZone(ZoneId z) { return new MutableClock(now, z); } public Instant instant() { return now; } } public class Main { public static void main(String[] args) throws Exception { List<Seat> layout = new ArrayList<>(); for (int n = 1; n <= 4; n++) layout.add(new Seat("A" + n, 1, n, SeatType.REGULAR)); for (int n = 1; n <= 4; n++) layout.add(new Seat("B" + n, 2, n, SeatType.PREMIUM)); for (int n = 1; n <= 2; n++) layout.add(new Seat("C" + n, 3, n, SeatType.RECLINER)); Screen screen1 = new Screen("S1", "Audi 1", layout); Cinema cinema = new Cinema("CIN1", "Cineplex Downtown", "Seattle", List.of(screen1)); Movie movie = new Movie("M1", "Inception", 148); MutableClock clock = new MutableClock(Instant.parse("2026-11-06T10:00:00Z"), ZoneOffset.UTC); MockPaymentGateway gateway = new MockPaymentGateway(); BookingService service = new BookingService(clock, Duration.ofMinutes(5), new WeekendSurgePricing(), gateway); service.addShow(new Show("SH1", movie, cinema, screen1, LocalDateTime.of(2026, 11, 6, 19, 0))); // a Friday Show show = service.findShows("seattle", "inception").get(0); System.out.println("Found " + show.getId() + " on " + show.getStartTime().getDayOfWeek() + " " + show.getStartTime() + ", free seats: " + service.availableSeats("SH1")); // 8 users race for the same two seats; exactly one must win. ExecutorService pool = Executors.newFixedThreadPool(8); CountDownLatch start = new CountDownLatch(1); Queue<Booking> winners = new ConcurrentLinkedQueue<>(); List<Future<?>> futures = new ArrayList<>(); for (int i = 1; i <= 8; i++) { String user = "racer" + i; futures.add(pool.submit(() -> { start.await(); try { winners.add(service.holdSeats(user, "SH1", List.of("A1", "A2"))); } catch (SeatUnavailableException ignored) { } return null; })); } start.countDown(); for (Future<?> f : futures) f.get(); pool.shutdown(); Booking win = winners.peek(); System.out.println("Racers holding A1+A2: " + winners.size() + ", price $" + String.format("%.2f", win.getAmountCents() / 100.0)); service.pay(win.getId(), "tok_visa"); service.pay(win.getId(), "tok_visa"); // double click System.out.println("After paying twice: " + win.getStatus() + ", cards charged: " + gateway.chargeCount()); attempt("u9 holds A2+A3", () -> service.holdSeats("u9", "SH1", List.of("A3", "A2"))); System.out.println("A3 still free? " + service.availableSeats("SH1").contains("A3")); Booking slow = service.holdSeats("u10", "SH1", List.of("C1")); System.out.println("u10 holds C1; 6 minutes pass..."); clock.advance(Duration.ofMinutes(6)); Booking fast = service.holdSeats("u11", "SH1", List.of("C1")); System.out.println("u11 took over the expired hold on C1"); attempt("u10 pays late", () -> service.pay(slow.getId(), "tok_visa")); Booking declined = service.pay(fast.getId(), "tok_declined"); System.out.println("u11 payment: " + declined.getStatus() + ", C1 free again? " + service.availableSeats("SH1").contains("C1")); System.out.println("Cancel the racer's booking:"); service.cancel(win.getId()); System.out.println(" status " + win.getStatus() + ", free seats: " + service.availableSeats("SH1")); attempt("Pay cancelled booking", () -> service.pay(win.getId(), "tok_visa")); } static void attempt(String label, Runnable action) { try { action.run(); System.out.println(label + ": ok"); } catch (RuntimeException e) { System.out.println(label + ": rejected - " + e.getMessage()); } } }
Demo output (the winning racer's name varies between runs, so the demo prints only counts)
Found SH1 on FRIDAY 2026-11-06T19:00, free seats: [A1, A2, A3, A4, B1, B2, B3, B4, C1, C2] Racers holding A1+A2: 1, price $24.00 After paying twice: CONFIRMED, cards charged: 1 u9 holds A2+A3: rejected - Seats [A2, A3] are not all available A3 still free? true u10 holds C1; 6 minutes pass... u11 took over the expired hold on C1 u10 pays late: rejected - BK10 hold expired; please select seats again u11 payment: FAILED, C1 free again? true Cancel the racer's booking: refunded PAY1 status CANCELLED, free seats: [A1, A2, A3, A4, B1, B2, B3, B4, C1, C2] Pay cancelled booking: rejected - cannot pay a CANCELLED booking
Walkthrough of the key decisions:
- Eight threads request A1 and A2 at the same instant; the show lock lets exactly one hold succeed.
- The show is on a Friday, so two regular seats cost 2 × $12.00 (base $10 plus 20%).
- Paying twice charges the card once: the booking returns early when already
CONFIRMED, and the gateway deduplicates on the booking ID as idempotency key. - The request for A2 and A3 fails as a whole, and A3 is not left half-held.
- u10's hold lapses after 5 minutes, so u11 can take C1. u10's late payment is rejected before charging the card.
- A declined card moves the booking to
FAILEDand returns C1 to the pool. Cancelling a confirmed booking refunds and frees its seats; a cancelled booking cannot be paid.
Concurrency Considerations
Where is the race? Two users read "A1 free" and both write "A1 held". The fix is to make check-and-set atomic for the whole set of requested seats.
- Lock granularity. This design uses one
ReentrantLockperShow. The critical section is tiny (a few map lookups), and contention is naturally partitioned: different shows never block each other. Per-seat locks give more parallelism but need ordered acquisition (sort seat IDs) to avoid deadlock when two users request overlapping sets in different orders. That is whyholdSeatssorts the seat IDs anyway. - All-or-nothing.
holdfirst verifies every seat and only then mutates, all under the lock, so a partial hold is impossible. - Ownership checks.
releasefrees only seats whosebookingIdstill matches. Without that check, an expired booking being cleaned up could free a seat that another user has since held, which is a subtle but classic bug. - Payment never runs under the show lock. Charging a card can take seconds; holding a lock that long would block every user of the show. Instead: verify the hold, charge outside the lock, then
confirmre-checks the hold atomically. If the hold expired mid-payment, we refund (compensating action). - Double clicks are serialized per booking (
synchronized (b)) and the gateway is idempotent by key.
Make the hold TTL comfortably longer than the payment provider's timeout (for example, hold 10 minutes, payment session 8 minutes). Otherwise the refund path, which is correct but annoying for users, becomes common.
Distributed version. With many app servers, the in-memory lock moves into storage:
UPDATE show_seat SET status = 'HELD', booking_id = :booking, hold_expires_at = now() + interval '10 minutes' WHERE show_id = :show AND seat_id IN (:seats) AND (status = 'AVAILABLE' OR (status = 'HELD' AND hold_expires_at < now())); -- commit only if updated row count = number of seats, else roll back
This is optimistic, row-level and all-or-nothing inside one transaction. Alternatives are SELECT ... FOR UPDATE (pessimistic, simple, more blocking) or a Redis SET seat_key booking NX PX 600000 per seat for very hot shows, with the database remaining the source of truth at confirmation.
Extensions
| Requirement | Change |
|---|---|
| Live seat map updates | Observer: Show publishes seat-state changes; a WebSocket gateway pushes them to viewers |
Mark stale bookings EXPIRED |
ScheduledExecutorService sweeper (or delayed queue) per show; correctness already holds via lazy expiry |
| Coupons, member discounts | Decorator around PricingStrategy (e.g. DiscountPricing(base, coupon)) |
| Very popular releases | Virtual waiting room that admits users in batches before they can hold seats |
| Cancellation policy (no refund within 2 hours) | CancellationPolicy strategy consulted by cancel |
| Multiple payment methods | More PaymentGateway adapters chosen by a factory from the user's selection |
| Seat-gap rules (no single empty seat) | Validation step in holdSeats using the row layout |
Interview Tips
- Separate
Seat(physical) fromShowSeat(per-show state) early; it unlocks the rest of the design. - Explicitly walk through the hold, pay, confirm flow and every failure branch: decline, timeout, double click, expiry during payment.
- Explain why you never hold a lock across a network call to the payment provider.
- Mention lazy expiry plus a sweeper, and why the sweeper is not needed for correctness.
- Be ready to translate the in-memory lock into a conditional SQL update or
SELECT ... FOR UPDATE. - Keep money in integer cents and inject the clock; both show production instincts.