InterviewKata
Low-level design · Problem P12 of 15 · Bigger designs

Movie ticket booking

Booking questions are really concurrency questions. The classes are simple; the interviewer wants to hear how two people clicking the same seat at the same moment can never both get it, and what happens to seats when someone abandons payment.

1. The prompt

The prompt"Design a system like BookMyShow or Fandango. Users choose seats for a show, pay, and get tickets. Two users must never book the same seat."

2. Clarify the requirements

Spend the first five minutes here. Ask, then write the answers down where the interviewer can see them.

AskAssume (unless told otherwise)
Partial holds?No: all requested seats or none.
How long is a hold?300 seconds.
What if payment succeeds after the hold expired?Confirm fails; refund the payment (or retry the hold if the seats are still free).
Scale?Many shows at once; contention is per show.

Must do

  • Hold several seats for a user while they pay
  • Confirm after payment; expire holds after a few minutes
  • Show a seat map

Must be

  • No seat sold twice, ever
  • Abandoned holds free their seats automatically
  • Different shows don't block each other

Out of scope

  • Payment itself (an external service)
  • Pricing tiers
  • Search for movies and cinemas

3. The classes

The class diagram: arrows show who uses or holds whom.
ClassIts one job
BookingServicehold() and confirm(); the rules
ShowIts seats, which hold owns each seat, and a lock
HoldWho, which seats, until when, and whether it's confirmed

4. How it works

5. Patterns and why

PatternWhereWhy it's worth it here
Lock per showShow.lock / synchronized (show)Two shows never wait for each other; one show's seats are always consistent
Lazy expiryAn expired hold counts as free when checkedNo background job is needed for correctness
All-or-nothingCheck every seat, then mark every seat, inside one lockNo half-held seat groups
Memory hook: "Hold under the lock, expire by the clock"Check and mark all seats inside the show's lock; a hold that's past its time simply stops counting.

6. The code

The last line is the race: 8 threads (or 8 async calls in TypeScript) try to hold seat A4 at the same moment, and exactly one succeeds.

from __future__ import annotations
import itertools
import threading
from dataclasses import dataclass
from typing import Optional

HOLD_SECONDS = 300


@dataclass
class Hold:
    id: str
    user: str
    seats: tuple[str, ...]
    expires_at: int
    confirmed: bool = False


class Show:
    def __init__(self, show_id: str, seats: list[str]) -> None:
        self.id = show_id
        self.seats = seats
        self.owner: dict[str, Hold] = {}   # seat -> the hold or booking that has it
        self.lock = threading.Lock()       # one lock per show: different shows never block each other

    def taken(self, seat: str, now: int) -> bool:
        h = self.owner.get(seat)
        return h is not None and (h.confirmed or h.expires_at > now)   # an expired hold counts as free


class BookingService:
    def __init__(self) -> None:
        self.shows: dict[str, Show] = {}
        self.holds: dict[str, tuple[Show, Hold]] = {}
        self.ids = itertools.count(1)

    def add_show(self, show: Show) -> None:
        self.shows[show.id] = show

    def hold(self, show_id: str, seats: list[str], user: str, now: int) -> Optional[str]:
        """All or nothing: either every seat is held for this user, or none is."""
        show = self.shows[show_id]
        with show.lock:
            if any(s not in show.seats or show.taken(s, now) for s in seats):
                return None
            h = Hold(f"H{next(self.ids)}", user, tuple(seats), now + HOLD_SECONDS)
            for s in seats:
                show.owner[s] = h
            self.holds[h.id] = (show, h)
            return h.id

    def confirm(self, hold_id: str, now: int) -> bool:
        """Called after payment succeeds. Fails if the hold expired first."""
        show, h = self.holds[hold_id]
        with show.lock:
            if h.confirmed:
                return True
            if h.expires_at <= now or any(show.owner.get(s) is not h for s in h.seats):
                return False
            h.confirmed = True
            return True

    def seat_map(self, show_id: str, now: int) -> str:
        show = self.shows[show_id]
        def mark(s: str) -> str:
            h = show.owner.get(s)
            if h is None or not show.taken(s, now):
                return f"{s}:free"
            return f"{s}:{'booked' if h.confirmed else 'held'}({h.user})"
        return " ".join(mark(s) for s in show.seats)


if __name__ == "__main__":
    svc = BookingService()
    svc.add_show(Show("S1", ["A1", "A2", "A3", "A4"]))
    h1 = svc.hold("S1", ["A1", "A2"], "ana", 0)
    print("ana holds A1 A2:", h1)
    print("ben holds A2 A3:", svc.hold("S1", ["A2", "A3"], "ben", 10))
    h2 = svc.hold("S1", ["A3", "A4"], "ben", 10)
    print("ben holds A3 A4:", h2)
    print("ana confirms at 100 s:", svc.confirm(h1, 100))
    print("ben confirms at 400 s:", svc.confirm(h2, 400))
    print("cara holds A3 at 401 s:", svc.hold("S1", ["A3"], "cara", 401))
    print(svc.seat_map("S1", 401))
    wins: list[str] = []
    def grab(user: str) -> None:
        if svc.hold("S1", ["A4"], user, 500):
            wins.append(user)
    threads = [threading.Thread(target=grab, args=(f"u{i}",)) for i in range(8)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    print("8 buyers race for A4 at 500 s, holds granted:", len(wins))
const HOLD_SECONDS = 300;

interface Hold { id: string; user: string; seats: string[]; expiresAt: number; confirmed: boolean; }

class Show {
  readonly owner = new Map<string, Hold>(); // seat -> the hold or booking that has it
  constructor(readonly id: string, readonly seats: string[]) {}
  taken(seat: string, now: number): boolean {
    const h = this.owner.get(seat);
    return h !== undefined && (h.confirmed || h.expiresAt > now); // an expired hold counts as free
  }
}

// JavaScript runs one callback at a time, so the check-then-set in hold() can't be interleaved within one
// process. With several servers, the same all-or-nothing rule must move into the database (a transaction).
class BookingService {
  private shows = new Map<string, Show>();
  private holds = new Map<string, { show: Show; hold: Hold }>();
  private nextId = 1;

  addShow(show: Show): void { this.shows.set(show.id, show); }

  /** All or nothing: either every seat is held for this user, or none is. */
  hold(showId: string, seats: string[], user: string, now: number): string | null {
    const show = this.shows.get(showId)!;
    if (seats.some((s) => !show.seats.includes(s) || show.taken(s, now))) return null;
    const h: Hold = { id: `H${this.nextId++}`, user, seats: [...seats], expiresAt: now + HOLD_SECONDS, confirmed: false };
    for (const s of seats) show.owner.set(s, h);
    this.holds.set(h.id, { show, hold: h });
    return h.id;
  }

  /** Called after payment succeeds. Fails if the hold expired first. */
  confirm(holdId: string, now: number): boolean {
    const { show, hold: h } = this.holds.get(holdId)!;
    if (h.confirmed) return true;
    if (h.expiresAt <= now || h.seats.some((s) => show.owner.get(s) !== h)) return false;
    h.confirmed = true;
    return true;
  }

  seatMap(showId: string, now: number): string {
    const show = this.shows.get(showId)!;
    return show.seats.map((s) => {
      const h = show.owner.get(s);
      if (!h || !show.taken(s, now)) return `${s}:free`;
      return `${s}:${h.confirmed ? "booked" : "held"}(${h.user})`;
    }).join(" ");
  }
}

async function main(): Promise<void> {
  const svc = new BookingService();
  svc.addShow(new Show("S1", ["A1", "A2", "A3", "A4"]));
  const h1 = svc.hold("S1", ["A1", "A2"], "ana", 0)!;
  console.log("ana holds A1 A2:", h1);
  console.log("ben holds A2 A3:", svc.hold("S1", ["A2", "A3"], "ben", 10) ?? "None");
  const h2 = svc.hold("S1", ["A3", "A4"], "ben", 10)!;
  console.log("ben holds A3 A4:", h2);
  console.log("ana confirms at 100 s:", svc.confirm(h1, 100) ? "True" : "False");
  console.log("ben confirms at 400 s:", svc.confirm(h2, 400) ? "True" : "False");
  console.log("cara holds A3 at 401 s:", svc.hold("S1", ["A3"], "cara", 401) ?? "None");
  console.log(svc.seatMap("S1", 401));
  const results = await Promise.all(Array.from({ length: 8 }, async (_, i) => svc.hold("S1", ["A4"], `u${i}`, 500)));
  console.log("8 buyers race for A4 at 500 s, holds granted:", results.filter((r) => r !== null).length);
}
void main();
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

class Hold {
    final String id, user;
    final List<String> seats;
    final long expiresAt;
    boolean confirmed;

    Hold(String id, String user, List<String> seats, long expiresAt) {
        this.id = id;
        this.user = user;
        this.seats = List.copyOf(seats);
        this.expiresAt = expiresAt;
    }
}

class Show {
    final String id;
    final List<String> seats;
    final Map<String, Hold> owner = new ConcurrentHashMap<>(); // seat -> the hold or booking that has it

    Show(String id, List<String> seats) {
        this.id = id;
        this.seats = seats;
    }

    boolean taken(String seat, long now) {
        Hold h = owner.get(seat);
        return h != null && (h.confirmed || h.expiresAt > now); // an expired hold counts as free
    }
}

class BookingService {
    static final long HOLD_SECONDS = 300;
    private record Entry(Show show, Hold hold) {}

    private final Map<String, Show> shows = new ConcurrentHashMap<>();
    private final Map<String, Entry> holds = new ConcurrentHashMap<>();
    private final AtomicInteger ids = new AtomicInteger();

    void addShow(Show s) {
        shows.put(s.id, s);
    }

    /** All or nothing: either every seat is held for this user, or none is. Locks only this show. */
    String hold(String showId, List<String> seats, String user, long now) {
        Show show = shows.get(showId);
        synchronized (show) {
            for (String s : seats) if (!show.seats.contains(s) || show.taken(s, now)) return null;
            Hold h = new Hold("H" + ids.incrementAndGet(), user, seats, now + HOLD_SECONDS);
            for (String s : seats) show.owner.put(s, h);
            holds.put(h.id, new Entry(show, h));
            return h.id;
        }
    }

    /** Called after payment succeeds. Fails if the hold expired first. */
    boolean confirm(String holdId, long now) {
        Entry e = holds.get(holdId);
        synchronized (e.show()) {
            Hold h = e.hold();
            if (h.confirmed) return true;
            if (h.expiresAt <= now || h.seats.stream().anyMatch(s -> e.show().owner.get(s) != h)) return false;
            h.confirmed = true;
            return true;
        }
    }

    String seatMap(String showId, long now) {
        Show show = shows.get(showId);
        return show.seats.stream().map(s -> {
            Hold h = show.owner.get(s);
            if (h == null || !show.taken(s, now)) return s + ":free";
            return s + ":" + (h.confirmed ? "booked" : "held") + "(" + h.user + ")";
        }).collect(Collectors.joining(" "));
    }
}

public class Main {
    static String py(Object o) {
        return o == null ? "None" : o instanceof Boolean b ? (b ? "True" : "False") : o.toString();
    }

    public static void main(String[] args) throws InterruptedException {
        BookingService svc = new BookingService();
        svc.addShow(new Show("S1", List.of("A1", "A2", "A3", "A4")));
        String h1 = svc.hold("S1", List.of("A1", "A2"), "ana", 0);
        System.out.println("ana holds A1 A2: " + py(h1));
        System.out.println("ben holds A2 A3: " + py(svc.hold("S1", List.of("A2", "A3"), "ben", 10)));
        String h2 = svc.hold("S1", List.of("A3", "A4"), "ben", 10);
        System.out.println("ben holds A3 A4: " + py(h2));
        System.out.println("ana confirms at 100 s: " + py(svc.confirm(h1, 100)));
        System.out.println("ben confirms at 400 s: " + py(svc.confirm(h2, 400)));
        System.out.println("cara holds A3 at 401 s: " + py(svc.hold("S1", List.of("A3"), "cara", 401)));
        System.out.println(svc.seatMap("S1", 401));
        AtomicInteger wins = new AtomicInteger();
        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 8; i++) {
            String user = "u" + i;
            threads.add(Thread.ofPlatform().start(() -> { if (svc.hold("S1", List.of("A4"), user, 500) != null) wins.incrementAndGet(); }));
        }
        for (Thread t : threads) t.join();
        System.out.println("8 buyers race for A4 at 500 s, holds granted: " + wins.get());
    }
}

Output (the same in all three languages)

ana holds A1 A2: H1
ben holds A2 A3: None
ben holds A3 A4: H2
ana confirms at 100 s: True
ben confirms at 400 s: False
cara holds A3 at 401 s: H3
A1:booked(ana) A2:booked(ana) A3:held(cara) A4:free
8 buyers race for A4 at 500 s, holds granted: 1

7. Concurrency and edge cases

8. Follow-up questions

What interviewers ask once the core works, and a short answer for each.

How would you handle a very popular show?
A virtual waiting room in front of seat selection, so only a few hundred users choose seats at a time; see the flash-sale design (D24).
Why not lock individual seats?
Multi-seat holds would need several locks, in a fixed order to avoid deadlock. One lock per show is simpler and contention is short.
Optimistic instead of pessimistic?
Version each seat; update only if the version hasn't changed, and retry or fail on conflict. Good when conflicts are rare.
Release seats when the user closes the tab?
Best effort only (an unload call can be lost); expiry is the guarantee.

9. Common mistakes

Checking availability, then booking in a separate stepThe classic double-booking race.
A cleanup job as the only way holds expireIf the job is late, seats stay blocked. Treat expired holds as free when reading.
Holding the lock while calling paymentEvery other buyer for that show waits seconds.

10. Check yourself

What actually prevents double booking?

Two checks can both pass; only atomicity stops the second mark.

A hold expired at 310 s and confirm is called at 400 s. Result?

Confirm re-checks the expiry.

Why one lock per show rather than one global lock?

Contention is per show.

Your turn

  1. Database version (15 min). Write the SQL update that holds seats atomically and detects partial success.
  2. Add a waiting room (10 min). Admit a fixed number of users per minute to seat selection.