Course Content
Object-Oriented Design Interview
14 sections · 29 lessons
Movie Ticket Booking: the seat state machine, double booking and extensions
With ShowSeat in the model, the rest of the problem is about its lifecycle and about what happens when two people reach for the same one. This lesson draws the state machine, writes the hold operation that prevents double booking, and then works through the extension questions this problem attracts.
The class diagram and the booking state machine
Two drawings. The class diagram shows the structure; the state diagram shows the lifecycle, and the lifecycle is where this design's correctness lives.
The seat lifecycle in words
A ShowSeat moves through three states:
- AVAILABLE — nobody has claimed it. Initial state, created with the show.
- HELD — one user has claimed it for a fixed window, with an expiry timestamp and a holder. It is not sold, and it is not for sale.
- BOOKED — payment succeeded. Terminal, unless cancelled.
Four transitions matter, and one of them is the one candidates forget:
| From | Event | To |
|---|---|---|
| AVAILABLE | user selects and holds | HELD |
| HELD | payment succeeds | BOOKED |
| HELD | payment fails | AVAILABLE |
| HELD | hold expires | AVAILABLE |
| BOOKED | booking cancelled before cut-off | AVAILABLE |
The bold one is the forgotten transition. Without it, every abandoned checkout permanently removes a seat from sale.
Two state machines, not one
ShowSeat has a status and so does Booking (pending, confirmed, cancelled, expired). They are related but not the same, and merging them is a modelling error: a booking of three seats is one booking with three seat states, and a partial failure — two seats held, one taken by someone else — has to be representable.
Keep them separate and let Booking coordinate: a booking becomes confirmed only when all of its show-seats are booked.
Preventing double booking, in code
The core of the problem. Get the mechanism right, name the alternatives, and recommend one with a reason.
The naive version and the race
1public Booking hold(String showId, List<String> seatIds, String userId) {2 List<ShowSeat> seats = repository.findShowSeats(showId, seatIds);3 for (ShowSeat s : seats) {4 if (s.getStatus() != AVAILABLE) throw new SeatUnavailableException(s.getId());5 }6 for (ShowSeat s : seats) { // <-- gap between check and act7 s.setStatus(HELD);8 s.setHeldBy(userId);9 s.setHoldExpiresAt(clock.now().plus(HOLD_DURATION));10 }11 repository.saveAll(seats);12 return new Booking(userId, seats);13}Two threads, both checking H12 as AVAILABLE before either writes. Both hold it. Both take payment. Two people arrive at the cinema with a ticket for the same chair.
This is a check-then-act race, and it cannot be fixed by reordering the loops or adding a re-check. The check and the write have to be one indivisible operation.
Three ways to fix it
Option A — pessimistic locking. Lock the rows before checking.
1@Transactional2public Booking hold(String showId, List<String> seatIds, String userId) {3 // ORDER BY seat_id keeps the lock order consistent and prevents deadlock4 List<ShowSeat> seats = repository.findShowSeatsForUpdate(showId, sorted(seatIds));5 for (ShowSeat s : seats) {6 if (s.getStatus() != AVAILABLE) throw new SeatUnavailableException(s.getId());7 }8 seats.forEach(s -> s.hold(userId, clock.now().plus(holdDuration)));9 repository.saveAll(seats);10 return bookingRepository.save(Booking.pending(userId, seats));11}findShowSeatsForUpdate issues SELECT … FOR UPDATE, so the second thread blocks until the first commits, then sees HELD and fails cleanly.
The sorted(seatIds) is not decoration. Two users each booking {H12, H13} in different orders can deadlock; sorting gives every transaction the same lock order. Mentioning this unprompted is a strong signal.
Cost: held database locks for the duration of the transaction. Fine here — the transaction is microseconds and does not include payment.
Option B — optimistic locking. No locks. Every row carries a version; the update asserts it.
1// UPDATE show_seat SET status='HELD', held_by=?, version = version + 12// WHERE id = ? AND status = 'AVAILABLE' AND version = ?3int updated = repository.holdIfAvailable(seatId, userId, expiry, expectedVersion);4if (updated == 0) throw new SeatUnavailableException(seatId); // someone beat usThe loser's update matches zero rows and fails. No blocking at all.
Cost: the losing user gets an error and retries, which is a worse experience precisely when contention is highest — the popular show on release night.
Option C — a unique constraint. Let the database enforce it. A booked_seat table with a unique key on (show_id, seat_id); the second insert violates it.
Cost: it expresses "booked" cleanly and expresses "held with an expiry" awkwardly, since a hold is temporary and a constraint is not. Works well as a final backstop underneath A or B.
The recommendation, and why
| Pessimistic (A) | Optimistic (B) | Constraint (C) | |
|---|---|---|---|
| Behaviour under contention | Second waits, then fails clearly | Second fails immediately | Second fails at insert |
| Throughput when contention is low | Slightly lower | Best | Best |
| Throughput when contention is high | Predictable | Retry storms | Retry storms |
| Complexity in the code | Low | Medium (versions everywhere) | Low |
| Handles the hold state | Naturally | Yes | Awkwardly |
Recommend pessimistic locking for the hold, with a unique constraint as a backstop. The reason to say out loud: contention here is concentrated — hundreds of people want the same twenty seats — so optimistic retries are exactly wrong. The locked section is short and does not include the payment call, so blocking costs microseconds. And this is money: a wrong answer means two people at one chair, so the boring mechanism with predictable behaviour is the right one.
Releasing expired holds
Two mechanisms, both defensible:
- Lazy — when the seat map is read or a hold is attempted, treat any HELD seat whose
holdExpiresAthas passed as AVAILABLE. No background job. Expired seats linger in the database until touched. - Sweeper — a scheduled task every thirty seconds sets expired holds back to AVAILABLE. Clean data, one more moving part.
Use both. Lazy for correctness — it is impossible to hand out a stale hold, even if the sweeper is down — and the sweeper so the seat map looks right to users who are watching it. Belt and braces on a money path is a defensible position, and saying "lazy check for correctness, sweeper for user experience" is a complete answer.
Extensions
1. Dynamic pricing
Prices vary by show time, seat type, demand, and day of the week. This is Strategy again (The seven patterns that actually appear), and the key point is that ShowSeat.price is set when the show is created, not computed at booking time.
Why: a user who sees ₹300 on the seat map and is charged ₹340 at checkout because demand rose in between will file a complaint, and rightly. Freeze the price on the show-seat; recompute only for shows not yet on sale. If the interviewer wants live surge pricing, the honest answer is that you also need to freeze the price for the duration of the hold.
2. Group bookings that must be adjacent
"Four seats together" is a genuinely interesting sub-problem. Seats in a row have consecutive numbers, so scan each row for a run of N consecutive available seats:
1public Optional<List<ShowSeat>> findAdjacent(String showId, int count) {2 for (List<ShowSeat> row : seatsByRow(showId)) {3 int run = 0;4 for (int i = 0; i < row.size(); i++) {5 run = row.get(i).isAvailable() ? run + 1 : 0;6 if (run == count) return Optional.of(row.subList(i - count + 1, i + 1));7 }8 }9 return Optional.empty();10}A linear pass per row. Two refinements worth mentioning: prefer central rows for a better seat, and avoid leaving a single orphan seat at the end of a run — cinemas care about that, and noticing it is a domain-awareness signal.
3. Waitlists
When a show is full, a user joins a queue. On a cancellation, the head of the queue gets a short exclusive window to book. This is Observer (The seven patterns that actually appear): the show publishes "seat released", the waitlist subscribes.
The interesting part is the exclusive window — the released seat must be HELD for the waitlisted user, not made AVAILABLE to everyone, or the queue means nothing. Same hold mechanism, different holder.
4. Hold expiry: timer or lazy check
The mechanism is the lazy-check-plus-sweeper pair from releasing expired holds, above. Interviewers often ask it as a standalone question — "why not just start a timer for each hold?" — so have the comparison ready. A per-hold timer object (one scheduled task per hold) does not survive a restart and does not work across multiple application instances. A periodic sweep plus a lazy check on read is stateless, restart-safe, and works with any number of instances. Recommend the sweep-plus-lazy pair and say why the timer fails.
5. The concurrency question, restated
Raise this yourself if it has not come up:
"One more thing worth naming: everything I've described assumes a single database enforcing the lock. With several application instances that still holds, because the lock is in the database, not in the application. If we ever cached seat availability in application memory, that cache becomes a source of double bookings and I'd want the write path to always go to the database."
That sentence shows you understand where the correctness boundary is, which is the actual question underneath.