Movie ticket booking
Design seat booking for a movie ticketing app like BookMyShow. Users pick seats, the app holds them for a few minutes during payment, and two users can never book the same seat.
Design the booking part of a movie ticketing app like BookMyShow. Show the seat map for a show, let a user select seats, hold them while they pay, and confirm the booking. No seat may ever be sold twice.
Questions to ask first
- Do we cover search and listings? Assume no. Focus on seats, holds and bookings for one show.
- How long is a hold? Assume 10 minutes. After that the seats return to the pool if payment has not finished.
- Can a user book many seats at once? Assume up to 10, and the hold is all or nothing.
- Does one server handle all requests? Assume one process with many threads for the machine-coding round. The deep dive covers a database.
- Do seat prices differ? Assume yes by seat type (regular, premium, recliner), fixed per show.
- What happens if payment succeeds after the hold expired? Assume confirm fails and we refund at once, even if nobody took the seats yet.
Requirements
- Model cinemas, screens and seats, and shows of a movie on a screen at a time.
- Show the seat map of a show with each seat's status: available, held or booked.
- Hold a set of seats for one user for 10 minutes, all or nothing.
- Confirm a booking after payment, only if this user still holds every seat.
- Release seats when a hold expires or the user cancels.
- Never book one seat of one show to two users, even under many parallel requests.
Out of scope
- Movie search, reviews and recommendations.
- The payment gateway itself; we call it through an interface.
- Food orders, offers and loyalty points.
Entities
- Cinema -> Screen -> Seat (row, number, SeatType)
- Movie: title, duration, language
- Show: movie, screen, startTime, showSeats -> seat(id)
- ShowSeat: one seat in one show, SeatStatus (AVAILABLE, HELD, BOOKED), heldBy, holdExpiresAt
- SeatHoldService -> hold(show, seats, user), confirm(), release()
- Booking: user, show, seats, amount, status
- BookingService -> book(): hold, pay, confirm; PaymentGateway -> charge()
Classes in the model design
- Cinema (class)
- Screen (class)
- Seat (class)
- SeatType (enum)
- Movie (class)
- Show (class)
- ShowSeat (class)
- SeatStatus (enum)
- Booking (class)
- SeatHoldService (class)
- BookingService (class)
- PaymentGateway (interface)
The main use case, step by step
- At 7 pm Riya opens the 9 pm show and picks seats F7 and F8. The app calls `BookingService.startBooking(showId, [F7, F8], riya)`.
- `SeatHoldService.hold` takes the lock for this one show. Other shows are not blocked. Inside the lock it checks that both `ShowSeat` objects are free: AVAILABLE, or HELD with an expired hold.
- Both are free, so it marks them HELD by Riya until 7:10 pm and releases the lock. If either seat was taken, it changes nothing and returns false, and the app asks Riya to pick again.
- At the same moment Karan tries F8. His call waits for the show lock, then sees F8 is HELD by Riya and not expired. His hold fails cleanly.
- Riya pays. `BookingService` calls `PaymentGateway.charge` outside the lock, because payment is slow and must not block other users.
- After payment, `SeatHoldService.confirm` takes the lock again, checks that Riya still holds both seats, and marks them BOOKED. The service then creates the `Booking`. If the hold had expired, confirm fails and the service refunds Riya.
Patterns used
- State (as an enum with guarded moves): ShowSeat moves between AVAILABLE, HELD and BOOKED, and only through its own methods.. Every change of status goes through one place, so invalid moves (BOOKED straight to HELD) are easy to reject and easy to test. An enum is enough here; full State classes would be too much.
- Lock per show (lock striping): SeatHoldService keeps one ReentrantLock per show in a ConcurrentHashMap.. Holding seats must be atomic: check all, then mark all. One global lock would make every show wait for every other. A lock per show keeps shows independent and still prevents double booking.
- Facade: BookingService gives clients two calls (start and complete) and hides holds, payment and refunds.. The app does not need to know the order of hold, charge, confirm and refund. Keeping that order in one class means the rule cannot be skipped by a careless caller.
Key code
ShowSeat: status changes in one place
public enum SeatStatus { AVAILABLE, HELD, BOOKED }
/** One physical seat in one show. Only SeatHoldService calls these, under the show's lock. */
public final class ShowSeat {
private final Seat seat;
private final long pricePaise;
private SeatStatus status = SeatStatus.AVAILABLE;
private String heldBy;
private Instant holdExpiresAt;
public ShowSeat(Seat seat, long pricePaise) {
this.seat = seat;
this.pricePaise = pricePaise;
}
/** An expired hold counts as free, so we do not depend on a cleanup job running on time. */
boolean isFree(Instant now) {
return status == SeatStatus.AVAILABLE
|| (status == SeatStatus.HELD && !now.isBefore(holdExpiresAt));
}
boolean isHeldBy(String userId, Instant now) {
return status == SeatStatus.HELD && userId.equals(heldBy) && now.isBefore(holdExpiresAt);
}
void hold(String userId, Instant until) {
status = SeatStatus.HELD;
heldBy = userId;
holdExpiresAt = until;
}
void book() {
if (status != SeatStatus.HELD) throw new IllegalStateException("Only a held seat can be booked");
status = SeatStatus.BOOKED;
heldBy = null;
holdExpiresAt = null;
}
void release() {
status = SeatStatus.AVAILABLE;
heldBy = null;
holdExpiresAt = null;
}
public long pricePaise() { return pricePaise; }
public Seat seat() { return seat; }
}
Thread-safe hold and confirm, one lock per show
public final class SeatHoldService {
private static final Duration HOLD_TIME = Duration.ofMinutes(10);
private final ConcurrentMap<String, ReentrantLock> showLocks = new ConcurrentHashMap<>();
private final Clock clock; // injected, so tests can move time forward
public SeatHoldService(Clock clock) { this.clock = clock; }
private ReentrantLock lockFor(Show show) {
return showLocks.computeIfAbsent(show.id(), id -> new ReentrantLock());
}
/** All or nothing: every seat is held for this user, or none is. */
public boolean hold(Show show, List<String> seatIds, String userId) {
ReentrantLock lock = lockFor(show);
lock.lock();
try {
Instant now = clock.instant();
List<ShowSeat> seats = seatIds.stream().map(show::seat).toList();
if (!seats.stream().allMatch(s -> s.isFree(now))) return false;
Instant until = now.plus(HOLD_TIME);
seats.forEach(s -> s.hold(userId, until));
return true;
} finally {
lock.unlock();
}
}
/** Called after payment. Fails if any hold expired or now belongs to someone else. */
public boolean confirm(Show show, List<String> seatIds, String userId) {
ReentrantLock lock = lockFor(show);
lock.lock();
try {
Instant now = clock.instant();
List<ShowSeat> seats = seatIds.stream().map(show::seat).toList();
if (!seats.stream().allMatch(s -> s.isHeldBy(userId, now))) return false;
seats.forEach(ShowSeat::book);
return true;
} finally {
lock.unlock();
}
}
}
Deep dives
Preventing double booking
Weak: Check if (seat.status == AVAILABLE) and then set it to HELD, with no lock. Two threads can both pass the check before either writes. This is a race condition (the result depends on timing), and both users get seat F8.
Good: Make hold a synchronized method on the service. Check and set now happen as one step, so double booking is impossible. But one lock serves every show in the city, so a busy release night queues all users behind each other.
Great: Use one lock per show, kept in a ConcurrentHashMap. Inside it, check all requested seats and mark them all, so the hold is atomic and all or nothing. Never call payment while holding the lock. With a database, do the same with one conditional update: UPDATE show_seat SET status = 'HELD' ... WHERE show_id = ? AND seat_id IN (...) AND status = 'AVAILABLE'. If the updated row count is less than the seat count, roll back.
Trade-off: In-memory locks work only inside one process. Once you run several servers, the database (a conditional update or a version column for optimistic locking) must be the judge. Optimistic locking fails fast under heavy contention; pessimistic row locks (SELECT FOR UPDATE) wait instead.
Expiring holds
Weak: Holds never expire. A user who closes the app during payment blocks two seats until someone cleans them by hand. On a busy show this hides seats that real buyers want.
Good: Store holdExpiresAt and run a scheduled job every 30 seconds that releases expired holds. Seats come back on their own. But between job runs, an expired seat still looks taken, and the job must take the same show lock to be safe.
Great: Treat an expired hold as free at the moment you check it: isFree(now) returns true for HELD seats whose time has passed. Correctness no longer depends on the job. Keep the job only to tidy the data and to show fresh seat maps. Inject a Clock so a test can move time forward 11 minutes and check that the seats came back.
Trade-off: Lazy expiry means the seat map shows stale HELD seats until someone touches them or the job runs. That is fine for correctness, but the seat map should compute status with the same isFree(now) rule, so users do not see grey seats that are really free.
Where seat status lives
Weak: Put status on Seat. But seat F8 of Screen 2 exists in every show on that screen. Booking F8 for the 7 pm show would block it for the 10 pm show too.
Good: Create a ShowSeat for each seat in each show. It holds the status, price and hold details for that show only. The physical Seat keeps row, number and type, which never change.
Great: Do the same, and create ShowSeat objects when a show is scheduled, priced by seat type. Keep Booking separate from ShowSeat: a booking is the user's record (with amount and payment), while the seat status is inventory. Cancelling a booking releases its seats through the hold service, so the same lock rules apply.
Trade-off: A ShowSeat per seat per show means many rows: 300 seats times 5 shows times 1,000 screens is 1.5 million a day. That is normal for a database, but in memory you would load only today's shows.
Now add one more feature
- Now add dynamic pricing: seats cost more as the show fills up. What changes? Add a `PricingStrategy` interface (fixed, demand-based) that `BookingService` calls when the hold is created. The price is stored on the hold, so the user pays what they saw.
- Now add a waitlist for sold-out shows. What changes? Add a `Waitlist` per show. When `release` frees seats, the hold service publishes an event and an observer offers the seats to the first user in the queue with a short hold.
- Now run on three servers. What changes? In-memory locks no longer protect anything. Move the check-and-set into the database as a conditional update, or use a version column, and keep `SeatHoldService` as the class that wraps that query.
- Now let users cancel a booking up to two hours before the show. What changes? Add CANCELLED to `BookingStatus`, a `cancel` method that checks the time rule, releases the seats under the show lock, and calls `PaymentGateway.refund`.
What each level is expected to show
SDE-1: Model Cinema, Screen, Seat, Show, ShowSeat and Booking with a SeatStatus enum. Explain why status sits on ShowSeat, not Seat. Make hold safe with a synchronized method, and show a main method where two threads try the same seat and only one wins.
SDE-2: Everything at SDE-1, plus a lock per show, all-or-nothing holds, expiry checked at read time with an injected Clock, and payment outside the lock with refund on a failed confirm. Explain how the same rule moves to a database with a conditional update or optimistic locking.
Common mistakes
- Putting seat status on the physical Seat, so one booking blocks the seat in every show.
- Check-then-set with no lock, which lets two users book the same seat.
- One global lock for all shows, which makes every user wait for every other user.
- Calling the payment gateway while holding the lock, so a slow payment blocks the whole show.
- No hold expiry, or expiry that depends only on a background job running on time.
- Holding seats one by one, so a user can end up with half of a group booking.