Knowledge Guide
HomeHands-On BuildsLLD Katas

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):

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:

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.

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

DecisionOption AOption BWhen A winsWhen B wins
Seat-transition concurrency controlPessimistic 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 waitingThe 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 reservationHold the DB/lock across the whole payment callSeat-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 durationAlways 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 enforcementBackground 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 asksCorrectness 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 layerApplication-level lock/CAS onlyDB 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 itselfIn-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 balancerOne process owns all seat state in memory — fine for a kata, wrong for a real multi-instance serviceThe 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

You can now defend


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.

🎨 Explain it visually

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.
🤔 Walk me through it (interactive)

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.
🧪 Quiz me & fix my gaps

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.
🧠 Make it stick

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.

📝 My notes