Design Movie Ticket Booking (BookMyShow), Low Level Design (LLD) Interview
Object-oriented design of a movie ticket booking system where the hard part is locking selected seats with a timeout so two users never book the same seat under concurrency.
Asked at: Asked in machine coding / LLD rounds at product companies hiring SDE1 to SDE3, common at Walmart, Flipkart, Swiggy, Uber, Atlassian, Oracle, and most India product-company OOD interviews. It is one of the canonical "design BookMyShow" prompts alongside parking lot, elevator, and Splitwise.
Why this is asked
It looks simple (browse shows, pick seats, pay) but the interviewer is really probing whether you can reason about a shared mutable resource, a seat, under concurrent access. The single most important thing they want to see is how you stop two people clicking the same seat at the same instant from both succeeding. That forces a real conversation about locking, lock timeouts, idempotency, and the booking state machine. It also exercises clean entity modelling (a Show is not a Movie, a Seat is not a ShowSeat) and tasteful use of design patterns without over-engineering.
Requirements
Functional
- Browse cities, then theatres in a city, then movies playing in those theatres.
- View shows for a movie at a theatre on a given date (a Show is a movie playing on one screen at one start time).
- View the seat map for a show: which seats exist, their type/price, and which are AVAILABLE, LOCKED, or BOOKED.
- Select one or more available seats and have them temporarily held for the user.
- Confirm a booking by paying; on success the held seats become permanently BOOKED.
- If the user does not pay within a timeout (e.g. 5-10 minutes) the held seats are automatically released back to AVAILABLE.
- Support different seat categories with different prices (e.g. SILVER, GOLD, PLATINUM, RECLINER).
- Allow a user to cancel a confirmed booking (optional, with refund through the payment gateway).
- Generate a unique booking id and ticket for a confirmed booking.
Constraints & non-functional
- A seat can be sold to at most one booking per show. This is the core invariant and must hold under concurrent requests.
- Seat holds must expire automatically so abandoned carts do not permanently block seats.
- Seat selection and booking confirmation must be safe under many users hitting the same show simultaneously.
- Payment is handled by an external gateway; the system must not double-charge and must treat the booking as the source of truth (idempotent confirm).
- In-memory single-JVM design for the interview, but the locking abstraction should be replaceable by a distributed lock (Redis) for the real multi-server system.
- Reads (seat map) are far more frequent than writes (booking); browsing should not block on booking.
Core classes & entities
Movie
Catalog metadata about a film. Pure reference data, independent of where or when it plays.
attrs: movieId, title, durationMinutes, language, genre, certificate
methods: getDurationMinutes()
City
A city that contains theatres. Top of the browse hierarchy.
attrs: cityId, name, List<Theatre> theatres
methods: getTheatres()
Theatre
A physical cinema in a city. Owns its screens.
attrs: theatreId, name, address, City city, List<Screen> screens
methods: getScreens(), addScreen(Screen)
Screen
An auditorium inside a theatre. Defines the physical seat layout that every show on it inherits.
attrs: screenId, name, Theatre theatre, List<Seat> seats
methods: getSeats()
Seat
A physical seat in a screen. Static identity (row, number, category). Has NO booking status, status is per show.
attrs: seatId, rowLabel, seatNumber, SeatCategory category
methods: getCategory()
Show
A specific screening: one movie, on one screen, starting at one time. The unit users actually book against.
attrs: showId, Movie movie, Screen screen, startTime, endTime, Map<String, ShowSeat> showSeats
methods: getShowSeat(seatId), getAvailableSeats()
ShowSeat
The bookable state of one physical seat for one show. This is where AVAILABLE/LOCKED/BOOKED lives, plus the per-show price and which lock holds it.
attrs: showSeatId, Seat seat, Show show, SeatStatus status, price, lockedByUserId, lockExpiryTime, bookingId
methods: getStatus(), isAvailable(), getPrice()
SeatLockProvider
Abstraction that atomically locks a set of show seats for a user with an expiry, validates ownership, and releases them. The concurrency heart of the system.
attrs: lockTimeoutSeconds, internal lock store
methods: lockSeats(Show, List<ShowSeat>, userId), unlockSeats(Show, List<ShowSeat>, userId), validateLock(Show, ShowSeat, userId), getLockedSeats(Show)
Booking
A user's reservation of a set of show seats for a show. Holds the booking state machine (CREATED -> CONFIRMED / EXPIRED / CANCELLED) and the total amount.
attrs: bookingId, userId, Show show, List<ShowSeat> seats, BookingStatus status, amount, Payment payment, createdAt
methods: getStatus(), confirm(), expire(), cancel(), getAmount()
Payment
Record of a payment attempt against a booking. Talks to the external gateway and carries the outcome.
attrs: paymentId, bookingId, amount, PaymentStatus status, gatewayRef, method
methods: process(), getStatus()
BookingService
Orchestrator/facade. Coordinates seat lock, booking creation, payment, and confirmation. The public entry point machine-coding graders call.
attrs: SeatLockProvider lockProvider, PaymentService paymentService, booking store
methods: selectSeats(showId, seatIds, userId), confirmBooking(bookingId, paymentInfo), expireBooking(bookingId), getSeatMap(showId)
PaymentService
Wraps the external payment gateway behind a clean interface so the gateway can be swapped or mocked. Returns success/failure idempotently.
attrs: PaymentGateway gateway
methods: charge(bookingId, amount, paymentInfo), refund(paymentId)
Relationships
- City → composition → Theatre. A city owns many theatres (1 to N). A theatre does not exist outside its city in the browse model.
- Theatre → composition → Screen. A theatre owns many screens (1 to N).
- Screen → composition → Seat. A screen owns a fixed physical layout of seats. Seats are part of the screen, not of any show.
- Show → association → Movie. A show references one movie. Many shows can point to the same movie (N to 1). The movie is shared catalog data, not owned by the show.
- Show → association → Screen. A show plays on exactly one screen. The screen's seat layout is projected into the show's ShowSeats.
- Show → composition → ShowSeat. A show owns one ShowSeat per physical seat in its screen. This is the key modelling move: per-show seat state lives here, not on Seat.
- ShowSeat → association → Seat. Each ShowSeat wraps one physical Seat to inherit its row/number/category, while adding show-specific status and price.
- Booking → association → ShowSeat. A booking holds the set of ShowSeats it reserved (1 to N). On confirm it stamps its bookingId onto each.
- Booking → association → Show. A booking belongs to exactly one show.
- Booking → composition → Payment. A confirmed booking owns its payment record (1 to 1).
- BookingService → association → SeatLockProvider. BookingService delegates all hold/release to the lock provider (dependency injection so the lock can be in-memory or Redis-backed).
- BookingService → association → PaymentService. BookingService delegates charging/refunding to the payment service.
Design patterns used
Strategy in SeatLockProvider interface with InMemorySeatLockProvider and DistributedSeatLockProvider (Redis) implementations; also pricing via a PricingStrategy and PaymentGateway implementations.
The locking mechanism is the part most likely to change between the interview (single JVM) and production (many servers). Hiding it behind an interface lets you swap a ConcurrentHashMap lock for a Redis SETNX lock without touching BookingService. Same reasoning lets weekend/peak pricing or a different gateway (Stripe/Razorpay) drop in.
State in Booking status transitions (CREATED -> CONFIRMED / EXPIRED / CANCELLED) and ShowSeat status (AVAILABLE -> LOCKED -> BOOKED).
Booking and seat behaviour depend entirely on current status. Modelling status as a state machine makes illegal transitions (e.g. confirming an already EXPIRED booking, or booking a LOCKED seat held by someone else) explicit and guards the core invariant.
Facade in BookingService exposes selectSeats / confirmBooking / getSeatMap to clients.
Clients should not orchestrate lock provider + payment + booking store themselves. A single coordinating service keeps the multi-step, concurrency-sensitive flow in one place that is easy to reason about and to make idempotent.
Factory in ShowFactory / SeatFactory to build a Show's ShowSeat map from its Screen layout, and a PaymentGatewayFactory to pick a gateway by region/method.
Creating a Show means projecting every physical seat into a fresh AVAILABLE ShowSeat with the right price. Centralizing that construction avoids inconsistent show setup and keeps the Show constructor clean.
Singleton in The in-memory lock store / lock provider instance (one per JVM).
There must be exactly one authority on who holds which seat. Two lock stores would each grant the same seat to a different user. A single shared instance enforces one source of truth (in production this single authority becomes Redis).
Observer in Optional: a BookingConfirmed event notifies NotificationService (email/SMS) and AnalyticsService.
Sending the ticket SMS and updating analytics are side effects that should not be coupled into the booking transaction. Publishing an event lets listeners react without BookingService knowing about them.
Enums
Key API / methods
List<ShowSeat> getSeatMap(String showId)Returns every ShowSeat for the show with its current status (AVAILABLE/LOCKED/BOOKED) and price. Lazily treats a LOCKED seat whose lockExpiryTime has passed as AVAILABLE so the map is always truthful even before the cleanup sweep runs.
Booking selectSeats(String showId, List<String> seatIds, String userId)Atomically locks the requested seats for the user via SeatLockProvider.lockSeats and, on success, creates a CREATED booking with the locked seats and a computed amount. Throws SeatUnavailableException if any seat is already LOCKED by someone else or BOOKED. This is the all-or-nothing step that prevents double-booking.
boolean SeatLockProvider.lockSeats(Show show, List<ShowSeat> seats, String userId)The concurrency primitive. Under a single short-held lock, checks that every requested seat is lockable (AVAILABLE, or already locked by this same user, or an expired lock), and only then flips them all to LOCKED with this userId and a fresh expiry. All-or-nothing: if any one fails, none are locked.
Booking confirmBooking(String bookingId, PaymentInfo paymentInfo)Validates the booking is still CREATED and its locks are still held by this user (not expired), charges via PaymentService, then on payment success flips the seats to BOOKED, sets booking CONFIRMED, and clears the locks. Idempotent: calling it twice for an already CONFIRMED booking returns the same booking without re-charging.
void expireBooking(String bookingId)Called by the background scheduler when a CREATED booking's hold times out. Releases the seat locks, returns seats to AVAILABLE, and marks the booking EXPIRED. No-op if already CONFIRMED.
Refund cancelBooking(String bookingId, String userId)For a CONFIRMED booking owned by the user, releases the seats back to AVAILABLE, marks booking CANCELLED, and triggers a gateway refund. Subject to a cancellation cutoff before show start.
Code skeleton
// ---------- Enums ----------
enum SeatCategory { SILVER, GOLD, PLATINUM, RECLINER }
enum SeatStatus { AVAILABLE, LOCKED, BOOKED }
enum BookingStatus { CREATED, CONFIRMED, EXPIRED, CANCELLED }
enum PaymentStatus { PENDING, SUCCESS, FAILED, REFUNDED }
// ---------- Static layout ----------
class Seat { // physical seat, no status
String seatId; String row; int number; SeatCategory category;
}
class Screen {
String screenId; String name; List<Seat> seats;
}
class Show {
String showId; Movie movie; Screen screen;
LocalDateTime startTime, endTime;
Map<String, ShowSeat> showSeats; // seatId -> per-show state
ShowSeat seatFor(String seatId) { return showSeats.get(seatId); }
}
// ---------- Per-show bookable seat ----------
class ShowSeat {
String showSeatId; Seat seat; Show show;
SeatStatus status = SeatStatus.AVAILABLE;
double price;
String lockedByUserId;
Instant lockExpiry;
String bookingId;
boolean lockableBy(String userId, Instant now) {
if (status == SeatStatus.BOOKED) return false;
if (status == SeatStatus.AVAILABLE) return true;
// LOCKED: ok only if expired or held by the same user
return now.isAfter(lockExpiry) || userId.equals(lockedByUserId);
}
}
// ---------- Locking (Strategy) ----------
interface SeatLockProvider {
boolean lockSeats(Show show, List<ShowSeat> seats, String userId);
void unlockSeats(Show show, List<ShowSeat> seats, String userId);
boolean validateLock(Show show, ShowSeat seat, String userId);
}
class InMemorySeatLockProvider implements SeatLockProvider {
private final Duration lockTtl;
// one mutex per show keeps contention scoped to the same screening
private final Map<String, Object> showLocks = new ConcurrentHashMap<>();
private Object mutex(String showId) {
return showLocks.computeIfAbsent(showId, k -> new Object());
}
public boolean lockSeats(Show show, List<ShowSeat> seats, String userId) {
synchronized (mutex(show.showId)) { // critical section
Instant now = Instant.now();
for (ShowSeat s : seats) // check ALL first
if (!s.lockableBy(userId, now)) return false;
Instant expiry = now.plus(lockTtl);
for (ShowSeat s : seats) { // then set ALL
s.status = SeatStatus.LOCKED;
s.lockedByUserId = userId;
s.lockExpiry = expiry;
}
return true; // all-or-nothing
}
}
public void unlockSeats(Show show, List<ShowSeat> seats, String userId) {
synchronized (mutex(show.showId)) {
for (ShowSeat s : seats)
if (s.status == SeatStatus.LOCKED && userId.equals(s.lockedByUserId)) {
s.status = SeatStatus.AVAILABLE;
s.lockedByUserId = null; s.lockExpiry = null;
}
}
}
public boolean validateLock(Show show, ShowSeat s, String userId) {
synchronized (mutex(show.showId)) {
return s.status == SeatStatus.LOCKED
&& userId.equals(s.lockedByUserId)
&& Instant.now().isBefore(s.lockExpiry);
}
}
}
// Production: same interface, body uses Redis SET key val NX PX ttl
// (atomic on the server, TTL handles expiry and crashed holders).
// ---------- Booking ----------
class Booking {
String bookingId; String userId; Show show;
List<ShowSeat> seats; double amount;
BookingStatus status = BookingStatus.CREATED;
Payment payment; Instant createdAt;
}
// ---------- Orchestrator (Facade) ----------
class BookingService {
private final SeatLockProvider lockProvider;
private final PaymentService paymentService;
private final Map<String, Booking> bookings = new ConcurrentHashMap<>();
private final Map<String, Show> shows;
public Booking selectSeats(String showId, List<String> seatIds, String userId) {
Show show = shows.get(showId);
List<ShowSeat> seats = seatIds.stream()
.map(show::seatFor).collect(toList());
if (!lockProvider.lockSeats(show, seats, userId))
throw new SeatUnavailableException("One or more seats just got taken");
double amount = seats.stream().mapToDouble(s -> s.price).sum();
Booking b = new Booking(UUID.randomUUID().toString(),
userId, show, seats, amount);
bookings.put(b.bookingId, b);
// schedule expireBooking(b.bookingId) after lock TTL
return b;
}
public synchronized Booking confirmBooking(String bookingId, PaymentInfo info) {
Booking b = bookings.get(bookingId);
if (b.status == BookingStatus.CONFIRMED) return b; // idempotent
if (b.status != BookingStatus.CREATED)
throw new IllegalStateException("Booking " + b.status);
for (ShowSeat s : b.seats) // re-validate holds
if (!lockProvider.validateLock(b.show, s, b.userId))
throw new SeatLockExpiredException("Hold expired, please retry");
Payment p = paymentService.charge(bookingId, b.amount, info);
if (p.status != PaymentStatus.SUCCESS)
throw new PaymentFailedException();
for (ShowSeat s : b.seats) { // commit
s.status = SeatStatus.BOOKED;
s.bookingId = bookingId;
s.lockedByUserId = null; s.lockExpiry = null;
}
b.payment = p;
b.status = BookingStatus.CONFIRMED;
return b; // publish BookingConfirmed event
}
public synchronized void expireBooking(String bookingId) {
Booking b = bookings.get(bookingId);
if (b == null || b.status != BookingStatus.CREATED) return; // race-safe
lockProvider.unlockSeats(b.show, b.seats, b.userId);
b.status = BookingStatus.EXPIRED;
}
}How it works
Start by separating static layout from per-show state, because that is the decision the whole design hangs on. A City owns Theatres, a Theatre owns Screens, and a Screen owns a fixed list of Seats. None of those carry a booking status, a physical seat does not know if it is sold, because it is sold differently for the 3pm show and the 6pm show. The bookable unit is the Show: one Movie playing on one Screen at one start time. When a Show is created (via a factory) it projects every physical Seat in its Screen into a ShowSeat, each starting AVAILABLE with a per-show price. ShowSeat is where AVAILABLE/LOCKED/BOOKED, the price, and the lock owner live. Getting this Seat-vs-ShowSeat split right is half the interview.
Now the main flow. A user browses City -> Theatre -> Movie -> Show, then calls getSeatMap(showId) to see the grid. Seats already BOOKED or held by someone else show as unavailable; importantly, getSeatMap treats a LOCKED seat whose expiry has passed as AVAILABLE, so the map is honest even between cleanup sweeps. The user picks A1, A2, A3 and the client calls selectSeats. This is the moment two users can collide, so all the concurrency control lives behind one call: SeatLockProvider.lockSeats. Inside it, under a per-show mutex (so contention is scoped to one screening, not the whole cinema), it first checks that every requested seat is lockable, and only if all are does it flip them all to LOCKED with this user's id and an expiry a few minutes out. It is strictly all-or-nothing: if A2 was grabbed a millisecond earlier, the whole call returns false, nothing is locked, and selectSeats throws SeatUnavailable. That single atomic check-and-set is what makes double-booking impossible, two concurrent callers serialize on the mutex, the first wins all three seats, the second sees A2 LOCKED and fails cleanly.
On a successful lock, BookingService creates a Booking in CREATED state with the locked seats and the summed amount, and schedules expireBooking to fire when the hold TTL elapses. The user goes to pay and calls confirmBooking. Here the system does not trust that the lock is still valid, it re-validates every seat's lock against this user and the current time. If the user dawdled and the hold expired (or a sweep already released it), confirm fails with a clear retry message rather than confirming on a stale lock. If the locks are good, it charges through PaymentService; on success it flips the seats from LOCKED to BOOKED, stamps the bookingId, clears the lock fields, and moves the Booking to CONFIRMED. confirmBooking is idempotent: a retried confirm on an already CONFIRMED booking returns the same booking and does not charge again.
The expiry path is the safety net and it races with confirm by design, which is fine because both go through the Booking state machine. If the user never pays, the scheduled expireBooking runs, but it first checks the booking is still CREATED; if confirm already moved it to CONFIRMED, expire is a no-op. Conversely if expire wins, the seats are unlocked and the booking is EXPIRED, so a late confirm sees a non-CREATED state and is rejected. Only one transition out of CREATED can ever happen. Because every lock carries an expiry (a Redis TTL in production), a crashed user session or a dead server never strands a seat, the hold simply evaporates and the seat returns to the pool. To move from this single-JVM design to many servers, you swap InMemorySeatLockProvider for a Redis-backed one using SET key value NX PX ttl; the BookingService and the entire flow stay identical because all the concurrency was deliberately hidden behind the SeatLockProvider Strategy.
Edge cases & gotchas
- Two users select the exact same seat at the same millisecond. Only one lockSeats call may win. The check-and-set inside the lock provider must be atomic (single lock / compare-and-swap), otherwise both read AVAILABLE and both write LOCKED.
- Partial selection: a user picks seats A1, A2, A3 but A2 was just taken. The whole selection must fail and release A1/A3, never lock a partial set, because the UI promised the user all three.
- Hold expires while the user is on the payment screen. confirmBooking must re-validate the lock (validateLock) and reject with a clear error if the lock expired or was reassigned; never confirm on a stale lock.
- User pays but the response is lost (network blip) and they retry. confirmBooking must be idempotent keyed on bookingId so the gateway is not charged twice.
- Lock expiry sweep races with confirm: a background thread tries to expire a booking at the same instant the user confirms it. Guard with the booking state machine, only one of EXPIRE or CONFIRM can transition out of CREATED.
- A seat ends up LOCKED forever because the process that held it crashed. The lockExpiryTime makes every lock self-healing; getSeatMap also treats expired locks as available so a crash never permanently strands a seat.
- Same user clicks a seat they already hold. lockSeats should be reentrant for the same userId (extend the hold) rather than throwing.
- Payment succeeds at the gateway but our confirm code throws before flipping seats to BOOKED. Reconcile via gateway webhook + the booking record so money and seats stay consistent; treat the booking, not the charge, as source of truth.
- Cancelling after the show has started, or cancelling an already CANCELLED/EXPIRED booking, must be rejected by the state machine.
- Clock skew across servers in the distributed version makes lock expiry unreliable; rely on Redis TTL (server-side expiry) rather than comparing local timestamps.