Object-Oriented Design Interview

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:

FromEventTo
AVAILABLEuser selects and holdsHELD
HELDpayment succeedsBOOKED
HELDpayment failsAVAILABLE
HELDhold expiresAVAILABLE
BOOKEDbooking cancelled before cut-offAVAILABLE

The bold one is the forgotten transition. Without it, every abandoned checkout permanently removes a seat from sale.

AVAILABLEHELDBOOKEDreserve() — 10 min lockpay() succeedstimeout, or pay() failsThe HELD state is the whole designWithout it, two users pay for the same seat and one of them has to be refunded by a human.the hold must expire on a timer, not onthe user closing the tab
Everything hard about ticket booking lives in the arrow back from HELD to AVAILABLE.
Cinema- name: String- halls: ListHall- seats: List+ layout() SeatMapShow- movie: Movie- hall: Hall- startsAt: Instant+ availableSeats() ListSeat- row: char- number: int- tier: SeatTierShowSeat- status: SeatStatus- heldUntil: Instant?+ reserve()+ release()Booking- user: User- seats: List- status: BookingStatus+ confirm()+ cancel()Payment- amount: Money- method: PaymentMethod+ process() booleanMovie- title: String- runtime: Duration11..*11..*runs in11..*1..*settled byofnotationcomposition — owns; dies with itaggregation — has, but can outliveassociation — knows aboutSeat is the physical chair; ShowSeat is that chair forone screening. Collapsing the two is the mostcommon modelling mistake in this question.
Splitting Seat from ShowSeat is what lets the same chair be free for the 6 pm show and sold for the 9 pm one.

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 lifecycle of one ShowSeatAVAILABLEHELD,with expiryBOOKEDBack toAVAILABLEThe AVAILABLE to HELD move must be one atomic compare-and-set, not a read then a write.
Check-then-act is the race; the fix is making the check and the act a single indivisible step.

The naive version and the race

Java
public Booking hold(String showId, List<String> seatIds, String userId) {    List<ShowSeat> seats = repository.findShowSeats(showId, seatIds);    for (ShowSeat s : seats) {        if (s.getStatus() != AVAILABLE) throw new SeatUnavailableException(s.getId());    }    for (ShowSeat s : seats) {                    // <-- gap between check and act        s.setStatus(HELD);        s.setHeldBy(userId);        s.setHoldExpiresAt(clock.now().plus(HOLD_DURATION));    }    repository.saveAll(seats);    return new Booking(userId, seats);}

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.

Java
@Transactionalpublic Booking hold(String showId, List<String> seatIds, String userId) {    // ORDER BY seat_id keeps the lock order consistent and prevents deadlock    List<ShowSeat> seats = repository.findShowSeatsForUpdate(showId, sorted(seatIds));    for (ShowSeat s : seats) {        if (s.getStatus() != AVAILABLE) throw new SeatUnavailableException(s.getId());    }    seats.forEach(s -> s.hold(userId, clock.now().plus(holdDuration)));    repository.saveAll(seats);    return bookingRepository.save(Booking.pending(userId, seats));}

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.

Java
// UPDATE show_seat SET status='HELD', held_by=?, version = version + 1//  WHERE id = ? AND status = 'AVAILABLE' AND version = ?int updated = repository.holdIfAvailable(seatId, userId, expiry, expectedVersion);if (updated == 0) throw new SeatUnavailableException(seatId);   // someone beat us

The 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 contentionSecond waits, then fails clearlySecond fails immediatelySecond fails at insert
Throughput when contention is lowSlightly lowerBestBest
Throughput when contention is highPredictableRetry stormsRetry storms
Complexity in the codeLowMedium (versions everywhere)Low
Handles the hold stateNaturallyYesAwkwardly

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 holdExpiresAt has 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.

The five follow-upsAbsorbed,or rebuilt?Dynamic pricingAdjacent group seatsWaitlistsTimer or lazy expiryConcurrency, restated
Price is set on ShowSeat when the show is created, never 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:

Java
public Optional<List<ShowSeat>> findAdjacent(String showId, int count) {    for (List<ShowSeat> row : seatsByRow(showId)) {        int run = 0;        for (int i = 0; i < row.size(); i++) {            run = row.get(i).isAvailable() ? run + 1 : 0;            if (run == count) return Optional.of(row.subList(i - count + 1, i + 1));        }    }    return Optional.empty();}

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.