Why this matters: the design act in this problem is graded on one question — where does the truth about a seat live, and how does it change hands? Get that right and payment failures, expiring holds, and duplicate confirmations all become state transitions on a clean model. Get it wrong — truth smeared across bookings, holds as timestamps someone might check — and every edge case becomes a special case.
The model in words
Say it before you draw it: "Each show owns its own seat map — one authoritative record of every seat's status for that show. Picking seats creates a hold: a first-class object with an expiry, claiming specific seats atomically. Payment happens against the hold; a booking is only created by confirming one. Every seat-status change goes through the show's seat map — one writer path, so the race has exactly one place to be decided."
Four decisions. Here is each earning its place.
Seat state lives on the show
Lesson 01 flagged the trap: the screen owns the physical layout (rows, seat labels — shared by every show on that screen), while each show owns its availability (F7's status for this showing). Two shows on the same screen hold two independent availability records over the same layout. Separating them is separation of concerns doing real work: layout is immutable reference data; availability is the hot, mutating state. Conflate them and you either copy layout per show (harmless waste) or share availability across shows (a catastrophic bug — buying F7 at 6 pm sells it for 9 pm too).
Per seat and show, the status is a tiny lifecycle:
AVAILABLE --- hold placed ---> HELD --- hold confirmed ---> BOOKED
^ |
+--- expiry / release ------+
Booked is terminal in v1 (cancellation is a later variation). Every transition happens inside the show's seat map — the single writer.
Holds are objects, not timestamps
A hold has identity (id), contents (specific seats), a subject (which show), an owner, and a deadline. Making it a first-class object — rather than scattering "held-until" timestamps on seats — is cohesion: everything about a claim lives in one place, so releasing it (payment failed), expiring it (deadline passed), and confirming it (payment succeeded) are operations on the hold, each releasing or converting all its seats together. A multi-seat hold is atomic in both directions: all seats claimed together or none (partial holds strand users with two of four seats), and all released together.
Expiry deserves design honesty. In a single process the clean mechanism is lazy expiry: every operation that touches a hold or reads availability first asks "has this deadline passed?" and treats an expired hold as released. No background timer thread to reason about, no clock races between a sweeper and a confirmation — the deadline check happens on the same path that's about to act on the answer. A periodic sweep can also reclaim memory, but correctness never depends on it. Stating that split — correctness from lazy checks, hygiene from sweeps — is a senior sentence.
The atomic claim: where the race is decided
The race from lesson 01 resolves in exactly one method: place a hold on these seats. The method checks every requested seat is available (counting expired holds as available) and, only if all are, marks them held — one indivisible check-then-claim. In a single-threaded process, indivisibility is free; with concurrent requests it's a critical section on the show's seat map. Either way, the design point stands: because all claims flow through one method on one owner, there is exactly one place where two users can collide — and it decides first-come cleanly. If seat state had multiple writers, the race would have multiple battlegrounds, and one of them would be wrong.
The booking lifecycle: built for payment's failure modes
Payment can succeed, fail, or vanish into a timeout. The model's answer is that a booking is created only from a successful confirmation of a live hold — the hold is the pending state, so there's no "pending booking" limbo object to clean up:
hold placed -> payment attempted -> success: confirm(holdId) -> BOOKING
-> failure: release(holdId) -> seats freed
-> timeout: do nothing; TTL expires the hold
The timeout row is the quiet best part: unknown outcome requires no special handling, because the TTL already bounds every hold's life. And retry-safety falls out of keying confirmation by hold id — confirming an already-confirmed hold returns the same booking (idempotency: same input, same result, no second effect); confirming an expired one fails cleanly, which is the honest answer when payment succeeded after the deadline — a real edge worth naming aloud.
Invariants worth stating
- a seat (per show) is AVAILABLE, HELD, or BOOKED — exactly one, and only the show's seat map changes it - a hold's seats transition together: all claimed, all released, all confirmed - a booking exists only if its hold was confirmed before expiry - confirm(holdId) is idempotent; expired holds are equivalent to released ones everywhere
What we rejected
Seat state on the screen. The trap itself: one availability record shared by every show on the screen. The moment two shows exist, sold seats leak across them.
Booking-first flows. Create a "pending booking" at seat-pick, patch its status through payment. Now unpaid bookings need cleanup, double-confirm needs deduplication logic, and the seat map must trust booking rows scattered elsewhere — truth has two homes. The hold is the pending state; bookings only ever exist confirmed.
Timestamps-on-seats instead of hold objects. Expiry checks smear across every read path, multi-seat atomicity becomes manual bookkeeping, and "release this user's claim" requires a scan. The hold object is the cohesion that makes all three trivial.
Key takeaway
Truth about a seat lives in exactly one place — the show's seat map — and changes hands only through holds: first-class objects claiming seats atomically, expiring by lazily-checked TTL, released on payment failure, and confirmed idempotently by id into bookings. The race is decided in the one check-then-claim method, timeouts need no handling because the TTL already bounds every hold, and the screen keeps only the immutable layout. One owner, one writer path, one place to collide.