Design a Movie Ticket Booking System
Build a Movie Ticket Booking System
"Design a movie ticket booking system" reads like a modeling exercise — Movie, Show, Seat, Booking — right up until the interviewer asks "what happens when two people tap seat A5 in the same second?" That single question is the entire interview. You already understand the class breakdown from the Design a Movie Ticket Booking System walkthrough; this kata makes you build the seat-concurrency mechanism yourself — a seat state machine, a lock-free compare-and-set that makes exactly one buyer win, a payment-window hold with a timeout so an abandoned cart doesn't lock the seat forever, and a concurrency bug you will reproduce and fix — in Java and Go, from an empty file. This is the canonical seat-concurrency problem: the same "lost update on a shared resource" bug that shows up in flight seats, hotel rooms, and limited-inventory flash sales, just with real money riding on getting it right.
The Trap
Opening night for a hot new release, show 99, seat A5 — the last good seat in the theater. Two customers, Alice and Bob, both have the seat map open and both tap A5 within the same millisecond. The obvious first implementation checks a flag and flips it:
// ILLUSTRATIVE ONLY -- naive pseudocode, not the real reference implementation below.
if (seat.isAvailable) { // CHECK
seat.isAvailable = false; // ACT
seat.holder = userId;
return true; // "you got the seat!"
}
return false;
Trace it with real timestamps. Both requests hit the same JVM/process (or the same database row, if this check-then-act is done as two separate SQL statements without a transaction that actually serializes it):
- t = 100ms — Alice's request reads
seat.isAvailable→true. - t = 101ms — Bob's request reads
seat.isAvailable→true(Alice hasn't written her update yet). - t = 102ms — Alice's request writes
isAvailable = false, holder = "alice". - t = 103ms — Bob's request writes
isAvailable = false, holder = "bob", overwriting Alice's holder field.
Both requests return true. Both Alice and Bob walk into the theater holding a ticket for seat A5. This is a classic read-modify-write race (a "lost update") — the gap between the CHECK and the ACT is wide enough for a second thread to slip through, and nothing about "add more RAM" or "use a faster database" closes that gap. You need a mechanism that makes the check-and-flip a single, indivisible operation. That mechanism — not the class diagram — is what this kata builds.
Scope it like a senior
Before writing a line of seat-locking code, pin the contract down:
- What's the hard guarantee? A seat is sold at most once, ever, for a given show. Never double-sell — this is non-negotiable, it's the one thing the interviewer is grading.
- What happens between "select seat" and "payment clears"? Real payment takes seconds to minutes (card auth, 3-D Secure, a slow gateway). The seat can't sit fully open during that window (someone else grabs it while Alice is paying) and it can't be permanently claimed before payment succeeds (Alice abandons the checkout, seat is dead forever). Answer: a HELD state with a TTL — reserve it for, say, 5–10 minutes; if payment doesn't land inside that window, the seat becomes sellable again automatically.
- Single seat or a group? Real booking flows sell 1–8 seats in one transaction — a family doesn't want 3 of 4 seats. The hold/confirm must be all-or-nothing across the requested set.
- What's the scale shape? Not "high average traffic" — it's extreme contention on a tiny number of hot rows at a specific instant (the last few seats of a sold-out opening night, or an entire flash sale hitting the same few hundred seats in the same second). Optimize for that shape, not for uniform load.
- Out of scope for this kata (name as extensions, don't build by default): the actual payment gateway integration, dynamic/surge pricing, seat-map search and discovery, refunds and cancellations after the showtime. These don't change the concurrency mechanism, which is the actual subject here.
Answer: one state machine per seat-per-show (AVAILABLE → HELD → BOOKED, HELD → AVAILABLE on timeout/cancel), a TTL-bound hold for the payment window, all-or-nothing multi-seat booking, and a concurrency-safe transition that guarantees exactly one winner under contention.
Reason to the design
Simplest thing that could work is the trap itself: a plain boolean flag, checked then flipped, no synchronization. Why it fails: shown above — the CHECK and the ACT are two separate operations with a visible gap, so two threads can both pass the check before either writes.
First fix that comes to mind: a lock. Wrap the check-and-flip in a mutex (Java synchronized, Go sync.Mutex) scoped to that one seat. This works — whichever thread gets the lock first does the whole check-and-flip atomically, and the second thread blocks, then sees the seat already taken. It's simple and it's correct. But it has a real cost: the losing thread blocks — it sits parked until the lock holder finishes. If the lock holder gets preempted, or the critical section grows (someone adds a slow audit-log call inside the locked region — an easy accident), every other buyer racing for that seat stalls behind it.
The better fix: compare-and-set (CAS) on an immutable state snapshot. Instead of a mutable isAvailable flag guarded by a lock, model the seat's entire state (status + who's holding it + when the hold expires) as one immutable snapshot object, held in an atomic reference. A transition is: read the current snapshot, check it's eligible, build the *next* snapshot, then atomically swap old-for-new with a hardware compare-and-swap — which only succeeds if nobody else changed the snapshot in between. If it fails, loop and re-read: either the seat is genuinely gone (return false immediately, no blocking) or the CAS just lost a footrace and retries against the fresh value (bounded by however many concurrent writers there are — not an infinite spin). No thread ever blocks another — the whole system keeps making progress even if one thread is slow or preempted, which a blocking lock cannot promise.
Why not confirm straight to BOOKED? Because payment takes real wall-clock time and can fail. Jumping straight from AVAILABLE to a permanent BOOKED the instant a seat is selected would either let Alice's slow card auth block Bob out of a seat Alice may never actually pay for, or force you to decide "is this attempt done yet?" with no clean signal. The fix is the middle state: AVAILABLE → HELD (with an expiry timestamp baked into the same atomic snapshot) → BOOKED once payment clears. If payment never clears, the hold is worthless data sitting in memory — so expiry has to be enforced somewhere.
Background sweep vs. lazy expiry. Two ways to enforce the TTL: (a) a background thread that periodically scans every HELD seat and flips expired ones back to AVAILABLE, or (b) lazy expiry — bake holdExpiresAt into the snapshot, and treat a HELD seat whose expiry has passed as eligible for a *new* hold the next time anyone calls tryHold on it. This kata uses lazy expiry: it needs zero background coordination, and correctness doesn't depend on a sweep interval — the very next attempt on that seat sees the truth. The trade-off (made explicit in the table below): the seat *looks* held in a naive read until someone actually contests it, so a production system pairs lazy expiry with a UI countdown timer and, optionally, a low-frequency sweep purely for "seats remaining" display freshness — never for correctness.
Multi-seat, all-or-nothing. A group of 3 wants A5, A6, A7. Hold each one in turn; if any fails, release everything already held for this attempt and fail the whole request — a customer never ends up with 2 of 3 seats. One more detail worth deriving: process the seat IDs in a fixed sorted order before touching them. This matters most once you escalate seat-locking to blocking locks (the trade-off table below) — two overlapping group bookings acquiring seats in different orders is the textbook recipe for a lock-ordering deadlock. CAS doesn't block, so it can't deadlock either way, but sorting the IDs is free, keeps the two implementations symmetric, and is the right habit to have already formed before you need it.
Build it — milestones
Attempt-first: the contract is Seat.tryHold(holderId, now, ttl) → bool, Seat.confirm(holderId, now) → bool, Seat.release(holderId) → bool, and BookingService.startBooking(userId, seatIds, now, ttl) → Booking | null. Try each milestone before reading the reference implementation below.
- M1 — One seat, single-threaded lifecycle. A
Seatwith a status field (AVAILABLE/HELD/BOOKED).tryHold/confirm/releasewith no concurrency guard yet — get the state machine's happy path and rejection cases right first (can't confirm an unheld seat, can't re-hold a booked seat). - M2 — Make it thread-safe: compare-and-set. Replace the plain fields with an immutable snapshot behind an atomic reference; every transition becomes a CAS loop. Prove it: one seat, N threads calling
tryHoldconcurrently, exactly one must succeed. - M3 — TTL hold + lazy expiry. Add
holdExpiresAtto the snapshot.tryHoldtreats an expired HELD seat as available again;confirmrejects a hold whose TTL has lapsed. Prove it: hold at t0 for 60s, a rival is rejected at t0+30s, the same rival succeeds at t0+61s. - M4 — Multi-seat, all-or-nothing. A
BookingServicethat holds a sorted list of seat IDs one at a time, rolling back everything already held the moment one seat fails.confirmacross the whole booking, rolling back if any seat's hold expired mid-payment.
Reference implementation — Java
The whole trick lives in Seat: state is an immutable SeatSnapshot behind an AtomicReference, and every transition is a compare-and-set loop. UnsafeSeat at the bottom is the naive twin from "The Trap" above, kept only so the break-it test can reproduce the race on this exact page's code — never shape real code that way. Save as two files, MovieBooking.java then MovieBookingTests.java, compiled together.
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
/** A film. Immutable metadata only -- the booking mechanics never touch this. */
final class Movie {
final String id, title;
Movie(String id, String title) { this.id = id; this.title = title; }
}
/** A physical auditorium. Seat layout is owned by the Show, not the Screen --
* the same physical seat is a DIFFERENT bookable Seat per showtime. */
final class Screen {
final String id;
Screen(String id) { this.id = id; }
}
/** One scheduled screening: a Movie in a Screen at a start time, with its own
* independent set of bookable Seats. Seat "A5" for the 7pm show and seat
* "A5" for the 9:30pm show are two different Seat objects with independent state. */
final class Show {
final String id;
final Movie movie;
final Screen screen;
final long startTimeMillis;
private final Map<String, Seat> seats = new HashMap<>();
Show(String id, Movie movie, Screen screen, long startTimeMillis, List<String> seatIds) {
this.id = id; this.movie = movie; this.screen = screen; this.startTimeMillis = startTimeMillis;
for (String seatId : seatIds) seats.put(seatId, new Seat(seatId));
}
Seat seat(String seatId) { return seats.get(seatId); }
}
enum SeatStatus { AVAILABLE, HELD, BOOKED }
/** Immutable snapshot of a seat's state -- swapped atomically as a whole unit
* via compare-and-set, so status/holder/expiry can never be observed half-updated. */
final class SeatSnapshot {
final SeatStatus status;
final String holderId; // booking id that holds/owns the seat; null when AVAILABLE
final long holdExpiresAt; // epoch millis; meaningful only when status == HELD
SeatSnapshot(SeatStatus status, String holderId, long holdExpiresAt) {
this.status = status; this.holderId = holderId; this.holdExpiresAt = holdExpiresAt;
}
}
/**
* One bookable seat for one show. State machine: AVAILABLE -> HELD -> BOOKED,
* and HELD -> AVAILABLE on cancel or lazy TTL expiry. The whole trick: every
* transition is a single compareAndSet on an immutable SeatSnapshot, so two
* concurrent callers racing the SAME transition can never both win.
*/
final class Seat {
final String seatId;
private final AtomicReference<SeatSnapshot> state =
new AtomicReference<>(new SeatSnapshot(SeatStatus.AVAILABLE, null, 0));
Seat(String seatId) { this.seatId = seatId; }
/** AVAILABLE (or an expired HELD) -> HELD. Exactly one caller wins under contention. */
boolean tryHold(String holderId, long now, long ttlMillis) {
while (true) {
SeatSnapshot cur = state.get();
boolean free = cur.status == SeatStatus.AVAILABLE
|| (cur.status == SeatStatus.HELD && cur.holdExpiresAt <= now);
if (!free) return false;
SeatSnapshot next = new SeatSnapshot(SeatStatus.HELD, holderId, now + ttlMillis);
if (state.compareAndSet(cur, next)) return true; // atomic: wins iff nobody changed `cur` first
// else: lost the race to a concurrent writer between get() and compareAndSet() -- retry with the fresh value
}
}
/** HELD (same holder, not expired) -> BOOKED. Call once payment has cleared. */
boolean confirm(String holderId, long now) {
while (true) {
SeatSnapshot cur = state.get();
boolean validHold = cur.status == SeatStatus.HELD
&& holderId.equals(cur.holderId) && cur.holdExpiresAt > now;
if (!validHold) return false;
SeatSnapshot next = new SeatSnapshot(SeatStatus.BOOKED, holderId, 0);
if (state.compareAndSet(cur, next)) return true;
}
}
/** HELD (same holder) -> AVAILABLE. Explicit cancel; timeout is handled lazily inside tryHold. */
boolean release(String holderId) {
while (true) {
SeatSnapshot cur = state.get();
if (cur.status != SeatStatus.HELD || !holderId.equals(cur.holderId)) return false;
SeatSnapshot next = new SeatSnapshot(SeatStatus.AVAILABLE, null, 0);
if (state.compareAndSet(cur, next)) return true;
}
}
SeatStatus status() { return state.get().status; }
}
/**
* UNSAFE twin of Seat, kept ONLY to demonstrate the double-booking race in
* the break-it test below. Same eligibility check as Seat.tryHold, but the
* check and the write are two separate, unsynchronized steps with a
* deliberate gap standing in for real work between "seat looks free" and
* "commit the hold" (serialize the request, write an audit log, etc).
* This IS the bug the rest of the page fixes -- never shape real code this way.
*/
final class UnsafeSeat {
SeatStatus status = SeatStatus.AVAILABLE; // plain field: no lock, no atomic, no volatile
String holderId;
boolean tryHoldUnsafe(String holderId) {
if (status != SeatStatus.AVAILABLE) return false; // CHECK
for (int i = 0; i < 2000; i++) { /* busy-wait: widen the race window */ }
status = SeatStatus.HELD; // ACT -- not atomic with the CHECK above
this.holderId = holderId;
return true;
}
}
enum BookingStatus { PENDING_PAYMENT, CONFIRMED, FAILED, CANCELED }
/** A user's attempt to book one or more seats on one Show. */
final class Booking {
final String bookingId, userId;
final List<String> seatIds;
BookingStatus status;
Booking(String bookingId, String userId, List<String> seatIds, BookingStatus status) {
this.bookingId = bookingId; this.userId = userId; this.seatIds = seatIds; this.status = status;
}
}
/**
* Orchestrates a booking across possibly-many seats on one Show. Multi-seat
* holds are all-or-nothing: if any requested seat can't be held, every seat
* already held for this attempt is released before returning null.
*/
final class BookingService {
private final Show show;
BookingService(Show show) { this.show = show; }
Booking startBooking(String userId, List<String> seatIds, long now, long ttlMillis) {
List<String> ordered = new ArrayList<>(seatIds);
Collections.sort(ordered); // fixed global order: avoids a lock-order deadlock across seats
String bookingId = userId + ":" + now;
List<Seat> held = new ArrayList<>();
for (String seatId : ordered) {
Seat seat = show.seat(seatId);
if (seat.tryHold(bookingId, now, ttlMillis)) {
held.add(seat);
} else {
for (Seat s : held) s.release(bookingId); // roll back the partial hold
return null;
}
}
return new Booking(bookingId, userId, ordered, BookingStatus.PENDING_PAYMENT);
}
boolean confirm(Booking booking, long now) {
for (String seatId : booking.seatIds) {
if (!show.seat(seatId).confirm(booking.bookingId, now)) {
for (String s : booking.seatIds) show.seat(s).release(booking.bookingId);
booking.status = BookingStatus.FAILED;
return false;
}
}
booking.status = BookingStatus.CONFIRMED;
return true;
}
}
Reference implementation — Go
Same shape: a Seat whose state is an immutable seatSnapshot behind atomic.Value, every transition a CompareAndSwap loop, plus the UnsafeSeat twin for the break-it demo. Save as moviebooking.go, package moviebooking, in a module named moviebooking.
// Package moviebooking is the reference implementation for the "Build a
// Movie Ticket Booking System" LLD kata: a per-seat state machine
// (Available -> Held -> Booked) guarded by lock-free compare-and-swap, with
// a TTL hold for the payment window.
package moviebooking
import (
"sort"
"sync/atomic"
"time"
)
// Movie is immutable metadata only -- the booking mechanics never touch it.
type Movie struct {
ID, Title string
}
// Screen is a physical auditorium. Seat layout is owned by the Show, not the
// Screen -- the same physical seat is a DIFFERENT bookable Seat per showtime.
type Screen struct {
ID string
}
// Show is one scheduled screening: a Movie in a Screen at a start time, with
// its own independent set of bookable Seats. Seat "A5" for the 7pm show and
// seat "A5" for the 9:30pm show are two different Seat objects.
type Show struct {
ID string
Movie Movie
Screen Screen
StartTime time.Time
seats map[string]*Seat
}
// NewShow builds a Show with one fresh Seat per seatID.
func NewShow(id string, movie Movie, screen Screen, startTime time.Time, seatIDs []string) *Show {
s := &Show{ID: id, Movie: movie, Screen: screen, StartTime: startTime, seats: make(map[string]*Seat, len(seatIDs))}
for _, sid := range seatIDs {
s.seats[sid] = NewSeat(sid)
}
return s
}
// Seat looks up a bookable seat by ID (nil if it doesn't exist on this show).
func (s *Show) Seat(seatID string) *Seat { return s.seats[seatID] }
// SeatStatus is a seat's position in the state machine.
type SeatStatus int
const (
Available SeatStatus = iota
Held
Booked
)
// seatSnapshot is an immutable point-in-time view of a seat's state, swapped
// as a whole unit via CompareAndSwap so status/holder/expiry are never
// observed half-updated.
type seatSnapshot struct {
status SeatStatus
holderID string // booking id that holds/owns the seat; "" when Available
holdExpiresAt int64 // unix millis; meaningful only when status == Held
}
// Seat is one bookable seat for one show. State machine:
// Available -> Held -> Booked, and Held -> Available on cancel or lazy TTL
// expiry. Every transition is a single CompareAndSwap on an immutable
// snapshot, so two concurrent callers racing the SAME transition can never
// both win.
type Seat struct {
ID string
state atomic.Value // holds *seatSnapshot
}
// NewSeat creates a seat starting in the Available state.
func NewSeat(id string) *Seat {
s := &Seat{ID: id}
s.state.Store(&seatSnapshot{status: Available})
return s
}
// TryHold transitions Available (or an expired Held) -> Held. Exactly one
// caller wins under concurrent calls on the same seat.
func (s *Seat) TryHold(holderID string, now time.Time, ttl time.Duration) bool {
nowMs := now.UnixMilli()
for {
cur := s.state.Load().(*seatSnapshot)
free := cur.status == Available || (cur.status == Held && cur.holdExpiresAt <= nowMs)
if !free {
return false
}
next := &seatSnapshot{status: Held, holderID: holderID, holdExpiresAt: nowMs + ttl.Milliseconds()}
if s.state.CompareAndSwap(cur, next) {
return true
}
// lost the race to a concurrent writer between Load and CompareAndSwap -- retry with the fresh snapshot
}
}
// Confirm transitions Held (same holder, not expired) -> Booked. Call after
// payment clears.
func (s *Seat) Confirm(holderID string, now time.Time) bool {
nowMs := now.UnixMilli()
for {
cur := s.state.Load().(*seatSnapshot)
valid := cur.status == Held && cur.holderID == holderID && cur.holdExpiresAt > nowMs
if !valid {
return false
}
next := &seatSnapshot{status: Booked, holderID: holderID}
if s.state.CompareAndSwap(cur, next) {
return true
}
}
}
// Release transitions Held (same holder) -> Available. Explicit cancel;
// timeout expiry is handled lazily inside TryHold.
func (s *Seat) Release(holderID string) bool {
for {
cur := s.state.Load().(*seatSnapshot)
if cur.status != Held || cur.holderID != holderID {
return false
}
next := &seatSnapshot{status: Available}
if s.state.CompareAndSwap(cur, next) {
return true
}
}
}
// Status returns the seat's current status.
func (s *Seat) Status() SeatStatus { return s.state.Load().(*seatSnapshot).status }
// UnsafeSeat is the UNSAFE twin of Seat, kept ONLY to demonstrate the
// double-booking race in the break-it test. Same eligibility check as
// Seat.TryHold, but the check and the write are two separate, unsynchronized
// steps with a deliberate gap standing in for real work between "seat looks
// free" and "commit the hold" (serialize the request, write an audit log,
// etc). This IS the bug the rest of the page fixes -- never shape real code
// this way.
type UnsafeSeat struct {
Status SeatStatus // plain field: no mutex, no atomic -- a genuine data race by design
HolderID string
}
// NewUnsafeSeat creates an unsafe seat starting Available.
func NewUnsafeSeat() *UnsafeSeat { return &UnsafeSeat{Status: Available} }
// TryHoldUnsafe is the naive check-then-act that lets two callers both win.
func (s *UnsafeSeat) TryHoldUnsafe(holderID string) bool {
if s.Status != Available { // CHECK
return false
}
for i := 0; i < 2000; i++ { // busy-wait: widen the race window, stand-in for real work
}
s.Status = Held // ACT -- not atomic with the CHECK above
s.HolderID = holderID
return true
}
// BookingStatus is a Booking's lifecycle state.
type BookingStatus int
const (
PendingPayment BookingStatus = iota
Confirmed
Failed
Canceled
)
// Booking is a user's attempt to book one or more seats on one Show.
type Booking struct {
ID string
UserID string
SeatIDs []string
Status BookingStatus
}
// BookingService orchestrates a booking across possibly-many seats on one
// Show. Multi-seat holds are all-or-nothing: if any requested seat can't be
// held, every seat already held for this attempt is released before
// returning nil.
type BookingService struct {
show *Show
}
// NewBookingService builds a service scoped to one show.
func NewBookingService(show *Show) *BookingService { return &BookingService{show: show} }
// StartBooking tries to hold every seat in seatIDs, all-or-nothing.
func (b *BookingService) StartBooking(userID string, seatIDs []string, now time.Time, ttl time.Duration) *Booking {
ordered := append([]string(nil), seatIDs...)
sort.Strings(ordered) // fixed global order: avoids a lock-order deadlock across seats
bookingID := userID + ":" + now.Format(time.RFC3339Nano)
held := make([]*Seat, 0, len(ordered))
for _, seatID := range ordered {
seat := b.show.Seat(seatID)
if seat.TryHold(bookingID, now, ttl) {
held = append(held, seat)
} else {
for _, s := range held {
s.Release(bookingID) // roll back the partial hold
}
return nil
}
}
return &Booking{ID: bookingID, UserID: userID, SeatIDs: ordered, Status: PendingPayment}
}
// Confirm moves every seat in the booking from Held to Booked. If any seat
// fails (e.g. its hold expired mid-payment), the whole booking is rolled back.
func (b *BookingService) Confirm(booking *Booking, now time.Time) bool {
for _, seatID := range booking.SeatIDs {
if !b.show.Seat(seatID).Confirm(booking.ID, now) {
for _, sid := range booking.SeatIDs {
b.show.Seat(sid).Release(booking.ID)
}
booking.Status = Failed
return false
}
}
booking.Status = Confirmed
return true
}
Break it
Both reference implementations ship two seat types on the exact same page: Seat (the CAS-guarded fix) and UnsafeSeat (the naive check-then-act from "The Trap," kept only for this demo). Hammer one contended seat with 8 threads/goroutines all racing to hold it, aligned at a barrier so they collide on the same instant, repeated across 300 trials — and count how many trials produced more than one winner, i.e. an actual double-booking of the same seat.
Java — save as MovieBookingTests.java next to MovieBooking.java above, and compile both together:
import java.util.Arrays;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.atomic.AtomicInteger;
public final class MovieBookingTests {
static int pass = 0, fail = 0;
static void check(String name, boolean cond) {
if (cond) { pass++; System.out.println("PASS: " + name); }
else { fail++; System.out.println("FAIL: " + name); }
}
public static void main(String[] args) throws Exception {
long now = System.currentTimeMillis();
// --- M1/M2: basic hold -> confirm lifecycle, and the rejection cases ---
{
Seat seatA5 = new Seat("A5");
check("fresh seat starts AVAILABLE", seatA5.status() == SeatStatus.AVAILABLE);
check("first hold succeeds", seatA5.tryHold("u1", now, 300_000));
check("seat now HELD", seatA5.status() == SeatStatus.HELD);
check("second hold by a different user is rejected", !seatA5.tryHold("u2", now, 300_000));
check("confirm by the holder succeeds", seatA5.confirm("u1", now));
check("seat now BOOKED", seatA5.status() == SeatStatus.BOOKED);
check("cannot re-hold a BOOKED seat", !seatA5.tryHold("u3", now, 300_000));
}
// --- M3: TTL hold expiry auto-releases the seat (lazy expiry, checked on next tryHold) ---
{
Seat seatB1 = new Seat("B1");
long t0 = 1_000_000L;
check("u1 holds B1 for a 60s window", seatB1.tryHold("u1", t0, 60_000));
check("u2 is rejected while the hold is live (+30s)", !seatB1.tryHold("u2", t0 + 30_000, 60_000));
check("u2 CAN hold once the TTL has lapsed (+61s)", seatB1.tryHold("u2", t0 + 61_000, 60_000));
check("the ORIGINAL holder can no longer confirm after losing the seat", !seatB1.confirm("u1", t0 + 61_000));
check("the NEW holder can confirm", seatB1.confirm("u2", t0 + 61_000));
}
// --- M4: multi-seat booking is all-or-nothing ---
{
Show show = new Show("show-99", new Movie("m1", "Dune Part Three"), new Screen("scr-1"),
now, Arrays.asList("A5", "A6", "A7"));
BookingService svc = new BookingService(show);
show.seat("A7").tryHold("someone-else", now, 300_000); // pre-hold A7 so the group booking can't get all 3
Booking b = svc.startBooking("groupUser", Arrays.asList("A5", "A6", "A7"), now, 300_000);
check("group booking fails when ANY requested seat is unavailable", b == null);
check("A5 was rolled back to AVAILABLE (all-or-nothing)", show.seat("A5").status() == SeatStatus.AVAILABLE);
check("A6 was rolled back to AVAILABLE (all-or-nothing)", show.seat("A6").status() == SeatStatus.AVAILABLE);
// now the whole party succeeds once A7 is free
show.seat("A7").release("someone-else");
Booking ok = svc.startBooking("groupUser", Arrays.asList("A5", "A6", "A7"), now, 300_000);
check("group booking succeeds once all seats are free", ok != null);
check("confirm books all 3 seats", ok != null && svc.confirm(ok, now));
check("A5 ends BOOKED", show.seat("A5").status() == SeatStatus.BOOKED);
check("A7 ends BOOKED", show.seat("A7").status() == SeatStatus.BOOKED);
}
// --- Break-it: the double-booking race on a single contended seat ---
{
final int trials = 300, threads = 8;
int unsafeDoubleBooked = 0;
for (int t = 0; t < trials; t++) {
UnsafeSeat seat = new UnsafeSeat();
CyclicBarrier barrier = new CyclicBarrier(threads);
AtomicInteger winners = new AtomicInteger(0);
Thread[] pool = new Thread[threads];
for (int i = 0; i < threads; i++) {
final String uid = "user-" + i;
pool[i] = new Thread(() -> {
try { barrier.await(); } catch (Exception e) { throw new RuntimeException(e); }
if (seat.tryHoldUnsafe(uid)) winners.incrementAndGet();
});
}
for (Thread th : pool) th.start();
for (Thread th : pool) th.join();
if (winners.get() > 1) unsafeDoubleBooked++;
}
System.out.println("UNSAFE: " + unsafeDoubleBooked + "/" + trials + " trials double-booked the same seat");
check("break-it: UNSAFE tryHoldUnsafe DOES double-book seat A5 under contention", unsafeDoubleBooked > 0);
int safeAnomalies = 0;
for (int t = 0; t < trials; t++) {
Seat seat = new Seat("A5");
CyclicBarrier barrier = new CyclicBarrier(threads);
AtomicInteger winners = new AtomicInteger(0);
final long trialNow = now;
Thread[] pool = new Thread[threads];
for (int i = 0; i < threads; i++) {
final String uid = "user-" + i;
pool[i] = new Thread(() -> {
try { barrier.await(); } catch (Exception e) { throw new RuntimeException(e); }
if (seat.tryHold(uid, trialNow, 300_000)) winners.incrementAndGet();
});
}
for (Thread th : pool) th.start();
for (Thread th : pool) th.join();
if (winners.get() != 1) safeAnomalies++;
}
System.out.println("SAFE: " + (trials - safeAnomalies) + "/" + trials + " trials had EXACTLY one winner");
check("fix: SAFE tryHold (CAS) yields EXACTLY ONE winner in every one of " + trials + " trials",
safeAnomalies == 0);
}
System.out.println();
System.out.println(pass + " passed, " + fail + " failed");
if (fail > 0) System.exit(1);
}
}
Measured in this session (javac MovieBooking.java MovieBookingTests.java && java MovieBookingTests): all 21/21 assertions pass. Across repeated runs the UNSAFE path double-booked seat A5 in 13–15 of 300 trials every time it was executed — a real, reproducible failure, not a one-off flake — while the SAFE (CAS) path produced exactly one winner in all 300/300 trials, every run.
Go — the safe path is exercised by go test -race; the unsafe path is a separate program, because that's the honest way to demonstrate it (mixing an intentionally racy type into the same test binary as the audited path would taint -race's verdict on the code you're trying to prove clean). Save as moviebooking_test.go next to moviebooking.go:
package moviebooking
import (
"sync"
"testing"
"time"
)
func TestHoldConfirmLifecycle(t *testing.T) {
seat := NewSeat("A5")
now := time.Now()
if seat.Status() != Available {
t.Fatal("fresh seat must start Available")
}
if !seat.TryHold("u1", now, 5*time.Minute) {
t.Fatal("first hold should succeed")
}
if seat.Status() != Held {
t.Fatal("seat should now be Held")
}
if seat.TryHold("u2", now, 5*time.Minute) {
t.Fatal("second hold by a different user should be rejected")
}
if !seat.Confirm("u1", now) {
t.Fatal("confirm by the holder should succeed")
}
if seat.Status() != Booked {
t.Fatal("seat should now be Booked")
}
if seat.TryHold("u3", now, 5*time.Minute) {
t.Fatal("cannot re-hold a Booked seat")
}
}
func TestTTLExpiryAutoReleases(t *testing.T) {
seat := NewSeat("B1")
t0 := time.Unix(1000, 0)
if !seat.TryHold("u1", t0, 60*time.Second) {
t.Fatal("u1 should hold B1")
}
if seat.TryHold("u2", t0.Add(30*time.Second), 60*time.Second) {
t.Fatal("u2 should be rejected while the hold is still live")
}
if !seat.TryHold("u2", t0.Add(61*time.Second), 60*time.Second) {
t.Fatal("u2 should be able to hold once the TTL has lapsed")
}
if seat.Confirm("u1", t0.Add(61*time.Second)) {
t.Fatal("the original holder must not be able to confirm after losing the seat")
}
if !seat.Confirm("u2", t0.Add(61*time.Second)) {
t.Fatal("the new holder should be able to confirm")
}
}
func TestGroupBookingIsAllOrNothing(t *testing.T) {
now := time.Now()
show := NewShow("show-99", Movie{ID: "m1", Title: "Dune Part Three"}, Screen{ID: "scr-1"}, now,
[]string{"A5", "A6", "A7"})
svc := NewBookingService(show)
show.Seat("A7").TryHold("someone-else", now, 5*time.Minute) // pre-hold A7
if b := svc.StartBooking("groupUser", []string{"A5", "A6", "A7"}, now, 5*time.Minute); b != nil {
t.Fatal("group booking must fail when any requested seat is unavailable")
}
if show.Seat("A5").Status() != Available {
t.Error("A5 should have been rolled back to Available")
}
if show.Seat("A6").Status() != Available {
t.Error("A6 should have been rolled back to Available")
}
show.Seat("A7").Release("someone-else")
ok := svc.StartBooking("groupUser", []string{"A5", "A6", "A7"}, now, 5*time.Minute)
if ok == nil {
t.Fatal("group booking should succeed once all seats are free")
}
if !svc.Confirm(ok, now) {
t.Fatal("confirm should book all 3 seats")
}
if show.Seat("A5").Status() != Booked || show.Seat("A7").Status() != Booked {
t.Error("all seats in the booking should end Booked")
}
}
// TestConcurrentTryHoldIsSafe hammers TryHold on ONE contested seat from many
// goroutines and asserts EXACTLY one wins every time. Run with `go test -race`:
// it must report zero races (contrast with cmd/breakit, which uses
// UnsafeSeat and is a separate program).
func TestConcurrentTryHoldIsSafe(t *testing.T) {
const trials = 300
const goroutines = 8
now := time.Now()
for trial := 0; trial < trials; trial++ {
seat := NewSeat("A5")
var wg sync.WaitGroup
var barrier sync.WaitGroup
barrier.Add(1)
var mu sync.Mutex
winners := 0
for g := 0; g < goroutines; g++ {
uid := "user"
wg.Add(1)
go func(uid string) {
defer wg.Done()
barrier.Wait() // align all goroutines at the same starting line
if seat.TryHold(uid, now, 5*time.Minute) {
mu.Lock()
winners++
mu.Unlock()
}
}(uid)
}
barrier.Done()
wg.Wait()
if winners != 1 {
t.Fatalf("trial %d: expected exactly 1 winner, got %d", trial, winners)
}
}
}
And the actual break-it program, at cmd/breakit/main.go in a module that imports the moviebooking package above:
// Command breakit hammers UnsafeSeat.TryHoldUnsafe (no synchronization) from
// many goroutines racing for the SAME seat, across many trials, and counts
// how often more than one goroutine believes it won the hold -- i.e. how
// often the seat got double-booked. Run it with the race detector to see
// the underlying data race reported directly on the offending read/write:
//
// go run ./cmd/breakit # prints how many trials double-booked the seat
// go run -race ./cmd/breakit # additionally prints "WARNING: DATA RACE" pinpointing Status/HolderID
package main
import (
"fmt"
"sync"
"moviebooking"
)
func main() {
const trials = 300
const goroutines = 8
doubleBooked := 0
for trial := 0; trial < trials; trial++ {
seat := moviebooking.NewUnsafeSeat()
var wg sync.WaitGroup
var barrier sync.WaitGroup
barrier.Add(1)
var mu sync.Mutex
winners := 0
for g := 0; g < goroutines; g++ {
uid := fmt.Sprintf("user-%d", g)
wg.Add(1)
go func(uid string) {
defer wg.Done()
barrier.Wait() // align all goroutines at the same starting line
if seat.TryHoldUnsafe(uid) {
mu.Lock()
winners++
mu.Unlock()
}
}(uid)
}
barrier.Done()
wg.Wait()
if winners > 1 {
doubleBooked++
}
}
fmt.Printf("UNSAFE: %d/%d trials double-booked the same seat (no lock, no CAS)\n", doubleBooked, trials)
if doubleBooked == 0 {
fmt.Println("(got lucky this run -- still racy; re-run or raise `goroutines` to force it)")
}
}
Measured in this session: go build ./... and go vet ./... are clean; go test -race ./... passes 4/4 tests with 0 races on the safe TryHold path. go run ./cmd/breakit reported 95–140 double-booked trials out of 300 across repeated runs — a much higher failure rate than Java's, because Go's scheduler interleaves goroutines more aggressively under the same busy-wait gap. go run -race ./cmd/breakit additionally printed WARNING: DATA RACE, pinpointing the exact unsynchronized read of Status racing against a concurrent write inside TryHoldUnsafe — the race detector finds the bug directly at the line, independent of whether that particular run happened to produce a visible double-booking.
Optimise — trade-offs
| Decision | Option A | Option B | When A wins | When B wins |
|---|---|---|---|---|
| Seat-transition concurrency control | Pessimistic lock (in-process mutex per seat, or SELECT ... FOR UPDATE on the seat row) | Optimistic CAS (atomic compare-and-set in memory, or UPDATE seats SET status='HELD' WHERE id=? AND status='AVAILABLE' in SQL) | Contention is expected to be sustained and you want simple, blocking, easy-to-reason-about code — and you're comfortable with threads waiting | The overwhelmingly common case for one seat: contention is a short burst (a handful of buyers clash for one instant, then it's over). No thread ever blocks another; a slow or preempted caller can't stall the rest. This kata's reference impl is this column |
| Payment-window reservation | Hold the DB/lock across the whole payment call | Seat-hold with a TTL (reserve, release the lock immediately, re-validate on confirm) | Never, in practice — payment can take seconds and a slow gateway now blocks every other buyer for that seat for the full duration | Always for anything payment-shaped: the hold is a cheap, short-lived reservation; the seat is provably released if the buyer vanishes, without anyone holding a lock across an external network call |
| TTL enforcement | Background sweep (a timer thread scans HELD seats and expires stale ones) | Lazy expiry (expiry is checked inline the next time anyone calls tryHold on that seat) | You need "seats remaining" counts to be fresh even when nobody is actively contesting a seat (a dashboard, a live-availability feed) — the sweep actively frees inventory for display, lazy expiry doesn't until someone asks | Correctness is all you need (this kata): zero background coordination, zero sweep-interval tuning, and the very next real attempt on that seat always sees the truth regardless of how stale the display looks |
| Duplicate-sale prevention layer | Application-level lock/CAS only | DB unique constraint as a backstop (e.g. UNIQUE(show_id, seat_id) on the bookings table, catch the constraint violation) | A single trusted process is the only writer to seat state (this kata's world) | Multiple app instances or a retried/duplicated request could reach the database independently of your in-process guard — the DB constraint is the last line of defense that doesn't care how many processes raced to get there |
| Scale-out of the CAS itself | In-process atomic reference (this kata) | Distributed CAS (a DB row with an optimistic version/WHERE-clause guard, or a Redis SET NX / Lua script) behind a load balancer | One process owns all seat state in memory — fine for a kata, wrong for a real multi-instance service | The booking service runs on N horizontally-scaled instances: seat state must live in one shared, atomically-updatable store so a CAS on instance #3 is visible to instance #7 instantly. This is the seat-lock lesson, unchanged, moved from one process's memory to a shared datastore |
At real flash-sale scale (a Ticketmaster on-sale), the seat-CAS mechanism is necessary but not sufficient — see Designing Ticketmaster — Seat Reservation Without Double-Booking, Traced for the surrounding system: a virtual waiting room to admit buyers in controlled batches so the booking tier isn't stampeded, a fast-path inventory counter (e.g. Redis, atomically decremented) reconciled against the durable, authoritative seat store, and an idempotency token per booking attempt so a client retry (timeout, double-click, a flaky mobile network) can never create a second booking for a request that actually already succeeded.
Defend under drilling
- "How exactly do you prevent double-booking?" — Model each seat's state as an immutable snapshot (status, holder, hold-expiry) behind a single atomic reference. Every transition — hold, confirm, release — reads the current snapshot, checks it's eligible, builds the next snapshot, and does one hardware compare-and-swap against exactly the value it read. If two threads race the same transition, at most one CAS can succeed — the other's CAS fails because the value changed underneath it, so it loops, re-reads, and discovers the seat is gone (returns false immediately; it never blocks). Measured in this session: 8 threads/goroutines racing one seat across 300 trials produced exactly one winner in 100% of the safe-path trials, versus double-booking in 13–15/300 (Java) and 95–140/300 (Go) trials on the unguarded check-then-act twin.
- "Walk me through the payment-window hold and its timeout." —
tryHoldmoves AVAILABLE → HELD and stamps aholdExpiresAtinto the very same atomic snapshot as the status — they can never be observed out of sync. Payment proceeds against a HELD seat;confirmonly succeeds if the same holder still owns the seat and the TTL hasn't lapsed, moving HELD → BOOKED. If the buyer abandons checkout, nothing has to actively "clean up" the seat: the next caller'stryHoldsees a HELD snapshot whose expiry is in the past, treats it as available, and claims it — that's lazy expiry, and it needs zero background threads to be correct. - "Pessimistic lock vs. optimistic CAS — when would you actually pick each?" — For a single hot seat contested for a brief instant (the realistic shape here), CAS wins: nobody blocks, a slow or preempted thread can't stall the others, and losers fail fast. A blocking per-seat lock is simpler to reason about line-by-line but pays for it the moment the critical section grows or a thread holding the lock gets descheduled — everyone else queues behind it. Pessimistic locking earns its keep when contention is long-lived and you specifically want callers to wait rather than retry-or-fail (e.g. a workflow where "wait your turn" is the desired UX, not "try again").
- "What breaks at 100× — a real Ticketmaster-scale flash sale?" — Two things stop being true. First, a single process's in-memory
AtomicReferenceonly serializes writers inside that process — once you horizontally scale the booking service across N instances, seat state has to live in one shared store, and the CAS has to happen there (a SQLUPDATE ... WHERE status='AVAILABLE', or a Redis Lua script) so instance #3's write is instantly visible to instance #7's read. Second, raw request volume at the instant of an on-sale can be orders of magnitude above the booking tier's real capacity even though only a few hundred seats are actually being contested — the fix isn't a bigger CAS, it's a virtual waiting room that meters entry into the booking tier itself, paired with an idempotency token so retried requests during that stampede can't manufacture a second booking. The seat-level guarantee (exactly one winner per seat) doesn't change at all; only where the CAS physically lives does.
You can now defend
- You can model a seat as a state machine (AVAILABLE → HELD → BOOKED, HELD → AVAILABLE) and explain exactly why a plain boolean flag checked-then-flipped is the double-booking bug, not an implementation detail of it.
- You've implemented and reasoned about lock-free compare-and-set on an immutable snapshot, and can defend it against a blocking per-seat lock: same correctness guarantee, but no thread ever blocks another.
- You've reproduced a real double-booking race in both Java (13–15/300 trials) and Go (95–140/300 trials, plus a race-detector-pinpointed data race), fixed it, and proved the fix holds in 300/300 trials — not by assertion, but by measurement.
- You can design the TTL-bound payment-window hold with lazy expiry, extend a single-seat guarantee to an all-or-nothing multi-seat booking, and name the concrete next escalations (DB unique constraint, distributed CAS, virtual waiting room, idempotency) a real flash-sale system needs on top of the seat-level mechanism.
Re-authored/Deepened for this guide. Related theory: Design a Movie Ticket Booking System — OO & Low-Level Design. Scaling escalation: Designing Ticketmaster — Seat Reservation Without Double-Booking, Traced. Reference implementations (Java + Go) compiled and tested in-session: javac clean, 21/21 assertions pass, including the deliberately-adversarial break-it assertion (measured double-booking in 13-15/300 trials on the unsafe path across repeated runs, 0/300 on the safe CAS path every time); go build + go vet + go test -race clean, 4/4 tests pass with 0 races on the safe path; the separate unsafe break-it program reproduced double-booking in 95-140/300 trials across repeated runs, with go run -race additionally reporting the exact data race on the unguarded status field.
🤖 Don't fully get this? Learn it with Claude
Stuck on Design a Movie Ticket Booking System? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design a Movie Ticket Booking System** (Hands-On Builds) and want to truly understand it. Explain Design a Movie Ticket Booking System from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design a Movie Ticket Booking System** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design a Movie Ticket Booking System** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design a Movie Ticket Booking System** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.