Low Level DesignWorked problems
Design a movie ticket booking system
Design a movie ticket booking system step by step for a low level design interview. Grow from shows and seats to holds that expire without a timer, a pay step that can't lose the seats or charge twice, cancellations with refunds, and the race when two people click the same seat, with code in Java, Python, C#, Go and TypeScript.
- Related
- Hotel bookingParking lot
“Design a movie ticket booking system, like BookMyShow or Fandango.” Usually followed within ten minutes by “what if two people pick the same seat?”
The catalog side of a cinema (movies, cinemas, screens, showtimes) is a page of simple data classes, and interviewers move through it quickly. The real question is about the minute between picking seats and paying for them.
Say you sell the seats the moment they’re picked. Then a customer who closes the tab keeps them forever. Say you sell them only after payment. Then two people can pay for the same seat. Everything interesting in this design happens in that minute. Holding seats. Letting holds run out. Paying without losing the seats. And two people clicking the same seat at once.
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains how we get here.
- A seat’s state belongs to a show, not to the seat. Seat C7 is free at 2pm and sold at 7pm. So each show has its own inventory of seats.
- Picking seats creates a hold with a deadline. A seat is free if nobody holds it, or if the hold’s deadline has passed. No timer has to fire for seats to come back.
- Paying pins the hold. Before charging the card, the hold is marked “being paid for”, so it can’t run out while the bank is slow. The charge carries the hold’s ID, so a second click on Pay can’t charge twice.
- Rules are policies. Prices (seat type, matinee discount) and refunds (how much notice was given) sit behind interfaces. The price is fixed when the seats are held.
- One lock per show. “Check every seat is free, then take them all” is one step per show. The slow card payment happens outside the lock, protected by the pin.
We’ll build this in four stages, then deal with two people clicking the same seat.
Scope it
Section titled “Scope it”Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.
Design a movie ticket booking system.
Whether you get to seat holds and payment within three minutes. Spending ten minutes on cities, cinemas, search filters and reviews is the most common way to run out of time before the real question starts.
Here are the questions that matter:
- Assigned seats, or first come, first in? Assigned seats, chosen on a seat map. Without seat numbers, a show is just a counter, and most of this design goes away.
- How long can someone hold seats? Five minutes, then they go back on sale. Longer holds block real buyers. Shorter ones fail slow payers.
- How many seats per booking? Up to 10, all or nothing. Nobody wants a booking that splits up a family.
- Payment? By card, through a payment company (a gateway) that can be slow or time out.
- Cancellations? Allowed until the show starts, with a refund that depends on how much notice was given.
These answers give us one promise that matters more than the others: no seat is ever sold twice, even when two people click it at the same moment. Three smaller promises come with it. A customer who is paying never loses their seats because the bank was slow. A customer is charged at most once per hold, however many times they click Pay. And abandoned holds free their seats, without depending on a background job running on time.
We leave out search, reviews, food orders and loyalty points.
Go deeperThree more questions, and what each changes1 min
| Question | We assume | If the answer were different |
|---|---|---|
| Pricing? | By seat type, matinees cheaper | Prices that change with demand are a different Pricing; the flow doesn’t change |
| How many cinemas? | Many, but a booking is one show | A booking across shows would need a different locking plan |
| Traffic? | Spikes when a big film opens | It shapes the database answer later on |
Build it
Section titled “Build it”We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram).
Stage 1: Shows, seats, and an instant booking
Section titled “Stage 1: Shows, seats, and an instant booking”Start simple: a customer picks two seats for tonight’s show and books them. What are your classes?
Whether seats belong to a screen while their state belongs to a show, in about two minutes. They also want the booking to be all or nothing. A booking that takes the free seats and skips the taken one has split a family.
Start with the smallest thing that works. Movie, Seat and Screen are catalog data that rarely
change: a screen is a fixed seating plan. A Show puts a movie on a screen at a time.
Seat state is per show. The booking service keeps, for each show, which seat was sold to which booking. And booking is all or nothing: if any requested seat is taken, or doesn’t exist, nothing is sold.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// A film: what people search for, with its running time.record Movie(String id, String title, int minutes) {}
// Premium seats cost more. The type is part of the seat, so pricing can read it.enum SeatType { REGULAR, PREMIUM }
// A physical seat in an auditorium, like "C7". It exists whether or not anything is showing.record Seat(String id, SeatType type) {}
// An auditorium and its fixed seating plan.record Screen(String id, List<Seat> seats) { Optional<Seat> seat(String seatId) { return seats.stream().filter(s -> s.id().equals(seatId)).findFirst(); }}
// One screening: a movie in a screen at a time. Seats are sold per show, so "is C7 free?"// only has an answer for a show, never for the seat alone.record Show(String id, Movie movie, Screen screen, Instant startsAt) {}
record Booking(String id, Show show, String userId, List<String> seatIds) {}
class BookingService { private final Map<String, Show> shows = new HashMap<>(); private final Map<String, Map<String, String>> sold = new HashMap<>(); // show -> seat -> booking private final Supplier<String> ids;
BookingService(Supplier<String> ids) { this.ids = ids; }
void addShow(Show s) { shows.put(s.id(), s); sold.put(s.id(), new HashMap<>()); }
List<String> availableSeats(String showId) { Map<String, String> taken = sold.get(showId); return shows.get(showId).screen().seats().stream().map(Seat::id) .filter(id -> !taken.containsKey(id)).toList(); }
// All or nothing: a family of four wants four seats together, not three of them. // flaw 1: seats are sold the moment they're picked, before any payment, so a customer who // closes the tab keeps them forever. Selling after payment instead lets two people pay for // the same seat. Stage 2 adds holds that expire. // flaw 2: no price and no payment. Stage 3. // flaw 3: two requests at once can both see C7 free and both sell it. The concurrency // section fixes that. Booking book(String userId, String showId, List<String> seatIds) { Show show = shows.get(showId); Map<String, String> taken = sold.get(showId); for (String s : seatIds) { if (show.screen().seat(s).isEmpty() || taken.containsKey(s)) return null; } Booking b = new Booking(ids.get(), show, userId, List.copyOf(seatIds)); for (String s : seatIds) taken.put(s, b.id()); return b; }}from dataclasses import dataclassfrom datetime import datetimefrom enum import Enum, autofrom typing import Callable
# A film: what people search for, with its running time.@dataclass(frozen=True)class Movie: id: str title: str minutes: int
# Premium seats cost more. The type is part of the seat, so pricing can read it.class SeatType(Enum): REGULAR = auto() PREMIUM = auto()
# A physical seat in an auditorium, like "C7". It exists whether or not anything is showing.@dataclass(frozen=True)class Seat: id: str type: SeatType
# An auditorium and its fixed seating plan.@dataclass(frozen=True)class Screen: id: str seats: tuple[Seat, ...]
def seat(self, seat_id: str) -> Seat | None: return next((s for s in self.seats if s.id == seat_id), None)
# One screening: a movie in a screen at a time. Seats are sold per show, so "is C7 free?"# only has an answer for a show, never for the seat alone.@dataclass(frozen=True)class Show: id: str movie: Movie screen: Screen starts_at: datetime
@dataclass(frozen=True)class Booking: id: str show: Show user_id: str seat_ids: tuple[str, ...]
class BookingService: def __init__(self, ids: Callable[[], str]): self._shows: dict[str, Show] = {} self._sold: dict[str, dict[str, str]] = {} # show -> seat -> booking self._ids = ids
def add_show(self, s: Show) -> None: self._shows[s.id] = s self._sold[s.id] = {}
def available_seats(self, show_id: str) -> list[str]: taken = self._sold[show_id] return [s.id for s in self._shows[show_id].screen.seats if s.id not in taken]
# All or nothing: a family of four wants four seats together, not three of them. # flaw 1: seats are sold the moment they're picked, before any payment, so a customer who # closes the tab keeps them forever. Selling after payment instead lets two people pay for # the same seat. Stage 2 adds holds that expire. # flaw 2: no price and no payment. Stage 3. # flaw 3: two requests at once can both see C7 free and both sell it. The concurrency # section fixes that. def book(self, user_id: str, show_id: str, seat_ids: list[str]) -> Booking | None: show = self._shows[show_id] taken = self._sold[show_id] if any(show.screen.seat(s) is None or s in taken for s in seat_ids): return None b = Booking(self._ids(), show, user_id, tuple(seat_ids)) for s in seat_ids: taken[s] = b.id return b// A film: what people search for, with its running time.public sealed record Movie(string Id, string Title, int Minutes);
// Premium seats cost more. The type is part of the seat, so pricing can read it.public enum SeatType { Regular, Premium }
// A physical seat in an auditorium, like "C7". It exists whether or not anything is showing.public sealed record Seat(string Id, SeatType Type);
// An auditorium and its fixed seating plan.public sealed record Screen(string Id, IReadOnlyList<Seat> Seats){ public Seat? Seat(string seatId) => Seats.FirstOrDefault(s => s.Id == seatId);}
// One screening: a movie in a screen at a time. Seats are sold per show, so "is C7 free?"// only has an answer for a show, never for the seat alone.public sealed record Show(string Id, Movie Movie, Screen Screen, DateTimeOffset StartsAt);
public sealed record Booking(string Id, Show Show, string UserId, IReadOnlyList<string> SeatIds);
public sealed class BookingService(Func<string> ids){ private readonly Dictionary<string, Show> _shows = []; private readonly Dictionary<string, Dictionary<string, string>> _sold = []; // show -> seat -> booking
public void AddShow(Show s) { _shows[s.Id] = s; _sold[s.Id] = []; }
public List<string> AvailableSeats(string showId) => _shows[showId].Screen.Seats.Select(s => s.Id).Where(id => !_sold[showId].ContainsKey(id)).ToList();
// All or nothing: a family of four wants four seats together, not three of them. // flaw 1: seats are sold the moment they're picked, before any payment, so a customer who // closes the tab keeps them forever. Selling after payment instead lets two people pay for // the same seat. Stage 2 adds holds that expire. // flaw 2: no price and no payment. Stage 3. // flaw 3: two requests at once can both see C7 free and both sell it. The concurrency // section fixes that. public Booking? Book(string userId, string showId, IReadOnlyList<string> seatIds) { var show = _shows[showId]; var taken = _sold[showId]; if (seatIds.Any(s => show.Screen.Seat(s) is null || taken.ContainsKey(s))) return null; var b = new Booking(ids(), show, userId, seatIds.ToList()); foreach (var s in seatIds) taken[s] = b.Id; return b; }}// Movie is a film: what people search for, with its running time.type Movie struct { ID, Title string Minutes int}
// SeatType: premium seats cost more. The type is part of the seat, so pricing can read it.type SeatType int
const ( Regular SeatType = iota Premium)
// Seat is a physical seat in an auditorium, like "C7". It exists whether or not anything is// showing.type Seat struct { ID string Type SeatType}
// Screen is an auditorium and its fixed seating plan.type Screen struct { ID string Seats []Seat}
func (s Screen) Seat(id string) (Seat, bool) { for _, x := range s.Seats { if x.ID == id { return x, true } } return Seat{}, false}
// Show is one screening: a movie in a screen at a time. Seats are sold per show, so "is C7// free?" only has an answer for a show, never for the seat alone.type Show struct { ID string Movie Movie Screen Screen StartsAt time.Time}
type Booking struct { ID string Show Show UserID string SeatIDs []string}
type BookingService struct { shows map[string]Show sold map[string]map[string]string // show -> seat -> booking ids func() string}
func NewBookingService(ids func() string) *BookingService { return &BookingService{shows: map[string]Show{}, sold: map[string]map[string]string{}, ids: ids}}
func (b *BookingService) AddShow(s Show) { b.shows[s.ID] = s b.sold[s.ID] = map[string]string{}}
func (b *BookingService) AvailableSeats(showID string) []string { var out []string for _, s := range b.shows[showID].Screen.Seats { if _, taken := b.sold[showID][s.ID]; !taken { out = append(out, s.ID) } } return out}
// Book is all or nothing: a family of four wants four seats together, not three of them.// flaw 1: seats are sold the moment they're picked, before any payment, so a customer who// closes the tab keeps them forever. Selling after payment instead lets two people pay for// the same seat. Stage 2 adds holds that expire. flaw 2: no price and no payment; stage 3.// flaw 3: two requests at once can both see C7 free and both sell it; the concurrency// section fixes that.func (b *BookingService) Book(userID, showID string, seatIDs []string) *Booking { show := b.shows[showID] taken := b.sold[showID] for _, s := range seatIDs { if _, ok := show.Screen.Seat(s); !ok { return nil } if _, sold := taken[s]; sold { return nil } } bk := &Booking{ID: b.ids(), Show: show, UserID: userID, SeatIDs: slices.Clone(seatIDs)} for _, s := range seatIDs { taken[s] = bk.ID } return bk}// A point in time as epoch milliseconds, the same thing Date.now() returns.type Instant = number;
// A film: what people search for, with its running time.interface Movie { readonly id: string; readonly title: string; readonly minutes: number;}
// Premium seats cost more. The type is part of the seat, so pricing can read it.type SeatType = 'regular' | 'premium';
// A physical seat in an auditorium, like "C7". It exists whether or not anything is showing.interface Seat { readonly id: string; readonly type: SeatType;}
// An auditorium and its fixed seating plan.interface Screen { readonly id: string; readonly seats: readonly Seat[];}
// One screening: a movie in a screen at a time. Seats are sold per show, so "is C7 free?"// only has an answer for a show, never for the seat alone.interface Show { readonly id: string; readonly movie: Movie; readonly screen: Screen; readonly startsAt: Instant;}
interface Booking { readonly id: string; readonly show: Show; readonly userId: string; readonly seatIds: readonly string[];}
function seatOf(screen: Screen, seatId: string): Seat | undefined { return screen.seats.find((s) => s.id === seatId);}
class BookingService { private readonly shows = new Map<string, Show>(); private readonly sold = new Map<string, Map<string, string>>(); // show -> seat -> booking
constructor(private readonly ids: () => string) {}
addShow(s: Show): void { this.shows.set(s.id, s); this.sold.set(s.id, new Map()); }
availableSeats(showId: string): string[] { const taken = this.sold.get(showId)!; return this.shows.get(showId)!.screen.seats.map((s) => s.id).filter((id) => !taken.has(id)); }
// All or nothing: a family of four wants four seats together, not three of them. // flaw 1: seats are sold the moment they're picked, before any payment, so a customer who // closes the tab keeps them forever. Selling after payment instead lets two people pay for // the same seat. Stage 2 adds holds that expire. // flaw 2: no price and no payment. Stage 3. // flaw 3: once payment involves an await, two requests can both see C7 free and both sell // it. The concurrency section covers that. book(userId: string, showId: string, seatIds: string[]): Booking | null { const show = this.shows.get(showId)!; const taken = this.sold.get(showId)!; if (seatIds.some((s) => !seatOf(show.screen, s) || taken.has(s))) return null; const b: Booking = { id: this.ids(), show, userId, seatIds: [...seatIds] }; for (const s of seatIds) taken.set(s, b.id); return b; }}It works, but it has three problems, and we leave them in on purpose. Seats are sold the moment they’re picked, before any payment, so an abandoned checkout keeps them forever. There’s no price and no payment. And two requests at the same moment can both see a seat free, and both sell it.
Stage 2: Holds that expire on their own
Section titled “Stage 2: Holds that expire on their own”A customer picks seats and then closes the tab without paying. What happens to the seats?
Whether you add a hold with a deadline, and whether its expiry depends on a timer firing. They expect to probe a scheduled job per hold, or a background thread that frees seats every minute. What happens when the job runs late?
Picking seats now creates a SeatHold: the seats, the customer, and a deadline five minutes away.
The seats are claimed for the hold, and the customer must confirm before the deadline to buy them.
The key decision is how holds run out. The obvious way is a timer per hold, or a job that cleans up every minute. But then the seats only come back when that code runs. A slow or crashed job keeps seats off sale.
So instead, expiry is a comparison. A seat is free if it’s unsold, and either nobody holds it, or its hold’s deadline has passed. Nothing has to run at 19:05. Anyone who looks at 19:05 simply sees the seat as free. A cleanup job can still delete old holds to save memory, but correctness never depends on it.
Seat state moves into ShowSeats, one per show: which seats are held by which hold, and which are
sold to which booking. Every request touches exactly one show, which will matter when two requests
arrive at once.
- Color key
- State
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// A short claim on seats while the customer pays. It ends one of three ways: confirmed into a// booking, released, or simply running out of time.class SeatHold { final String id; final String showId; final String userId; final List<String> seatIds; final Instant expiresAt;
SeatHold(String id, String showId, String userId, List<String> seatIds, Instant expiresAt) { this.id = id; this.showId = showId; this.userId = userId; this.seatIds = seatIds; this.expiresAt = expiresAt; }
// Expiry is a comparison, not an event. Nothing has to run at 19:05 to free the seats; // anyone who looks after 19:05 sees them free. boolean expired(Instant now) { return !now.isBefore(expiresAt); }}
enum Result { OK, INVALID_SEATS, SEAT_TAKEN, NO_SUCH_HOLD, HOLD_EXPIRED }
// What hold() and confirm() return: a result, plus the new hold or booking when it is OK.record HoldResult(Result result, SeatHold hold) {}
record BookingResult(Result result, Booking booking) {}
// Who has which seat, for one show. Each show is its own small inventory: no request ever// needs seats from two shows at once.class ShowSeats { final Show show; private final Map<String, SeatHold> held = new HashMap<>(); // seat -> hold private final Map<String, String> sold = new HashMap<>(); // seat -> booking id
ShowSeats(Show show) { this.show = show; }
boolean isFree(String seatId, Instant now) { if (sold.containsKey(seatId)) return false; SeatHold h = held.get(seatId); return h == null || h.expired(now); }
// All or nothing: check every seat, then take every seat. Result hold(SeatHold h, Instant now) { for (String s : h.seatIds) if (!isFree(s, now)) return Result.SEAT_TAKEN; for (String s : h.seatIds) held.put(s, h); return Result.OK; }
// An expired hold may already have lost a seat to someone else's hold, so being unexpired // is not enough: every seat must still point at this hold. boolean isLive(SeatHold h, Instant now) { return !h.expired(now) && h.seatIds.stream().allMatch(s -> held.get(s) == h); }
void sell(SeatHold h, String bookingId) { for (String s : h.seatIds) { held.remove(s); sold.put(s, bookingId); } }
// Removes only seats this hold still has; a seat someone else held since is left alone. void release(SeatHold h) { for (String s : h.seatIds) held.remove(s, h); }
List<String> available(Instant now) { return show.screen().seats().stream().map(Seat::id).filter(s -> isFree(s, now)).toList(); }}
class BookingService { static final Duration HOLD_FOR = Duration.ofMinutes(5); static final int MAX_SEATS = 10;
// Concurrent maps because requests arrive in parallel. Seats get their own protection in // the concurrency section; these maps only need to be safe to read and write. private final Map<String, ShowSeats> shows = new ConcurrentHashMap<>(); private final Map<String, SeatHold> holds = new ConcurrentHashMap<>(); private final Supplier<String> ids; private final Supplier<Instant> clock; // injected, so tests can let a hold run out
BookingService(Supplier<String> ids, Supplier<Instant> clock) { this.ids = ids; this.clock = clock; }
void addShow(Show s) { shows.put(s.id(), new ShowSeats(s)); }
List<String> availableSeats(String showId) { return shows.get(showId).available(clock.get()); }
HoldResult hold(String userId, String showId, List<String> seatIds) { ShowSeats seats = shows.get(showId); if (seats == null || !valid(seats.show, seatIds)) return new HoldResult(Result.INVALID_SEATS, null); Instant now = clock.get(); SeatHold h = new SeatHold(ids.get(), showId, userId, List.copyOf(seatIds), now.plus(HOLD_FOR)); Result r = seats.hold(h, now); if (r != Result.OK) return new HoldResult(r, null); holds.put(h.id, h); return new HoldResult(Result.OK, h); }
// No payment yet: confirming a live hold sells its seats. BookingResult confirm(String holdId) { SeatHold h = holds.remove(holdId); if (h == null) return new BookingResult(Result.NO_SUCH_HOLD, null); ShowSeats seats = shows.get(h.showId); if (!seats.isLive(h, clock.get())) { seats.release(h); return new BookingResult(Result.HOLD_EXPIRED, null); } Booking b = new Booking(ids.get(), seats.show, h.userId, h.seatIds); seats.sell(h, b.id()); return new BookingResult(Result.OK, b); }
void release(String holdId) { SeatHold h = holds.remove(holdId); if (h != null) shows.get(h.showId).release(h); }
// Some seats, not too many, no seat twice, and every seat in this screen. private static boolean valid(Show show, List<String> seatIds) { return !seatIds.isEmpty() && seatIds.size() <= MAX_SEATS && new HashSet<>(seatIds).size() == seatIds.size() && seatIds.stream().allMatch(s -> show.screen().seat(s).isPresent()); }}from datetime import timedelta
# A short claim on seats while the customer pays. It ends one of three ways: confirmed into# a booking, released, or simply running out of time.@dataclass(eq=False) # compared by identity: two holds are never "the same hold"class SeatHold: id: str show_id: str user_id: str seat_ids: tuple[str, ...] expires_at: datetime
# Expiry is a comparison, not an event. Nothing has to run at 19:05 to free the seats; # anyone who looks after 19:05 sees them free. def expired(self, now: datetime) -> bool: return now >= self.expires_at
class Result(Enum): OK = auto() INVALID_SEATS = auto() SEAT_TAKEN = auto() NO_SUCH_HOLD = auto() HOLD_EXPIRED = auto()
# Who has which seat, for one show. Each show is its own small inventory: no request ever# needs seats from two shows at once.class ShowSeats: def __init__(self, show: Show): self.show = show self._held: dict[str, SeatHold] = {} # seat -> hold self._sold: dict[str, str] = {} # seat -> booking id
def is_free(self, seat_id: str, now: datetime) -> bool: if seat_id in self._sold: return False h = self._held.get(seat_id) return h is None or h.expired(now)
# All or nothing: check every seat, then take every seat. def hold(self, h: SeatHold, now: datetime) -> Result: if not all(self.is_free(s, now) for s in h.seat_ids): return Result.SEAT_TAKEN for s in h.seat_ids: self._held[s] = h return Result.OK
# An expired hold may already have lost a seat to someone else's hold, so being unexpired # is not enough: every seat must still point at this hold. def is_live(self, h: SeatHold, now: datetime) -> bool: return not h.expired(now) and all(self._held.get(s) is h for s in h.seat_ids)
def sell(self, h: SeatHold, booking_id: str) -> None: for s in h.seat_ids: self._held.pop(s, None) self._sold[s] = booking_id
# Removes only seats this hold still has; a seat someone else held since is left alone. def release(self, h: SeatHold) -> None: for s in h.seat_ids: if self._held.get(s) is h: del self._held[s]
def available(self, now: datetime) -> list[str]: return [s.id for s in self.show.screen.seats if self.is_free(s.id, now)]
HOLD_FOR = timedelta(minutes=5) # time to pay before seats go backMAX_SEATS = 10 # per booking, so one person can't hold a whole row
class BookingService: def __init__(self, ids: Callable[[], str], clock: Callable[[], datetime]): self._shows: dict[str, ShowSeats] = {} self._holds: dict[str, SeatHold] = {} self._ids = ids self._clock = clock # injected, so tests can let a hold run out
def add_show(self, s: Show) -> None: self._shows[s.id] = ShowSeats(s)
def available_seats(self, show_id: str) -> list[str]: return self._shows[show_id].available(self._clock())
def hold(self, user_id: str, show_id: str, seat_ids: list[str]) -> tuple[Result, SeatHold | None]: seats = self._shows.get(show_id) if seats is None or not _valid(seats.show, seat_ids): return Result.INVALID_SEATS, None now = self._clock() h = SeatHold(self._ids(), show_id, user_id, tuple(seat_ids), now + HOLD_FOR) r = seats.hold(h, now) if r is not Result.OK: return r, None self._holds[h.id] = h return Result.OK, h
# No payment yet: confirming a live hold sells its seats. def confirm(self, hold_id: str) -> tuple[Result, Booking | None]: h = self._holds.pop(hold_id, None) if h is None: return Result.NO_SUCH_HOLD, None seats = self._shows[h.show_id] if not seats.is_live(h, self._clock()): seats.release(h) return Result.HOLD_EXPIRED, None b = Booking(self._ids(), seats.show, h.user_id, h.seat_ids) seats.sell(h, b.id) return Result.OK, b
def release(self, hold_id: str) -> None: h = self._holds.pop(hold_id, None) if h is not None: self._shows[h.show_id].release(h)
# Some seats, not too many, no seat twice, and every seat in this screen.def _valid(show: Show, seat_ids: list[str]) -> bool: return (0 < len(seat_ids) <= MAX_SEATS and len(set(seat_ids)) == len(seat_ids) and all(show.screen.seat(s) is not None for s in seat_ids))// A short claim on seats while the customer pays. It ends one of three ways: confirmed into a// booking, released, or simply running out of time.public sealed class SeatHold(string id, string showId, string userId, IReadOnlyList<string> seatIds, DateTimeOffset expiresAt){ public string Id { get; } = id; public string ShowId { get; } = showId; public string UserId { get; } = userId; public IReadOnlyList<string> SeatIds { get; } = seatIds; public DateTimeOffset ExpiresAt { get; } = expiresAt;
// Expiry is a comparison, not an event. Nothing has to run at 19:05 to free the seats; // anyone who looks after 19:05 sees them free. public bool Expired(DateTimeOffset now) => now >= ExpiresAt;}
public enum Result { Ok, InvalidSeats, SeatTaken, NoSuchHold, HoldExpired }
// Who has which seat, for one show. Each show is its own small inventory: no request ever// needs seats from two shows at once.public sealed class ShowSeats(Show show){ private readonly Dictionary<string, SeatHold> _held = []; // seat -> hold private readonly Dictionary<string, string> _sold = []; // seat -> booking id
public Show Show { get; } = show;
public bool IsFree(string seatId, DateTimeOffset now) => !_sold.ContainsKey(seatId) && (!_held.TryGetValue(seatId, out var h) || h.Expired(now));
// All or nothing: check every seat, then take every seat. public Result Hold(SeatHold h, DateTimeOffset now) { if (!h.SeatIds.All(s => IsFree(s, now))) return Result.SeatTaken; foreach (var s in h.SeatIds) _held[s] = h; return Result.Ok; }
// An expired hold may already have lost a seat to someone else's hold, so being unexpired // is not enough: every seat must still point at this hold. public bool IsLive(SeatHold h, DateTimeOffset now) => !h.Expired(now) && h.SeatIds.All(s => _held.GetValueOrDefault(s) == h);
public void Sell(SeatHold h, string bookingId) { foreach (var s in h.SeatIds) { _held.Remove(s); _sold[s] = bookingId; } }
// Removes only seats this hold still has; a seat someone else held since is left alone. public void Release(SeatHold h) { foreach (var s in h.SeatIds) if (_held.GetValueOrDefault(s) == h) _held.Remove(s); }
public List<string> Available(DateTimeOffset now) => Show.Screen.Seats.Select(s => s.Id).Where(s => IsFree(s, now)).ToList();}
public sealed class BookingService(Func<string> ids, Func<DateTimeOffset> clock){ public static readonly TimeSpan HoldFor = TimeSpan.FromMinutes(5); public const int MaxSeats = 10;
// Concurrent maps because requests arrive in parallel. Seats get their own protection in // the concurrency section; these maps only need to be safe to read and write. private readonly ConcurrentDictionary<string, ShowSeats> _shows = new(); private readonly ConcurrentDictionary<string, SeatHold> _holds = new();
// clock is injected, so tests can let a hold run out. public void AddShow(Show s) => _shows[s.Id] = new ShowSeats(s);
public List<string> AvailableSeats(string showId) => _shows[showId].Available(clock());
public (Result, SeatHold?) Hold(string userId, string showId, IReadOnlyList<string> seatIds) { if (!_shows.TryGetValue(showId, out var seats) || !Valid(seats.Show, seatIds)) return (Result.InvalidSeats, null); var now = clock(); var h = new SeatHold(ids(), showId, userId, seatIds.ToList(), now + HoldFor); var r = seats.Hold(h, now); if (r != Result.Ok) return (r, null); _holds[h.Id] = h; return (Result.Ok, h); }
// No payment yet: confirming a live hold sells its seats. public (Result, Booking?) Confirm(string holdId) { if (!_holds.TryRemove(holdId, out var h)) return (Result.NoSuchHold, null); var seats = _shows[h.ShowId]; if (!seats.IsLive(h, clock())) { seats.Release(h); return (Result.HoldExpired, null); } var b = new Booking(ids(), seats.Show, h.UserId, h.SeatIds); seats.Sell(h, b.Id); return (Result.Ok, b); }
public void Release(string holdId) { if (_holds.TryRemove(holdId, out var h)) _shows[h.ShowId].Release(h); }
// Some seats, not too many, no seat twice, and every seat in this screen. private static bool Valid(Show show, IReadOnlyList<string> seatIds) => seatIds.Count > 0 && seatIds.Count <= MaxSeats && seatIds.Distinct().Count() == seatIds.Count && seatIds.All(s => show.Screen.Seat(s) is not null);}// SeatHold is a short claim on seats while the customer pays. It ends one of three ways:// confirmed into a booking, released, or simply running out of time.type SeatHold struct { ID, ShowID, UserID string SeatIDs []string ExpiresAt time.Time}
// Expired is a comparison, not an event. Nothing has to run at 19:05 to free the seats;// anyone who looks after 19:05 sees them free.func (h *SeatHold) Expired(now time.Time) bool { return !now.Before(h.ExpiresAt) }
// Result is what a request gets back, so the app can tell the customer what happened.type Result int
const ( OK Result = iota InvalidSeats SeatTaken NoSuchHold HoldExpired)
// ShowSeats is who has which seat, for one show. Each show is its own small inventory: no// request ever needs seats from two shows at once.type ShowSeats struct { show Show held map[string]*SeatHold // seat -> hold sold map[string]string // seat -> booking id}
func NewShowSeats(s Show) *ShowSeats { return &ShowSeats{show: s, held: map[string]*SeatHold{}, sold: map[string]string{}}}
func (ss *ShowSeats) isFree(seatID string, now time.Time) bool { if _, ok := ss.sold[seatID]; ok { return false } h := ss.held[seatID] return h == nil || h.Expired(now)}
// Hold is all or nothing: check every seat, then take every seat.func (ss *ShowSeats) Hold(h *SeatHold, now time.Time) Result { for _, s := range h.SeatIDs { if !ss.isFree(s, now) { return SeatTaken } } for _, s := range h.SeatIDs { ss.held[s] = h } return OK}
// isLive checks more than expiry: an expired hold may already have lost a seat to someone// else's hold, so every seat must still point at this hold.func (ss *ShowSeats) isLive(h *SeatHold, now time.Time) bool { if h.Expired(now) { return false } for _, s := range h.SeatIDs { if ss.held[s] != h { return false } } return true}
func (ss *ShowSeats) Sell(h *SeatHold, bookingID string) { for _, s := range h.SeatIDs { delete(ss.held, s) ss.sold[s] = bookingID }}
// Release removes only seats this hold still has; a seat someone else held since is left alone.func (ss *ShowSeats) Release(h *SeatHold) { for _, s := range h.SeatIDs { if ss.held[s] == h { delete(ss.held, s) } }}
func (ss *ShowSeats) Available(now time.Time) []string { var out []string for _, s := range ss.show.Screen.Seats { if ss.isFree(s.ID, now) { out = append(out, s.ID) } } return out}
const ( holdFor = 5 * time.Minute // time to pay before seats go back maxSeats = 10 // per booking, so one person can't hold a whole row)
type BookingService struct { mu sync.Mutex // guards the two maps below; seats have their own protection later shows map[string]*ShowSeats holds map[string]*SeatHold ids func() string clock func() time.Time // injected, so tests can let a hold run out}
func NewBookingService(ids func() string, clock func() time.Time) *BookingService { return &BookingService{shows: map[string]*ShowSeats{}, holds: map[string]*SeatHold{}, ids: ids, clock: clock}}
func (b *BookingService) AddShow(s Show) { b.mu.Lock() defer b.mu.Unlock() b.shows[s.ID] = NewShowSeats(s)}
func (b *BookingService) seats(showID string) *ShowSeats { b.mu.Lock() defer b.mu.Unlock() return b.shows[showID]}
// takeHold looks a hold up and, if remove is set, forgets it, under the service's lock.func (b *BookingService) takeHold(id string, remove bool) *SeatHold { b.mu.Lock() defer b.mu.Unlock() h := b.holds[id] if remove { delete(b.holds, id) } return h}
func (b *BookingService) AvailableSeats(showID string) []string { return b.seats(showID).Available(b.clock())}
func (b *BookingService) Hold(userID, showID string, seatIDs []string) (Result, *SeatHold) { ss := b.seats(showID) if ss == nil || !valid(ss.show, seatIDs) { return InvalidSeats, nil } now := b.clock() h := &SeatHold{ID: b.ids(), ShowID: showID, UserID: userID, SeatIDs: slices.Clone(seatIDs), ExpiresAt: now.Add(holdFor)} if r := ss.Hold(h, now); r != OK { return r, nil } b.mu.Lock() b.holds[h.ID] = h b.mu.Unlock() return OK, h}
// Confirm has no payment yet: confirming a live hold sells its seats.func (b *BookingService) Confirm(holdID string) (Result, *Booking) { h := b.takeHold(holdID, true) if h == nil { return NoSuchHold, nil } ss := b.seats(h.ShowID) if !ss.isLive(h, b.clock()) { ss.Release(h) return HoldExpired, nil } bk := &Booking{ID: b.ids(), Show: ss.show, UserID: h.UserID, SeatIDs: h.SeatIDs} ss.Sell(h, bk.ID) return OK, bk}
func (b *BookingService) Release(holdID string) { if h := b.takeHold(holdID, true); h != nil { b.seats(h.ShowID).Release(h) }}
// valid wants some seats, not too many, no seat twice, and every seat in this screen.func valid(show Show, seatIDs []string) bool { if len(seatIDs) == 0 || len(seatIDs) > maxSeats { return false } seen := map[string]bool{} for _, s := range seatIDs { if _, ok := show.Screen.Seat(s); !ok || seen[s] { return false } seen[s] = true } return true}const HOLD_FOR_MS = 5 * 60_000; // time to pay before seats go backconst MAX_SEATS = 10; // per booking, so one person can't hold a whole row
// A short claim on seats while the customer pays. It ends one of three ways: confirmed into a// booking, released, or simply running out of time.class SeatHold { constructor( readonly id: string, readonly showId: string, readonly userId: string, readonly seatIds: readonly string[], readonly expiresAt: Instant, ) {}
// Expiry is a comparison, not an event. Nothing has to run at 19:05 to free the seats; // anyone who looks after 19:05 sees them free. expired(now: Instant): boolean { return now >= this.expiresAt; }}
type Result = 'ok' | 'invalid-seats' | 'seat-taken' | 'no-such-hold' | 'hold-expired';
// Who has which seat, for one show. Each show is its own small inventory: no request ever// needs seats from two shows at once.class ShowSeats { private readonly held = new Map<string, SeatHold>(); // seat -> hold private readonly sold = new Map<string, string>(); // seat -> booking id
constructor(readonly show: Show) {}
isFree(seatId: string, now: Instant): boolean { if (this.sold.has(seatId)) return false; const h = this.held.get(seatId); return !h || h.expired(now); }
// All or nothing: check every seat, then take every seat. hold(h: SeatHold, now: Instant): Result { if (!h.seatIds.every((s) => this.isFree(s, now))) return 'seat-taken'; for (const s of h.seatIds) this.held.set(s, h); return 'ok'; }
// An expired hold may already have lost a seat to someone else's hold, so being unexpired // is not enough: every seat must still point at this hold. isLive(h: SeatHold, now: Instant): boolean { return !h.expired(now) && h.seatIds.every((s) => this.held.get(s) === h); }
sell(h: SeatHold, bookingId: string): void { for (const s of h.seatIds) { this.held.delete(s); this.sold.set(s, bookingId); } }
// Removes only seats this hold still has; a seat someone else held since is left alone. release(h: SeatHold): void { for (const s of h.seatIds) if (this.held.get(s) === h) this.held.delete(s); }
available(now: Instant): string[] { return this.show.screen.seats.map((s) => s.id).filter((s) => this.isFree(s, now)); }}
class BookingService { private readonly shows = new Map<string, ShowSeats>(); private readonly holds = new Map<string, SeatHold>();
constructor( private readonly ids: () => string, private readonly clock: () => Instant, // injected, so tests can let a hold run out ) {}
addShow(s: Show): void { this.shows.set(s.id, new ShowSeats(s)); }
availableSeats(showId: string): string[] { return this.shows.get(showId)!.available(this.clock()); }
hold(userId: string, showId: string, seatIds: string[]): { result: Result; hold?: SeatHold } { const seats = this.shows.get(showId); if (!seats || !valid(seats.show, seatIds)) return { result: 'invalid-seats' }; const now = this.clock(); const h = new SeatHold(this.ids(), showId, userId, [...seatIds], now + HOLD_FOR_MS); const result = seats.hold(h, now); if (result !== 'ok') return { result }; this.holds.set(h.id, h); return { result, hold: h }; }
// No payment yet: confirming a live hold sells its seats. confirm(holdId: string): { result: Result; booking?: Booking } { const h = this.holds.get(holdId); if (!h) return { result: 'no-such-hold' }; this.holds.delete(holdId); const seats = this.shows.get(h.showId)!; if (!seats.isLive(h, this.clock())) { seats.release(h); return { result: 'hold-expired' }; } const booking: Booking = { id: this.ids(), show: seats.show, userId: h.userId, seatIds: h.seatIds }; seats.sell(h, booking.id); return { result: 'ok', booking }; }
release(holdId: string): void { const h = this.holds.get(holdId); if (!h) return; this.holds.delete(holdId); this.shows.get(h.showId)!.release(h); }}
// Some seats, not too many, no seat twice, and every seat in this screen.function valid(show: Show, seatIds: string[]): boolean { return ( seatIds.length > 0 && seatIds.length <= MAX_SEATS && new Set(seatIds).size === seatIds.length && seatIds.every((s) => seatOf(show.screen, s) !== undefined) );}There’s one subtle case. Ana’s hold runs out, Ben holds the same seat, and then Ana clicks Pay. Ana’s deadline has passed, so she’s refused. But even if the clock were slightly wrong, her seat now points at Ben’s hold. So the “is this hold still live?” check tests both: the deadline hasn’t passed, and every seat still points at this hold.
What does it cost? Expired holds stay in memory until something removes them, and every seat check compares a time. A cleanup now and then handles the memory, and the comparison costs nothing you’d notice.
Stage 3: Paying without losing the seats
Section titled “Stage 3: Paying without losing the seats”The customer clicks Pay at 4 minutes 50 seconds. Their bank shows a verification page, and they take a minute to approve it. What happens?
Whether you see that the hold can run out during the charge, and someone else can then take the seat the customer is paying for. They also listen for what stops a double-clicked Pay button from charging twice.
First, the price. It comes from a Pricing policy (seat type, matinee discount), and it’s
quoted when the seats are held. The quote is stored on the hold, so a price change in the
middle of checkout doesn’t change what the customer agreed to. Pricing is the Strategy
pattern: the cinema can swap the rules behind it.
Now the timing problem. Let’s walk through it. Ana clicks Pay at 4:50, and the hold is still live. Her bank takes a minute. At 5:00, her hold runs out. At 5:10, Ben holds the same seat. At 5:50, Ana’s charge succeeds, for a seat that’s now Ben’s. Either Ana pays for nothing, or the seat is sold twice.
So confirming becomes three steps, and the first one is the important one:
- Pin. If the hold is still live, mark it as being paid for. A pinned hold doesn’t run out, so the seats can’t go to someone else while the bank takes its time. If it’s already pinned (a second Pay click), turn the second request away.
- Charge the card, using the hold’s ID as the payment’s key. Say the request times out and is sent again. The gateway sees the same key, and treats it as the same charge, not a new one. A key like this is called an idempotency key. It makes an action safe to repeat.
- Sell the seats if the charge succeeded. If the card was declined, unpin: the hold goes back to its original deadline, and the customer can try another card in the time left.
A “release” from another browser tab can’t free a pinned hold either. The confirm in progress decides what happens.
Go deeperA slow payment, step by step1 min
Ana clicks Pay at 4:50. The service pins her hold, so it stops running out. The charge goes to the gateway, and her bank shows its verification page. At 5:10, Ben clicks the same seat. The seat still points at Ana’s pinned hold, so Ben is told it’s taken. At 5:50, Ana approves, the charge succeeds, and the service sells the seats to her. Nobody was charged for a seat they didn’t get.
- Color key
- Interface
- Entity
- Facade
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// Prices live behind an interface because they change for reasons the booking flow doesn't// care about: matinees, premium formats, holiday weekends.interface Pricing { long priceOf(Show show, Seat seat); // in cents}
// One cinema's prices: $12 regular, $18 premium, and a quarter off shows before 5pm.class StandardPricing implements Pricing { private final ZoneId zone; // the cinema's time zone: "before 5pm" is local time
StandardPricing(ZoneId zone) { this.zone = zone; }
public long priceOf(Show show, Seat seat) { long base = seat.type() == SeatType.PREMIUM ? 1800 : 1200; boolean matinee = show.startsAt().atZone(zone).getHour() < 17; return matinee ? base * 3 / 4 : base; }}
// The card network. Every charge carries a key, so a retry after a timeout is the same charge,// not a second one.interface PaymentGateway { boolean charge(String key, long cents, String card); void refund(String key, long cents);}
// Seats kept for one customer for a few minutes while they pay. Without it, a seat could be// sold to someone else between choosing it and paying.class SeatHold { final String id; final String showId; final String userId; final List<String> seatIds; final Instant expiresAt; final long price; // quoted at hold time; a price change mid-payment doesn't change the deal boolean paying; // set while the card is charged; a hold being paid for doesn't expire
SeatHold(String id, String showId, String userId, List<String> seatIds, Instant expiresAt, long price) { this.id = id; this.showId = showId; this.userId = userId; this.seatIds = seatIds; this.expiresAt = expiresAt; this.price = price; }
boolean expired(Instant now) { return !paying && !now.isBefore(expiresAt); }}
record Booking(String id, Show show, String userId, List<String> seatIds, long price, String paymentKey) {}
enum Result { OK, INVALID_SEATS, SEAT_TAKEN, NO_SUCH_HOLD, HOLD_EXPIRED, PAYMENT_IN_PROGRESS, PAYMENT_DECLINED }
class ShowSeats { final Show show; private final Map<String, SeatHold> held = new HashMap<>(); private final Map<String, String> sold = new HashMap<>();
ShowSeats(Show show) { this.show = show; }
boolean isFree(String seatId, Instant now) { if (sold.containsKey(seatId)) return false; SeatHold h = held.get(seatId); return h == null || h.expired(now); }
Result hold(SeatHold h, Instant now) { for (String s : h.seatIds) if (!isFree(s, now)) return Result.SEAT_TAKEN; for (String s : h.seatIds) held.put(s, h); return Result.OK; }
boolean isLive(SeatHold h, Instant now) { return !h.expired(now) && h.seatIds.stream().allMatch(s -> held.get(s) == h); }
// Step 1 of paying: if the hold is still live, pin it so it can't expire while the card is // charged. A second "Pay" click finds it already pinned and is turned away. Result pin(SeatHold h, Instant now) { if (h.paying) return Result.PAYMENT_IN_PROGRESS; if (!isLive(h, now)) return Result.HOLD_EXPIRED; h.paying = true; return Result.OK; }
// The card was declined: the hold goes back to its original deadline, so the customer can // try another card in the time that's left. void unpin(SeatHold h) { h.paying = false; }
void sell(SeatHold h, String bookingId) { for (String s : h.seatIds) { held.remove(s); sold.put(s, bookingId); } }
// A hold being paid for is settled by its confirm, never by a release from another tab. boolean release(SeatHold h) { if (h.paying) return false; for (String s : h.seatIds) held.remove(s, h); return true; }
List<String> available(Instant now) { return show.screen().seats().stream().map(Seat::id).filter(s -> isFree(s, now)).toList(); }}
class BookingService { static final Duration HOLD_FOR = Duration.ofMinutes(5); static final int MAX_SEATS = 10;
private final Map<String, ShowSeats> shows = new ConcurrentHashMap<>(); private final Map<String, SeatHold> holds = new ConcurrentHashMap<>(); private final Map<String, Booking> bookings = new ConcurrentHashMap<>(); private final Pricing pricing; private final PaymentGateway payments; private final Supplier<String> ids; private final Supplier<Instant> clock;
BookingService(Pricing pricing, PaymentGateway payments, Supplier<String> ids, Supplier<Instant> clock) { this.pricing = pricing; this.payments = payments; this.ids = ids; this.clock = clock; }
void addShow(Show s) { shows.put(s.id(), new ShowSeats(s)); }
List<String> availableSeats(String showId) { return shows.get(showId).available(clock.get()); }
HoldResult hold(String userId, String showId, List<String> seatIds) { ShowSeats seats = shows.get(showId); if (seats == null || !valid(seats.show, seatIds)) return new HoldResult(Result.INVALID_SEATS, null); Instant now = clock.get(); long price = seatIds.stream().mapToLong(s -> pricing.priceOf(seats.show, seats.show.screen().seat(s).get())).sum(); SeatHold h = new SeatHold(ids.get(), showId, userId, List.copyOf(seatIds), now.plus(HOLD_FOR), price); Result r = seats.hold(h, now); if (r != Result.OK) return new HoldResult(r, null); holds.put(h.id, h); return new HoldResult(Result.OK, h); }
// Pin, charge, then sell. The charge can take a minute (a bank's 3-D Secure page), so the // hold is pinned first: the seats can't expire out from under a customer who is paying. BookingResult confirm(String holdId, String card) { SeatHold h = holds.get(holdId); if (h == null) return new BookingResult(Result.NO_SUCH_HOLD, null); ShowSeats seats = shows.get(h.showId); Result pinned = seats.pin(h, clock.get()); if (pinned != Result.OK) return new BookingResult(pinned, null); // The hold id is the payment key, so a retried confirm can never charge twice. if (!payments.charge(h.id, h.price, card)) { seats.unpin(h); return new BookingResult(Result.PAYMENT_DECLINED, null); } Booking b = new Booking(ids.get(), seats.show, h.userId, h.seatIds, h.price, h.id); seats.sell(h, b.id()); holds.remove(holdId); bookings.put(b.id(), b); return new BookingResult(Result.OK, b); }
void release(String holdId) { SeatHold h = holds.get(holdId); if (h != null && shows.get(h.showId).release(h)) holds.remove(holdId); }
private static boolean valid(Show show, List<String> seatIds) { return !seatIds.isEmpty() && seatIds.size() <= MAX_SEATS && new HashSet<>(seatIds).size() == seatIds.size() && seatIds.stream().allMatch(s -> show.screen().seat(s).isPresent()); }}from typing import Protocolfrom zoneinfo import ZoneInfo
# Prices live behind an interface because they change for reasons the booking flow doesn't# care about: matinees, premium formats, holiday weekends.class Pricing(Protocol): def price_of(self, show: Show, seat: Seat) -> int: ... # in cents
# One cinema's prices: $12 regular, $18 premium, and a quarter off shows before 5pm.class StandardPricing: def __init__(self, zone: ZoneInfo): self._zone = zone # the cinema's time zone: "before 5pm" is local time
def price_of(self, show: Show, seat: Seat) -> int: base = 1800 if seat.type is SeatType.PREMIUM else 1200 matinee = show.starts_at.astimezone(self._zone).hour < 17 return base * 3 // 4 if matinee else base
# The card network. Every charge carries a key, so a retry after a timeout is the same charge,# not a second one.class PaymentGateway(Protocol): def charge(self, key: str, cents: int, card: str) -> bool: ... def refund(self, key: str, cents: int) -> None: ...
# Seats kept for one customer for a few minutes while they pay. Without it, a seat could be# sold to someone else between choosing it and paying.@dataclass(eq=False)class SeatHold: id: str show_id: str user_id: str seat_ids: tuple[str, ...] expires_at: datetime price: int # quoted at hold time; a price change mid-payment doesn't change the deal paying: bool = False # set while the card is charged; a hold being paid for doesn't expire
def expired(self, now: datetime) -> bool: return not self.paying and now >= self.expires_at
@dataclass(frozen=True)class Booking: id: str show: Show user_id: str seat_ids: tuple[str, ...] price: int payment_key: str
class Result(Enum): OK = auto() INVALID_SEATS = auto() SEAT_TAKEN = auto() NO_SUCH_HOLD = auto() HOLD_EXPIRED = auto() PAYMENT_IN_PROGRESS = auto() PAYMENT_DECLINED = auto()
class ShowSeats: def __init__(self, show: Show): self.show = show self._held: dict[str, SeatHold] = {} self._sold: dict[str, str] = {}
def is_free(self, seat_id: str, now: datetime) -> bool: if seat_id in self._sold: return False h = self._held.get(seat_id) return h is None or h.expired(now)
def hold(self, h: SeatHold, now: datetime) -> Result: if not all(self.is_free(s, now) for s in h.seat_ids): return Result.SEAT_TAKEN for s in h.seat_ids: self._held[s] = h return Result.OK
def is_live(self, h: SeatHold, now: datetime) -> bool: return not h.expired(now) and all(self._held.get(s) is h for s in h.seat_ids)
# Step 1 of paying: if the hold is still live, pin it so it can't expire while the card # is charged. A second "Pay" click finds it already pinned and is turned away. def pin(self, h: SeatHold, now: datetime) -> Result: if h.paying: return Result.PAYMENT_IN_PROGRESS if not self.is_live(h, now): return Result.HOLD_EXPIRED h.paying = True return Result.OK
# The card was declined: the hold goes back to its original deadline, so the customer can # try another card in the time that's left. def unpin(self, h: SeatHold) -> None: h.paying = False
def sell(self, h: SeatHold, booking_id: str) -> None: for s in h.seat_ids: self._held.pop(s, None) self._sold[s] = booking_id
# A hold being paid for is settled by its confirm, never by a release from another tab. def release(self, h: SeatHold) -> bool: if h.paying: return False for s in h.seat_ids: if self._held.get(s) is h: del self._held[s] return True
def available(self, now: datetime) -> list[str]: return [s.id for s in self.show.screen.seats if self.is_free(s.id, now)]
class BookingService: def __init__(self, pricing: Pricing, payments: PaymentGateway, ids: Callable[[], str], clock: Callable[[], datetime]): self._shows: dict[str, ShowSeats] = {} self._holds: dict[str, SeatHold] = {} self._bookings: dict[str, Booking] = {} self._pricing = pricing self._payments = payments self._ids = ids self._clock = clock
def add_show(self, s: Show) -> None: self._shows[s.id] = ShowSeats(s)
def available_seats(self, show_id: str) -> list[str]: return self._shows[show_id].available(self._clock())
def hold(self, user_id: str, show_id: str, seat_ids: list[str]) -> tuple[Result, SeatHold | None]: seats = self._shows.get(show_id) if seats is None or not _valid(seats.show, seat_ids): return Result.INVALID_SEATS, None now = self._clock() price = sum(self._pricing.price_of(seats.show, seats.show.screen.seat(s)) for s in seat_ids) h = SeatHold(self._ids(), show_id, user_id, tuple(seat_ids), now + HOLD_FOR, price) r = seats.hold(h, now) if r is not Result.OK: return r, None self._holds[h.id] = h return Result.OK, h
# Pin, charge, then sell. The charge can take a minute (a bank's 3-D Secure page), so the # hold is pinned first: the seats can't expire out from under a customer who is paying. def confirm(self, hold_id: str, card: str) -> tuple[Result, Booking | None]: h = self._holds.get(hold_id) if h is None: return Result.NO_SUCH_HOLD, None seats = self._shows[h.show_id] pinned = seats.pin(h, self._clock()) if pinned is not Result.OK: return pinned, None # The hold id is the payment key, so a retried confirm can never charge twice. if not self._payments.charge(h.id, h.price, card): seats.unpin(h) return Result.PAYMENT_DECLINED, None b = Booking(self._ids(), seats.show, h.user_id, h.seat_ids, h.price, h.id) seats.sell(h, b.id) self._holds.pop(hold_id, None) self._bookings[b.id] = b return Result.OK, b
def release(self, hold_id: str) -> None: h = self._holds.get(hold_id) if h is not None and self._shows[h.show_id].release(h): self._holds.pop(hold_id, None)// Prices live behind an interface because they change for reasons the booking flow doesn't// care about: matinees, premium formats, holiday weekends.public interface IPricing{ long PriceOf(Show show, Seat seat); // in cents}
// zone is the cinema's time zone: "before 5pm" is local time, not the server's.// One cinema's prices: $12 regular, $18 premium, and a quarter off shows before 5pm.public sealed class StandardPricing(TimeZoneInfo zone) : IPricing{ public long PriceOf(Show show, Seat seat) { long baseCents = seat.Type == SeatType.Premium ? 1800 : 1200; var matinee = TimeZoneInfo.ConvertTime(show.StartsAt, zone).Hour < 17; return matinee ? baseCents * 3 / 4 : baseCents; }}
// The card network. Every charge carries a key, so a retry after a timeout is the same charge,// not a second one.public interface IPaymentGateway{ bool Charge(string key, long cents, string card); void Refund(string key, long cents);}
// Seats kept for one customer for a few minutes while they pay. Without it, a seat could be// sold to someone else between choosing it and paying.public sealed class SeatHold(string id, string showId, string userId, IReadOnlyList<string> seatIds, DateTimeOffset expiresAt, long price){ public string Id { get; } = id; public string ShowId { get; } = showId; public string UserId { get; } = userId; public IReadOnlyList<string> SeatIds { get; } = seatIds; public DateTimeOffset ExpiresAt { get; } = expiresAt; public long Price { get; } = price; // quoted at hold time; a price change mid-payment doesn't change the deal public bool Paying { get; set; } // set while the card is charged; a hold being paid for doesn't expire
public bool Expired(DateTimeOffset now) => !Paying && now >= ExpiresAt;}
public sealed record Booking(string Id, Show Show, string UserId, IReadOnlyList<string> SeatIds, long Price, string PaymentKey);
public enum Result { Ok, InvalidSeats, SeatTaken, NoSuchHold, HoldExpired, PaymentInProgress, PaymentDeclined }
public sealed class ShowSeats(Show show){ private readonly Dictionary<string, SeatHold> _held = []; private readonly Dictionary<string, string> _sold = [];
public Show Show { get; } = show;
public bool IsFree(string seatId, DateTimeOffset now) => !_sold.ContainsKey(seatId) && (!_held.TryGetValue(seatId, out var h) || h.Expired(now));
public Result Hold(SeatHold h, DateTimeOffset now) { if (!h.SeatIds.All(s => IsFree(s, now))) return Result.SeatTaken; foreach (var s in h.SeatIds) _held[s] = h; return Result.Ok; }
public bool IsLive(SeatHold h, DateTimeOffset now) => !h.Expired(now) && h.SeatIds.All(s => _held.GetValueOrDefault(s) == h);
// Step 1 of paying: if the hold is still live, pin it so it can't expire while the card is // charged. A second "Pay" click finds it already pinned and is turned away. public Result Pin(SeatHold h, DateTimeOffset now) { if (h.Paying) return Result.PaymentInProgress; if (!IsLive(h, now)) return Result.HoldExpired; h.Paying = true; return Result.Ok; }
// The card was declined: the hold goes back to its original deadline, so the customer can // try another card in the time that's left. public void Unpin(SeatHold h) => h.Paying = false;
public void Sell(SeatHold h, string bookingId) { foreach (var s in h.SeatIds) { _held.Remove(s); _sold[s] = bookingId; } }
// A hold being paid for is settled by its confirm, never by a release from another tab. public bool Release(SeatHold h) { if (h.Paying) return false; foreach (var s in h.SeatIds) if (_held.GetValueOrDefault(s) == h) _held.Remove(s); return true; }
public List<string> Available(DateTimeOffset now) => Show.Screen.Seats.Select(s => s.Id).Where(s => IsFree(s, now)).ToList();}
public sealed class BookingService(IPricing pricing, IPaymentGateway payments, Func<string> ids, Func<DateTimeOffset> clock){ public static readonly TimeSpan HoldFor = TimeSpan.FromMinutes(5); public const int MaxSeats = 10;
private readonly ConcurrentDictionary<string, ShowSeats> _shows = new(); private readonly ConcurrentDictionary<string, SeatHold> _holds = new(); private readonly ConcurrentDictionary<string, Booking> _bookings = new();
public void AddShow(Show s) => _shows[s.Id] = new ShowSeats(s);
public List<string> AvailableSeats(string showId) => _shows[showId].Available(clock());
public (Result, SeatHold?) Hold(string userId, string showId, IReadOnlyList<string> seatIds) { if (!_shows.TryGetValue(showId, out var seats) || !Valid(seats.Show, seatIds)) return (Result.InvalidSeats, null); var now = clock(); var price = seatIds.Sum(s => pricing.PriceOf(seats.Show, seats.Show.Screen.Seat(s)!)); var h = new SeatHold(ids(), showId, userId, seatIds.ToList(), now + HoldFor, price); var r = seats.Hold(h, now); if (r != Result.Ok) return (r, null); _holds[h.Id] = h; return (Result.Ok, h); }
// Pin, charge, then sell. The charge can take a minute (a bank's 3-D Secure page), so the // hold is pinned first: the seats can't expire out from under a customer who is paying. public (Result, Booking?) Confirm(string holdId, string card) { if (!_holds.TryGetValue(holdId, out var h)) return (Result.NoSuchHold, null); var seats = _shows[h.ShowId]; var pinned = seats.Pin(h, clock()); if (pinned != Result.Ok) return (pinned, null); // The hold id is the payment key, so a retried confirm can never charge twice. if (!payments.Charge(h.Id, h.Price, card)) { seats.Unpin(h); return (Result.PaymentDeclined, null); } var b = new Booking(ids(), seats.Show, h.UserId, h.SeatIds, h.Price, h.Id); seats.Sell(h, b.Id); _holds.TryRemove(holdId, out _); _bookings[b.Id] = b; return (Result.Ok, b); }
public void Release(string holdId) { if (_holds.TryGetValue(holdId, out var h) && _shows[h.ShowId].Release(h)) _holds.TryRemove(holdId, out _); }
private static bool Valid(Show show, IReadOnlyList<string> seatIds) => seatIds.Count > 0 && seatIds.Count <= MaxSeats && seatIds.Distinct().Count() == seatIds.Count && seatIds.All(s => show.Screen.Seat(s) is not null);}// Pricing lives behind an interface because prices change for reasons the booking flow// doesn't care about: matinees, premium formats, holiday weekends.type Pricing interface { PriceOf(show Show, seat Seat) int64 // in cents}
// StandardPricing is one cinema's prices: $12 regular, $18 premium, and a quarter off shows// before 5pm.type StandardPricing struct { Zone *time.Location // the cinema's time zone: "before 5pm" is local time}
func (p StandardPricing) PriceOf(show Show, seat Seat) int64 { base := int64(1200) if seat.Type == Premium { base = 1800 } if show.StartsAt.In(p.Zone).Hour() < 17 { // matinee return base * 3 / 4 } return base}
// PaymentGateway is the card network. Every charge carries a key, so a retry after a timeout// is the same charge, not a second one.type PaymentGateway interface { Charge(key string, cents int64, card string) bool Refund(key string, cents int64)}
// SeatHold keeps seats for one customer for a few minutes while they pay. Without it, a seat// could be sold to someone else between choosing it and paying.type SeatHold struct { ID, ShowID, UserID string SeatIDs []string ExpiresAt time.Time Price int64 // quoted at hold time; a price change mid-payment doesn't change the deal paying bool // set while the card is charged; a hold being paid for doesn't expire}
func (h *SeatHold) Expired(now time.Time) bool { return !h.paying && !now.Before(h.ExpiresAt) }
type Booking struct { ID string Show Show UserID string SeatIDs []string Price int64 PaymentKey string}
const ( OK Result = iota InvalidSeats SeatTaken NoSuchHold HoldExpired PaymentInProgress PaymentDeclined)
// Pin is step 1 of paying: if the hold is still live, pin it so it can't expire while the// card is charged. A second "Pay" click finds it already pinned and is turned away.func (ss *ShowSeats) Pin(h *SeatHold, now time.Time) Result { if h.paying { return PaymentInProgress } if !ss.isLive(h, now) { return HoldExpired } h.paying = true return OK}
// Unpin follows a declined card: the hold goes back to its original deadline, so the customer// can try another card in the time that's left.func (ss *ShowSeats) Unpin(h *SeatHold) { h.paying = false }
// Release leaves a hold being paid for alone: its confirm settles it, not a release from// another tab.func (ss *ShowSeats) Release(h *SeatHold) bool { if h.paying { return false } for _, s := range h.SeatIDs { if ss.held[s] == h { delete(ss.held, s) } } return true}
type BookingService struct { mu sync.Mutex shows map[string]*ShowSeats holds map[string]*SeatHold bookings map[string]*Booking pricing Pricing payments PaymentGateway ids func() string clock func() time.Time}
func NewBookingService(pricing Pricing, payments PaymentGateway, ids func() string, clock func() time.Time) *BookingService { return &BookingService{shows: map[string]*ShowSeats{}, holds: map[string]*SeatHold{}, bookings: map[string]*Booking{}, pricing: pricing, payments: payments, ids: ids, clock: clock}}
func (b *BookingService) AddShow(s Show) { b.mu.Lock() defer b.mu.Unlock() b.shows[s.ID] = NewShowSeats(s)}
func (b *BookingService) seats(showID string) *ShowSeats { b.mu.Lock() defer b.mu.Unlock() return b.shows[showID]}
func (b *BookingService) takeHold(id string, remove bool) *SeatHold { b.mu.Lock() defer b.mu.Unlock() h := b.holds[id] if remove { delete(b.holds, id) } return h}
func (b *BookingService) AvailableSeats(showID string) []string { return b.seats(showID).Available(b.clock())}
func (b *BookingService) Hold(userID, showID string, seatIDs []string) (Result, *SeatHold) { ss := b.seats(showID) if ss == nil || !valid(ss.show, seatIDs) { return InvalidSeats, nil } now := b.clock() var price int64 for _, id := range seatIDs { seat, _ := ss.show.Screen.Seat(id) price += b.pricing.PriceOf(ss.show, seat) } h := &SeatHold{ID: b.ids(), ShowID: showID, UserID: userID, SeatIDs: slices.Clone(seatIDs), ExpiresAt: now.Add(holdFor), Price: price} if r := ss.Hold(h, now); r != OK { return r, nil } b.mu.Lock() b.holds[h.ID] = h b.mu.Unlock() return OK, h}
// Confirm pins, charges, then sells. The charge can take a minute (a bank's 3-D Secure page),// so the hold is pinned first: the seats can't expire out from under a customer who is paying.func (b *BookingService) Confirm(holdID, card string) (Result, *Booking) { h := b.takeHold(holdID, false) if h == nil { return NoSuchHold, nil } ss := b.seats(h.ShowID) if r := ss.Pin(h, b.clock()); r != OK { return r, nil } // The hold id is the payment key, so a retried confirm can never charge twice. if !b.payments.Charge(h.ID, h.Price, card) { ss.Unpin(h) return PaymentDeclined, nil } bk := &Booking{ID: b.ids(), Show: ss.show, UserID: h.UserID, SeatIDs: h.SeatIDs, Price: h.Price, PaymentKey: h.ID} ss.Sell(h, bk.ID) b.mu.Lock() delete(b.holds, holdID) b.bookings[bk.ID] = bk b.mu.Unlock() return OK, bk}
func (b *BookingService) Release(holdID string) { h := b.takeHold(holdID, false) if h != nil && b.seats(h.ShowID).Release(h) { b.takeHold(holdID, true) }}// Prices live behind an interface because they change for reasons the booking flow doesn't// care about: matinees, premium formats, holiday weekends.interface Pricing { priceOf(show: Show, seat: Seat): number; // in cents}
// One cinema's prices: $12 regular, $18 premium, and a quarter off shows before 5pm.class StandardPricing implements Pricing { private readonly hourOf: Intl.DateTimeFormat;
// The cinema's time zone: "before 5pm" is local time, not the server's. constructor(timeZone: string) { this.hourOf = new Intl.DateTimeFormat('en-US', { timeZone, hour: 'numeric', hourCycle: 'h23' }); }
priceOf(show: Show, seat: Seat): number { const base = seat.type === 'premium' ? 1800 : 1200; const matinee = Number(this.hourOf.format(show.startsAt)) < 17; return matinee ? (base * 3) / 4 : base; }}
// The card network. Every charge carries a key, so a retry after a timeout is the same charge,// not a second one. It's async: the customer may be on their bank's page for a minute.interface PaymentGateway { charge(key: string, cents: number, card: string): Promise<boolean>; refund(key: string, cents: number): Promise<void>;}
// Seats kept for one customer for a few minutes while they pay. Without it, a seat could be sold// to someone else between choosing it and paying.class SeatHold { paying = false; // set while the card is charged; a hold being paid for doesn't expire
constructor( readonly id: string, readonly showId: string, readonly userId: string, readonly seatIds: readonly string[], readonly expiresAt: Instant, readonly price: number, // quoted at hold time; a price change mid-payment doesn't change the deal ) {}
expired(now: Instant): boolean { return !this.paying && now >= this.expiresAt; }}
interface Booking { readonly id: string; readonly show: Show; readonly userId: string; readonly seatIds: readonly string[]; readonly price: number; readonly paymentKey: string;}
type Result = | 'ok' | 'invalid-seats' | 'seat-taken' | 'no-such-hold' | 'hold-expired' | 'payment-in-progress' | 'payment-declined';
class ShowSeats { private readonly held = new Map<string, SeatHold>(); private readonly sold = new Map<string, string>();
constructor(readonly show: Show) {}
isFree(seatId: string, now: Instant): boolean { if (this.sold.has(seatId)) return false; const h = this.held.get(seatId); return !h || h.expired(now); }
hold(h: SeatHold, now: Instant): Result { if (!h.seatIds.every((s) => this.isFree(s, now))) return 'seat-taken'; for (const s of h.seatIds) this.held.set(s, h); return 'ok'; }
isLive(h: SeatHold, now: Instant): boolean { return !h.expired(now) && h.seatIds.every((s) => this.held.get(s) === h); }
// Step 1 of paying: if the hold is still live, pin it so it can't expire while the card is // charged. A second "Pay" click finds it already pinned and is turned away. pin(h: SeatHold, now: Instant): Result { if (h.paying) return 'payment-in-progress'; if (!this.isLive(h, now)) return 'hold-expired'; h.paying = true; return 'ok'; }
// The card was declined: the hold goes back to its original deadline, so the customer can // try another card in the time that's left. unpin(h: SeatHold): void { h.paying = false; }
sell(h: SeatHold, bookingId: string): void { for (const s of h.seatIds) { this.held.delete(s); this.sold.set(s, bookingId); } }
// A hold being paid for is settled by its confirm, never by a release from another tab. release(h: SeatHold): boolean { if (h.paying) return false; for (const s of h.seatIds) if (this.held.get(s) === h) this.held.delete(s); return true; }
available(now: Instant): string[] { return this.show.screen.seats.map((s) => s.id).filter((s) => this.isFree(s, now)); }}
class BookingService { private readonly shows = new Map<string, ShowSeats>(); private readonly holds = new Map<string, SeatHold>(); private readonly bookings = new Map<string, Booking>();
constructor( private readonly pricing: Pricing, private readonly payments: PaymentGateway, private readonly ids: () => string, private readonly clock: () => Instant, ) {}
addShow(s: Show): void { this.shows.set(s.id, new ShowSeats(s)); }
availableSeats(showId: string): string[] { return this.shows.get(showId)!.available(this.clock()); }
hold(userId: string, showId: string, seatIds: string[]): { result: Result; hold?: SeatHold } { const seats = this.shows.get(showId); if (!seats || !valid(seats.show, seatIds)) return { result: 'invalid-seats' }; const now = this.clock(); const price = seatIds.reduce((sum, s) => sum + this.pricing.priceOf(seats.show, seatOf(seats.show.screen, s)!), 0); const h = new SeatHold(this.ids(), showId, userId, [...seatIds], now + HOLD_FOR_MS, price); const result = seats.hold(h, now); if (result !== 'ok') return { result }; this.holds.set(h.id, h); return { result, hold: h }; }
// Pin, charge, then sell. The await on the charge is where other requests run, and where the // clock moves on, so the hold is pinned first: the seats can't expire out from under a // customer who is paying. async confirm(holdId: string, card: string): Promise<{ result: Result; booking?: Booking }> { const h = this.holds.get(holdId); if (!h) return { result: 'no-such-hold' }; const seats = this.shows.get(h.showId)!; const pinned = seats.pin(h, this.clock()); if (pinned !== 'ok') return { result: pinned }; // The hold id is the payment key, so a retried confirm can never charge twice. if (!(await this.payments.charge(h.id, h.price, card))) { seats.unpin(h); return { result: 'payment-declined' }; } const booking: Booking = { id: this.ids(), show: seats.show, userId: h.userId, seatIds: h.seatIds, price: h.price, paymentKey: h.id, }; seats.sell(h, booking.id); this.holds.delete(holdId); this.bookings.set(booking.id, booking); return { result: 'ok', booking }; }
release(holdId: string): void { const h = this.holds.get(holdId); if (h && this.shows.get(h.showId)!.release(h)) this.holds.delete(holdId); }}What does pinning cost? A confirm that crashes after pinning leaves the seats pinned forever. So real systems give the pin its own, longer deadline (say 15 minutes). They also run a reconciler. It’s a job that asks the gateway what happened to that payment key, then sells or releases the seats. The key is what makes that question possible to ask.
Go deeperWhy quote the price when the seats are held, not at payment?1 min
Because the customer agreed to a price on the seat map. If the cinema switches to peak pricing while they type their card number, charging the new price is a bait and switch. Storing the quote on the hold makes “what did they agree to?” a field to read, not a question about timing.
Stage 4: Cancellations and refunds
Section titled “Stage 4: Cancellations and refunds”Customers can cancel until the show starts: full refund a day ahead, half refund up to two hours before, nothing after. Add it.
Whether the refund rule is a policy, whether cancelling puts the seats back on sale, and whether a double-clicked Cancel refunds twice. Hard-coding 24 hours and 2 hours into the service is the answer they hear most.
The refund rule belongs to the cinema and changes with promotions. So it’s a RefundPolicy, which
the service asks with the booking and the current time (Strategy again). Booking becomes a
class with a “cancelled” flag, and it remembers the payment key, so a refund can point at the
original charge.
Cancelling happens inside the show’s inventory as one step. It checks the booking isn’t already cancelled and the show hasn’t started. It marks it cancelled, and puts its seats back on sale. A second cancel finds it already done, so the refund is sent only once. The refund call comes after, with the charge’s key. If it fails, a retry job can send it again without paying out twice.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- New in this step
// A confirmed purchase. A class rather than a record because it can be cancelled.class Booking { final String id; final Show show; final String userId; final List<String> seatIds; final long price; final String paymentKey; // the charge's key, which a refund refers back to boolean cancelled;
Booking(String id, Show show, String userId, List<String> seatIds, long price, String paymentKey) { this.id = id; this.show = show; this.userId = userId; this.seatIds = seatIds; this.price = price; this.paymentKey = paymentKey; }}
// How much comes back depends on how close to the show the customer cancels. Cinemas set// this, and change it, so it's a policy the service asks.interface RefundPolicy { long refundFor(Booking b, Instant now); // in cents; 0 means no refund}
// Full refund a day or more before the show, half from two hours before, nothing after that.class StandardRefunds implements RefundPolicy { public long refundFor(Booking b, Instant now) { long hoursBefore = Duration.between(now, b.show.startsAt()).toHours(); if (hoursBefore >= 24) return b.price; if (hoursBefore >= 2) return b.price / 2; return 0; }}
// What a request gets back, so the app can tell the customer what happened.enum Result { OK, INVALID_SEATS, SEAT_TAKEN, NO_SUCH_HOLD, HOLD_EXPIRED, PAYMENT_IN_PROGRESS, PAYMENT_DECLINED, NO_SUCH_BOOKING, ALREADY_CANCELLED, SHOW_STARTED}
class ShowSeats { final Show show; private final Map<String, SeatHold> held = new HashMap<>(); private final Map<String, String> sold = new HashMap<>();
ShowSeats(Show show) { this.show = show; }
boolean isFree(String seatId, Instant now) { if (sold.containsKey(seatId)) return false; SeatHold h = held.get(seatId); return h == null || h.expired(now); }
Result hold(SeatHold h, Instant now) { for (String s : h.seatIds) if (!isFree(s, now)) return Result.SEAT_TAKEN; for (String s : h.seatIds) held.put(s, h); return Result.OK; }
boolean isLive(SeatHold h, Instant now) { return !h.expired(now) && h.seatIds.stream().allMatch(s -> held.get(s) == h); }
Result pin(SeatHold h, Instant now) { if (h.paying) return Result.PAYMENT_IN_PROGRESS; if (!isLive(h, now)) return Result.HOLD_EXPIRED; h.paying = true; return Result.OK; }
void unpin(SeatHold h) { h.paying = false; }
void sell(SeatHold h, String bookingId) { for (String s : h.seatIds) { held.remove(s); sold.put(s, bookingId); } }
boolean release(SeatHold h) { if (h.paying) return false; for (String s : h.seatIds) held.remove(s, h); return true; }
// Marks the booking cancelled and puts its seats back on sale, as one step: a second // cancel finds it already done, so the customer is refunded once. Result cancel(Booking b, Instant now) { if (b.cancelled) return Result.ALREADY_CANCELLED; if (!now.isBefore(show.startsAt())) return Result.SHOW_STARTED; b.cancelled = true; for (String s : b.seatIds) sold.remove(s, b.id); return Result.OK; }
List<String> available(Instant now) { return show.screen().seats().stream().map(Seat::id).filter(s -> isFree(s, now)).toList(); }}
// The front door. It checks requests, prices seats, takes payment, and hands the seat changes to// each show's ShowSeats.class BookingService { static final Duration HOLD_FOR = Duration.ofMinutes(5); // time to pay before seats go back static final int MAX_SEATS = 10; // per booking, so one person can't hold a whole row
private final Map<String, ShowSeats> shows = new ConcurrentHashMap<>(); private final Map<String, SeatHold> holds = new ConcurrentHashMap<>(); private final Map<String, Booking> bookings = new ConcurrentHashMap<>(); private final Pricing pricing; private final RefundPolicy refunds; private final PaymentGateway payments; private final Supplier<String> ids; private final Supplier<Instant> clock;
BookingService(Pricing pricing, RefundPolicy refunds, PaymentGateway payments, Supplier<String> ids, Supplier<Instant> clock) { this.pricing = pricing; this.refunds = refunds; this.payments = payments; this.ids = ids; this.clock = clock; }
void addShow(Show s) { shows.put(s.id(), new ShowSeats(s)); }
List<String> availableSeats(String showId) { return shows.get(showId).available(clock.get()); }
// Prices the seats now and keeps that price in the hold, then tries to take every seat. HoldResult hold(String userId, String showId, List<String> seatIds) { ShowSeats seats = shows.get(showId); if (seats == null || !valid(seats.show, seatIds)) return new HoldResult(Result.INVALID_SEATS, null); Instant now = clock.get(); long price = seatIds.stream().mapToLong(s -> pricing.priceOf(seats.show, seats.show.screen().seat(s).get())).sum(); SeatHold h = new SeatHold(ids.get(), showId, userId, List.copyOf(seatIds), now.plus(HOLD_FOR), price); Result r = seats.hold(h, now); if (r != Result.OK) return new HoldResult(r, null); holds.put(h.id, h); return new HoldResult(Result.OK, h); }
// Pin the hold, charge the card, then sell the seats. The hold's id is the payment key, so a // retried confirm can't charge twice. BookingResult confirm(String holdId, String card) { SeatHold h = holds.get(holdId); if (h == null) return new BookingResult(Result.NO_SUCH_HOLD, null); ShowSeats seats = shows.get(h.showId); Result pinned = seats.pin(h, clock.get()); if (pinned != Result.OK) return new BookingResult(pinned, null); if (!payments.charge(h.id, h.price, card)) { seats.unpin(h); return new BookingResult(Result.PAYMENT_DECLINED, null); } Booking b = new Booking(ids.get(), seats.show, h.userId, h.seatIds, h.price, h.id); seats.sell(h, b.id); holds.remove(holdId); bookings.put(b.id, b); return new BookingResult(Result.OK, b); }
// The customer gave up. A hold being paid for is not released. void release(String holdId) { SeatHold h = holds.get(holdId); if (h != null && shows.get(h.showId).release(h)) holds.remove(holdId); }
// Seats go back on sale first, then the refund. The refund names the original charge by its // key, so if it fails, a retry job can send it again and the gateway applies it once. Result cancel(String bookingId) { Booking b = bookings.get(bookingId); if (b == null) return Result.NO_SUCH_BOOKING; Instant now = clock.get(); Result r = shows.get(b.show.id()).cancel(b, now); if (r != Result.OK) return r; long amount = refunds.refundFor(b, now); if (amount > 0) payments.refund(b.paymentKey, amount); return Result.OK; }
// At least one seat, at most MAX_SEATS, no seat twice, and every seat in this screen. private static boolean valid(Show show, List<String> seatIds) { return !seatIds.isEmpty() && seatIds.size() <= MAX_SEATS && new HashSet<>(seatIds).size() == seatIds.size() && seatIds.stream().allMatch(s -> show.screen().seat(s).isPresent()); }}# A confirmed purchase. Not frozen, because it can be cancelled.@dataclass(eq=False)class Booking: id: str show: Show user_id: str seat_ids: tuple[str, ...] price: int payment_key: str # the charge's key, which a refund refers back to cancelled: bool = False
# How much comes back depends on how close to the show the customer cancels. Cinemas set# this, and change it, so it's a policy the service asks.class RefundPolicy(Protocol): def refund_for(self, b: Booking, now: datetime) -> int: ... # in cents; 0 means no refund
# Full refund a day or more before the show, half from two hours before, nothing after that.class StandardRefunds: def refund_for(self, b: Booking, now: datetime) -> int: hours_before = (b.show.starts_at - now) / timedelta(hours=1) if hours_before >= 24: return b.price if hours_before >= 2: return b.price // 2 return 0
# What a request gets back, so the app can tell the customer what happened.class Result(Enum): OK = auto() INVALID_SEATS = auto() SEAT_TAKEN = auto() NO_SUCH_HOLD = auto() HOLD_EXPIRED = auto() PAYMENT_IN_PROGRESS = auto() PAYMENT_DECLINED = auto() NO_SUCH_BOOKING = auto() ALREADY_CANCELLED = auto() SHOW_STARTED = auto()
class ShowSeats: def __init__(self, show: Show): self.show = show self._held: dict[str, SeatHold] = {} self._sold: dict[str, str] = {}
def is_free(self, seat_id: str, now: datetime) -> bool: if seat_id in self._sold: return False h = self._held.get(seat_id) return h is None or h.expired(now)
def hold(self, h: SeatHold, now: datetime) -> Result: if not all(self.is_free(s, now) for s in h.seat_ids): return Result.SEAT_TAKEN for s in h.seat_ids: self._held[s] = h return Result.OK
def is_live(self, h: SeatHold, now: datetime) -> bool: return not h.expired(now) and all(self._held.get(s) is h for s in h.seat_ids)
def pin(self, h: SeatHold, now: datetime) -> Result: if h.paying: return Result.PAYMENT_IN_PROGRESS if not self.is_live(h, now): return Result.HOLD_EXPIRED h.paying = True return Result.OK
def unpin(self, h: SeatHold) -> None: h.paying = False
def sell(self, h: SeatHold, booking_id: str) -> None: for s in h.seat_ids: self._held.pop(s, None) self._sold[s] = booking_id
def release(self, h: SeatHold) -> bool: if h.paying: return False for s in h.seat_ids: if self._held.get(s) is h: del self._held[s] return True
# Marks the booking cancelled and puts its seats back on sale, as one step: a second # cancel finds it already done, so the customer is refunded once. def cancel(self, b: Booking, now: datetime) -> Result: if b.cancelled: return Result.ALREADY_CANCELLED if now >= self.show.starts_at: return Result.SHOW_STARTED b.cancelled = True for s in b.seat_ids: if self._sold.get(s) == b.id: del self._sold[s] return Result.OK
def available(self, now: datetime) -> list[str]: return [s.id for s in self.show.screen.seats if self.is_free(s.id, now)]
# The front door. It checks requests, prices seats, takes payment, and hands the seat changes# to each show's ShowSeats.class BookingService: def __init__(self, pricing: Pricing, refunds: RefundPolicy, payments: PaymentGateway, ids: Callable[[], str], clock: Callable[[], datetime]): self._shows: dict[str, ShowSeats] = {} self._holds: dict[str, SeatHold] = {} self._bookings: dict[str, Booking] = {} self._pricing = pricing self._refunds = refunds self._payments = payments self._ids = ids self._clock = clock
def add_show(self, s: Show) -> None: self._shows[s.id] = ShowSeats(s)
def available_seats(self, show_id: str) -> list[str]: return self._shows[show_id].available(self._clock())
# Prices the seats now and keeps that price in the hold, then tries to take every seat. def hold(self, user_id: str, show_id: str, seat_ids: list[str]) -> tuple[Result, SeatHold | None]: seats = self._shows.get(show_id) if seats is None or not _valid(seats.show, seat_ids): return Result.INVALID_SEATS, None now = self._clock() price = sum(self._pricing.price_of(seats.show, seats.show.screen.seat(s)) for s in seat_ids) h = SeatHold(self._ids(), show_id, user_id, tuple(seat_ids), now + HOLD_FOR, price) r = seats.hold(h, now) if r is not Result.OK: return r, None self._holds[h.id] = h return Result.OK, h
# Pin the hold, charge the card, then sell the seats. The hold's id is the payment key, so a # retried confirm can't charge twice. def confirm(self, hold_id: str, card: str) -> tuple[Result, Booking | None]: h = self._holds.get(hold_id) if h is None: return Result.NO_SUCH_HOLD, None seats = self._shows[h.show_id] pinned = seats.pin(h, self._clock()) if pinned is not Result.OK: return pinned, None if not self._payments.charge(h.id, h.price, card): seats.unpin(h) return Result.PAYMENT_DECLINED, None b = Booking(self._ids(), seats.show, h.user_id, h.seat_ids, h.price, h.id) seats.sell(h, b.id) self._holds.pop(hold_id, None) self._bookings[b.id] = b return Result.OK, b
# The customer gave up. A hold being paid for is not released. def release(self, hold_id: str) -> None: h = self._holds.get(hold_id) if h is not None and self._shows[h.show_id].release(h): self._holds.pop(hold_id, None)
# Seats go back on sale first, then the refund. The refund names the original charge by # its key, so if it fails, a retry job can send it again and the gateway applies it once. def cancel(self, booking_id: str) -> Result: b = self._bookings.get(booking_id) if b is None: return Result.NO_SUCH_BOOKING now = self._clock() r = self._shows[b.show.id].cancel(b, now) if r is not Result.OK: return r amount = self._refunds.refund_for(b, now) if amount > 0: self._payments.refund(b.payment_key, amount) return Result.OK// A confirmed purchase. A class rather than a record because it can be cancelled.public sealed class Booking(string id, Show show, string userId, IReadOnlyList<string> seatIds, long price, string paymentKey){ public string Id { get; } = id; public Show Show { get; } = show; public string UserId { get; } = userId; public IReadOnlyList<string> SeatIds { get; } = seatIds; public long Price { get; } = price; public string PaymentKey { get; } = paymentKey; // the charge's key, which a refund refers back to public bool Cancelled { get; set; }}
// How much comes back depends on how close to the show the customer cancels. Cinemas set// this, and change it, so it's a policy the service asks.public interface IRefundPolicy{ long RefundFor(Booking b, DateTimeOffset now); // in cents; 0 means no refund}
// Full refund a day or more before the show, half from two hours before, nothing after that.public sealed class StandardRefunds : IRefundPolicy{ public long RefundFor(Booking b, DateTimeOffset now) { var before = b.Show.StartsAt - now; if (before >= TimeSpan.FromHours(24)) return b.Price; if (before >= TimeSpan.FromHours(2)) return b.Price / 2; return 0; }}
// What a request gets back, so the app can tell the customer what happened.public enum Result{ Ok, InvalidSeats, SeatTaken, NoSuchHold, HoldExpired, PaymentInProgress, PaymentDeclined, NoSuchBooking, AlreadyCancelled, ShowStarted,}
public sealed class ShowSeats(Show show){ private readonly Dictionary<string, SeatHold> _held = []; private readonly Dictionary<string, string> _sold = [];
public Show Show { get; } = show;
public bool IsFree(string seatId, DateTimeOffset now) => !_sold.ContainsKey(seatId) && (!_held.TryGetValue(seatId, out var h) || h.Expired(now));
public Result Hold(SeatHold h, DateTimeOffset now) { if (!h.SeatIds.All(s => IsFree(s, now))) return Result.SeatTaken; foreach (var s in h.SeatIds) _held[s] = h; return Result.Ok; }
public bool IsLive(SeatHold h, DateTimeOffset now) => !h.Expired(now) && h.SeatIds.All(s => _held.GetValueOrDefault(s) == h);
public Result Pin(SeatHold h, DateTimeOffset now) { if (h.Paying) return Result.PaymentInProgress; if (!IsLive(h, now)) return Result.HoldExpired; h.Paying = true; return Result.Ok; }
public void Unpin(SeatHold h) => h.Paying = false;
public void Sell(SeatHold h, string bookingId) { foreach (var s in h.SeatIds) { _held.Remove(s); _sold[s] = bookingId; } }
public bool Release(SeatHold h) { if (h.Paying) return false; foreach (var s in h.SeatIds) if (_held.GetValueOrDefault(s) == h) _held.Remove(s); return true; }
// Marks the booking cancelled and puts its seats back on sale, as one step: a second cancel // finds it already done, so the customer is refunded once. public Result Cancel(Booking b, DateTimeOffset now) { if (b.Cancelled) return Result.AlreadyCancelled; if (now >= Show.StartsAt) return Result.ShowStarted; b.Cancelled = true; foreach (var s in b.SeatIds) if (_sold.GetValueOrDefault(s) == b.Id) _sold.Remove(s); return Result.Ok; }
public List<string> Available(DateTimeOffset now) => Show.Screen.Seats.Select(s => s.Id).Where(s => IsFree(s, now)).ToList();}
// The front door. It checks requests, prices seats, takes payment, and hands the seat changes to// each show's ShowSeats.public sealed class BookingService(IPricing pricing, IRefundPolicy refunds, IPaymentGateway payments, Func<string> ids, Func<DateTimeOffset> clock){ public static readonly TimeSpan HoldFor = TimeSpan.FromMinutes(5); // time to pay before seats go back public const int MaxSeats = 10; // per booking, so one person can't hold a whole row
private readonly ConcurrentDictionary<string, ShowSeats> _shows = new(); private readonly ConcurrentDictionary<string, SeatHold> _holds = new(); private readonly ConcurrentDictionary<string, Booking> _bookings = new();
public void AddShow(Show s) => _shows[s.Id] = new ShowSeats(s);
public List<string> AvailableSeats(string showId) => _shows[showId].Available(clock());
// Prices the seats now and keeps that price in the hold, then tries to take every seat. public (Result, SeatHold?) Hold(string userId, string showId, IReadOnlyList<string> seatIds) { if (!_shows.TryGetValue(showId, out var seats) || !Valid(seats.Show, seatIds)) return (Result.InvalidSeats, null); var now = clock(); var price = seatIds.Sum(s => pricing.PriceOf(seats.Show, seats.Show.Screen.Seat(s)!)); var h = new SeatHold(ids(), showId, userId, seatIds.ToList(), now + HoldFor, price); var r = seats.Hold(h, now); if (r != Result.Ok) return (r, null); _holds[h.Id] = h; return (Result.Ok, h); }
// Pin the hold, charge the card, then sell the seats. The hold's id is the payment key, so a // retried Confirm can't charge twice. public (Result, Booking?) Confirm(string holdId, string card) { if (!_holds.TryGetValue(holdId, out var h)) return (Result.NoSuchHold, null); var seats = _shows[h.ShowId]; var pinned = seats.Pin(h, clock()); if (pinned != Result.Ok) return (pinned, null); if (!payments.Charge(h.Id, h.Price, card)) { seats.Unpin(h); return (Result.PaymentDeclined, null); } var b = new Booking(ids(), seats.Show, h.UserId, h.SeatIds, h.Price, h.Id); seats.Sell(h, b.Id); _holds.TryRemove(holdId, out _); _bookings[b.Id] = b; return (Result.Ok, b); }
// The customer gave up. A hold being paid for is not released. public void Release(string holdId) { if (_holds.TryGetValue(holdId, out var h) && _shows[h.ShowId].Release(h)) _holds.TryRemove(holdId, out _); }
// Seats go back on sale first, then the refund. The refund names the original charge by its // key, so if it fails, a retry job can send it again and the gateway applies it once. public Result Cancel(string bookingId) { if (!_bookings.TryGetValue(bookingId, out var b)) return Result.NoSuchBooking; var now = clock(); var r = _shows[b.Show.Id].Cancel(b, now); if (r != Result.Ok) return r; var amount = refunds.RefundFor(b, now); if (amount > 0) payments.Refund(b.PaymentKey, amount); return Result.Ok; }
// At least one seat, at most MaxSeats, no seat twice, and every seat in this screen. private static bool Valid(Show show, IReadOnlyList<string> seatIds) => seatIds.Count > 0 && seatIds.Count <= MaxSeats && seatIds.Distinct().Count() == seatIds.Count && seatIds.All(s => show.Screen.Seat(s) is not null);}// Booking is a confirmed purchase, which can be cancelled.type Booking struct { ID string Show Show UserID string SeatIDs []string Price int64 PaymentKey string // the charge's key, which a refund refers back to cancelled bool}
// RefundPolicy decides how much comes back, which depends on how close to the show the// customer cancels. Cinemas set this, and change it, so it's a policy the service asks.type RefundPolicy interface { RefundFor(b *Booking, now time.Time) int64 // in cents; 0 means no refund}
// StandardRefunds gives a full refund a day or more before the show, half from two hours before,// and nothing after that.type StandardRefunds struct{}
func (StandardRefunds) RefundFor(b *Booking, now time.Time) int64 { before := b.Show.StartsAt.Sub(now) switch { case before >= 24*time.Hour: return b.Price case before >= 2*time.Hour: return b.Price / 2 default: return 0 }}
const ( OK Result = iota InvalidSeats SeatTaken NoSuchHold HoldExpired PaymentInProgress PaymentDeclined NoSuchBooking AlreadyCancelled ShowStarted)
// Cancel marks the booking cancelled and puts its seats back on sale, as one step: a second// cancel finds it already done, so the customer is refunded once.func (ss *ShowSeats) Cancel(bk *Booking, now time.Time) Result { if bk.cancelled { return AlreadyCancelled } if !now.Before(ss.show.StartsAt) { return ShowStarted } bk.cancelled = true for _, s := range bk.SeatIDs { if ss.sold[s] == bk.ID { delete(ss.sold, s) } } return OK}
// BookingService is the front door. It checks requests, prices seats, takes payment, and hands// the seat changes to each show's ShowSeats.type BookingService struct { mu sync.Mutex shows map[string]*ShowSeats holds map[string]*SeatHold bookings map[string]*Booking pricing Pricing refunds RefundPolicy payments PaymentGateway ids func() string clock func() time.Time}
func NewBookingService(pricing Pricing, refunds RefundPolicy, payments PaymentGateway, ids func() string, clock func() time.Time) *BookingService { return &BookingService{shows: map[string]*ShowSeats{}, holds: map[string]*SeatHold{}, bookings: map[string]*Booking{}, pricing: pricing, refunds: refunds, payments: payments, ids: ids, clock: clock}}
func (b *BookingService) AddShow(s Show) { b.mu.Lock() defer b.mu.Unlock() b.shows[s.ID] = NewShowSeats(s)}
func (b *BookingService) seats(showID string) *ShowSeats { b.mu.Lock() defer b.mu.Unlock() return b.shows[showID]}
func (b *BookingService) takeHold(id string, remove bool) *SeatHold { b.mu.Lock() defer b.mu.Unlock() h := b.holds[id] if remove { delete(b.holds, id) } return h}
func (b *BookingService) AvailableSeats(showID string) []string { return b.seats(showID).Available(b.clock())}
// Hold prices the seats now and keeps that price in the hold, then tries to take every seat.func (b *BookingService) Hold(userID, showID string, seatIDs []string) (Result, *SeatHold) { ss := b.seats(showID) if ss == nil || !valid(ss.show, seatIDs) { return InvalidSeats, nil } now := b.clock() var price int64 for _, id := range seatIDs { seat, _ := ss.show.Screen.Seat(id) price += b.pricing.PriceOf(ss.show, seat) } h := &SeatHold{ID: b.ids(), ShowID: showID, UserID: userID, SeatIDs: slices.Clone(seatIDs), ExpiresAt: now.Add(holdFor), Price: price} if r := ss.Hold(h, now); r != OK { return r, nil } b.mu.Lock() b.holds[h.ID] = h b.mu.Unlock() return OK, h}
// Confirm pins the hold, charges the card, then sells the seats. The hold's ID is the payment// key, so a retried Confirm can't charge twice.func (b *BookingService) Confirm(holdID, card string) (Result, *Booking) { h := b.takeHold(holdID, false) if h == nil { return NoSuchHold, nil } ss := b.seats(h.ShowID) if r := ss.Pin(h, b.clock()); r != OK { return r, nil } if !b.payments.Charge(h.ID, h.Price, card) { ss.Unpin(h) return PaymentDeclined, nil } bk := &Booking{ID: b.ids(), Show: ss.show, UserID: h.UserID, SeatIDs: h.SeatIDs, Price: h.Price, PaymentKey: h.ID} ss.Sell(h, bk.ID) b.mu.Lock() delete(b.holds, holdID) b.bookings[bk.ID] = bk b.mu.Unlock() return OK, bk}
// Release is for a customer who gave up. A hold being paid for is not released.func (b *BookingService) Release(holdID string) { h := b.takeHold(holdID, false) if h != nil && b.seats(h.ShowID).Release(h) { b.takeHold(holdID, true) }}
// Cancel puts the seats back on sale first, then refunds. The refund names the original// charge by its key, so if it fails, a retry job can send it again and the gateway applies// it once.func (b *BookingService) Cancel(bookingID string) Result { b.mu.Lock() bk := b.bookings[bookingID] b.mu.Unlock() if bk == nil { return NoSuchBooking } now := b.clock() if r := b.seats(bk.Show.ID).Cancel(bk, now); r != OK { return r } if amount := b.refunds.RefundFor(bk, now); amount > 0 { b.payments.Refund(bk.PaymentKey, amount) } return OK}// A confirmed purchase. cancelled is mutable because a booking can be cancelled.interface Booking { readonly id: string; readonly show: Show; readonly userId: string; readonly seatIds: readonly string[]; readonly price: number; readonly paymentKey: string; // the charge's key, which a refund refers back to cancelled: boolean;}
// How much comes back depends on how close to the show the customer cancels. Cinemas set// this, and change it, so it's a policy the service asks.interface RefundPolicy { refundFor(b: Booking, now: Instant): number; // in cents; 0 means no refund}
const HOUR_MS = 3_600_000;
// Full refund a day or more before the show, half from two hours before, nothing after that.class StandardRefunds implements RefundPolicy { refundFor(b: Booking, now: Instant): number { const before = b.show.startsAt - now; if (before >= 24 * HOUR_MS) return b.price; if (before >= 2 * HOUR_MS) return Math.floor(b.price / 2); return 0; }}
// What a request gets back, so the app can tell the customer what happened.type Result = | 'ok' | 'invalid-seats' | 'seat-taken' | 'no-such-hold' | 'hold-expired' | 'payment-in-progress' | 'payment-declined' | 'no-such-booking' | 'already-cancelled' | 'show-started';
// Who has which seat, for one show. Each show is its own small inventory: no request ever needs// seats from two shows at once.class ShowSeats { private readonly held = new Map<string, SeatHold>(); // seat -> the hold keeping it private readonly sold = new Map<string, string>(); // seat -> the booking that bought it
constructor(readonly show: Show) {}
// A seat is free if nobody bought it and any hold on it has run out. Expired holds need no // cleanup job: they simply stop counting. isFree(seatId: string, now: Instant): boolean { if (this.sold.has(seatId)) return false; const h = this.held.get(seatId); return !h || h.expired(now); }
// All seats or none: check every seat first, then take them all. hold(h: SeatHold, now: Instant): Result { if (!h.seatIds.every((s) => this.isFree(s, now))) return 'seat-taken'; for (const s of h.seatIds) this.held.set(s, h); return 'ok'; }
// Still holding every seat. Another customer may have taken one after this hold expired. isLive(h: SeatHold, now: Instant): boolean { return !h.expired(now) && h.seatIds.every((s) => this.held.get(s) === h); }
// Marks the hold as being paid for, so it can't expire while the card is charged. pin(h: SeatHold, now: Instant): Result { if (h.paying) return 'payment-in-progress'; if (!this.isLive(h, now)) return 'hold-expired'; h.paying = true; return 'ok'; }
unpin(h: SeatHold): void { h.paying = false; }
sell(h: SeatHold, bookingId: string): void { for (const s of h.seatIds) { this.held.delete(s); this.sold.set(s, bookingId); } }
release(h: SeatHold): boolean { if (h.paying) return false; for (const s of h.seatIds) if (this.held.get(s) === h) this.held.delete(s); return true; }
// Marks the booking cancelled and puts its seats back on sale, as one step: a second cancel // finds it already done, so the customer is refunded once. cancel(b: Booking, now: Instant): Result { if (b.cancelled) return 'already-cancelled'; if (now >= this.show.startsAt) return 'show-started'; b.cancelled = true; for (const s of b.seatIds) if (this.sold.get(s) === b.id) this.sold.delete(s); return 'ok'; }
available(now: Instant): string[] { return this.show.screen.seats.map((s) => s.id).filter((s) => this.isFree(s, now)); }}
// The front door. It checks requests, prices seats, takes payment, and hands the seat changes to// each show's ShowSeats.class BookingService { private readonly shows = new Map<string, ShowSeats>(); private readonly holds = new Map<string, SeatHold>(); private readonly bookings = new Map<string, Booking>();
constructor( private readonly pricing: Pricing, private readonly refunds: RefundPolicy, private readonly payments: PaymentGateway, private readonly ids: () => string, private readonly clock: () => Instant, ) {}
addShow(s: Show): void { this.shows.set(s.id, new ShowSeats(s)); }
availableSeats(showId: string): string[] { return this.shows.get(showId)!.available(this.clock()); }
// Prices the seats now and keeps that price in the hold, then tries to take every seat. hold(userId: string, showId: string, seatIds: string[]): { result: Result; hold?: SeatHold } { const seats = this.shows.get(showId); if (!seats || !valid(seats.show, seatIds)) return { result: 'invalid-seats' }; const now = this.clock(); const price = seatIds.reduce((sum, s) => sum + this.pricing.priceOf(seats.show, seatOf(seats.show.screen, s)!), 0); const h = new SeatHold(this.ids(), showId, userId, [...seatIds], now + HOLD_FOR_MS, price); const result = seats.hold(h, now); if (result !== 'ok') return { result }; this.holds.set(h.id, h); return { result, hold: h }; }
// Pin the hold, charge the card, then sell the seats. The hold's id is the payment key, so a // retried confirm can't charge twice. async confirm(holdId: string, card: string): Promise<{ result: Result; booking?: Booking }> { const h = this.holds.get(holdId); if (!h) return { result: 'no-such-hold' }; const seats = this.shows.get(h.showId)!; const pinned = seats.pin(h, this.clock()); if (pinned !== 'ok') return { result: pinned }; if (!(await this.payments.charge(h.id, h.price, card))) { seats.unpin(h); return { result: 'payment-declined' }; } const booking: Booking = { id: this.ids(), show: seats.show, userId: h.userId, seatIds: h.seatIds, price: h.price, paymentKey: h.id, cancelled: false, }; seats.sell(h, booking.id); this.holds.delete(holdId); this.bookings.set(booking.id, booking); return { result: 'ok', booking }; }
// The customer gave up. A hold being paid for is not released. release(holdId: string): void { const h = this.holds.get(holdId); if (h && this.shows.get(h.showId)!.release(h)) this.holds.delete(holdId); }
// Seats go back on sale first, then the refund. The refund names the original charge by its // key, so if it fails, a retry job can send it again and the gateway applies it once. async cancel(bookingId: string): Promise<Result> { const b = this.bookings.get(bookingId); if (!b) return 'no-such-booking'; const now = this.clock(); const result = this.shows.get(b.show.id)!.cancel(b, now); if (result !== 'ok') return result; const amount = this.refunds.refundFor(b, now); if (amount > 0) await this.payments.refund(b.paymentKey, amount); return 'ok'; }}This is enough for a mid-level answer. Seat state belongs to a show. Holds have a deadline. And the card is charged before the seats are sold. The rest of the page is what makes it a senior answer.
The full design
Section titled “The full design”Here is everything together. Each class is here because a requirement needed it.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// The complete design, built from every stage on this page.
import java.util.*;import java.util.concurrent.*;import java.util.function.*;import java.time.*;
// A film: what people search for, with its running time.record Movie(String id, String title, int minutes) {}
// Premium seats cost more. The type is part of the seat, so pricing can read it.enum SeatType { REGULAR, PREMIUM }
// A physical seat in an auditorium, like "C7". It exists whether or not anything is showing.record Seat(String id, SeatType type) {}
// An auditorium and its fixed seating plan.record Screen(String id, List<Seat> seats) { Optional<Seat> seat(String seatId) { return seats.stream().filter(s -> s.id().equals(seatId)).findFirst(); }}
// One screening: a movie in a screen at a time. Seats are sold per show, so "is C7 free?"// only has an answer for a show, never for the seat alone.record Show(String id, Movie movie, Screen screen, Instant startsAt) {}
// A confirmed purchase. A class rather than a record because it can be cancelled.class Booking { final String id; final Show show; final String userId; final List<String> seatIds; final long price; final String paymentKey; // the charge's key, which a refund refers back to boolean cancelled;
Booking(String id, Show show, String userId, List<String> seatIds, long price, String paymentKey) { this.id = id; this.show = show; this.userId = userId; this.seatIds = seatIds; this.price = price; this.paymentKey = paymentKey; }}
// The front door. It checks requests, prices seats, takes payment, and hands the seat changes to// each show's ShowSeats.class BookingService { static final Duration HOLD_FOR = Duration.ofMinutes(5); // time to pay before seats go back static final int MAX_SEATS = 10; // per booking, so one person can't hold a whole row
private final Map<String, ShowSeats> shows = new ConcurrentHashMap<>(); private final Map<String, SeatHold> holds = new ConcurrentHashMap<>(); private final Map<String, Booking> bookings = new ConcurrentHashMap<>(); private final Pricing pricing; private final RefundPolicy refunds; private final PaymentGateway payments; private final Supplier<String> ids; private final Supplier<Instant> clock;
BookingService(Pricing pricing, RefundPolicy refunds, PaymentGateway payments, Supplier<String> ids, Supplier<Instant> clock) { this.pricing = pricing; this.refunds = refunds; this.payments = payments; this.ids = ids; this.clock = clock; }
void addShow(Show s) { shows.put(s.id(), new ShowSeats(s)); }
List<String> availableSeats(String showId) { return shows.get(showId).available(clock.get()); }
// Prices the seats now and keeps that price in the hold, then tries to take every seat. HoldResult hold(String userId, String showId, List<String> seatIds) { ShowSeats seats = shows.get(showId); if (seats == null || !valid(seats.show, seatIds)) return new HoldResult(Result.INVALID_SEATS, null); Instant now = clock.get(); long price = seatIds.stream().mapToLong(s -> pricing.priceOf(seats.show, seats.show.screen().seat(s).get())).sum(); SeatHold h = new SeatHold(ids.get(), showId, userId, List.copyOf(seatIds), now.plus(HOLD_FOR), price); Result r = seats.hold(h, now); if (r != Result.OK) return new HoldResult(r, null); holds.put(h.id, h); return new HoldResult(Result.OK, h); }
// Pin the hold, charge the card, then sell the seats. The hold's id is the payment key, so a // retried confirm can't charge twice. BookingResult confirm(String holdId, String card) { SeatHold h = holds.get(holdId); if (h == null) return new BookingResult(Result.NO_SUCH_HOLD, null); ShowSeats seats = shows.get(h.showId); Result pinned = seats.pin(h, clock.get()); if (pinned != Result.OK) return new BookingResult(pinned, null); if (!payments.charge(h.id, h.price, card)) { seats.unpin(h); return new BookingResult(Result.PAYMENT_DECLINED, null); } Booking b = new Booking(ids.get(), seats.show, h.userId, h.seatIds, h.price, h.id); seats.sell(h, b.id); holds.remove(holdId); bookings.put(b.id, b); return new BookingResult(Result.OK, b); }
// The customer gave up. A hold being paid for is not released. void release(String holdId) { SeatHold h = holds.get(holdId); if (h != null && shows.get(h.showId).release(h)) holds.remove(holdId); }
// Seats go back on sale first, then the refund. The refund names the original charge by its // key, so if it fails, a retry job can send it again and the gateway applies it once. Result cancel(String bookingId) { Booking b = bookings.get(bookingId); if (b == null) return Result.NO_SUCH_BOOKING; Instant now = clock.get(); Result r = shows.get(b.show.id()).cancel(b, now); if (r != Result.OK) return r; long amount = refunds.refundFor(b, now); if (amount > 0) payments.refund(b.paymentKey, amount); return Result.OK; }
// At least one seat, at most MAX_SEATS, no seat twice, and every seat in this screen. private static boolean valid(Show show, List<String> seatIds) { return !seatIds.isEmpty() && seatIds.size() <= MAX_SEATS && new HashSet<>(seatIds).size() == seatIds.size() && seatIds.stream().allMatch(s -> show.screen().seat(s).isPresent()); }}
// Seats kept for one customer for a few minutes while they pay. Without it, a seat could be// sold to someone else between choosing it and paying.class SeatHold { final String id; final String showId; final String userId; final List<String> seatIds; final Instant expiresAt; final long price; // quoted at hold time; a price change mid-payment doesn't change the deal boolean paying; // set while the card is charged; a hold being paid for doesn't expire
SeatHold(String id, String showId, String userId, List<String> seatIds, Instant expiresAt, long price) { this.id = id; this.showId = showId; this.userId = userId; this.seatIds = seatIds; this.expiresAt = expiresAt; this.price = price; }
boolean expired(Instant now) { return !paying && !now.isBefore(expiresAt); }}
// What a request gets back, so the app can tell the customer what happened.enum Result { OK, INVALID_SEATS, SEAT_TAKEN, NO_SUCH_HOLD, HOLD_EXPIRED, PAYMENT_IN_PROGRESS, PAYMENT_DECLINED, NO_SUCH_BOOKING, ALREADY_CANCELLED, SHOW_STARTED}
// What hold() and confirm() return: a result, plus the new hold or booking when it is OK.record HoldResult(Result result, SeatHold hold) {}
record BookingResult(Result result, Booking booking) {}
// Two people click C7 at the same moment. Without a lock, both holds run isFree("C7"), both// see true, and both take it. The fix is to make "check every seat, then take every seat" one// step for each show. Every method here is synchronized on this show's seats, so requests for// the same show take turns and requests for different shows never wait on each other.//// What stays outside the lock is the slow part. confirm() pins under the lock, charges the// card with no lock held, and sells under the lock again; the pin is what keeps the seats safe// in between.class ShowSeats { final Show show; private final Map<String, SeatHold> held = new HashMap<>(); // seat -> the hold keeping it private final Map<String, String> sold = new HashMap<>(); // seat -> the booking that bought it
ShowSeats(Show show) { this.show = show; }
// A seat is free if nobody bought it and any hold on it has run out. Expired holds need no // cleanup job: they simply stop counting. synchronized boolean isFree(String seatId, Instant now) { if (sold.containsKey(seatId)) return false; SeatHold h = held.get(seatId); return h == null || h.expired(now); }
// All seats or none: check every seat first, then take them all. synchronized Result hold(SeatHold h, Instant now) { for (String s : h.seatIds) if (!isFree(s, now)) return Result.SEAT_TAKEN; for (String s : h.seatIds) held.put(s, h); return Result.OK; }
// Still holding every seat. Another customer may have taken one after this hold expired. synchronized boolean isLive(SeatHold h, Instant now) { return !h.expired(now) && h.seatIds.stream().allMatch(s -> held.get(s) == h); }
// Marks the hold as being paid for, so it can't expire while the card is charged. synchronized Result pin(SeatHold h, Instant now) { if (h.paying) return Result.PAYMENT_IN_PROGRESS; if (!isLive(h, now)) return Result.HOLD_EXPIRED; h.paying = true; return Result.OK; }
synchronized void unpin(SeatHold h) { h.paying = false; }
synchronized void sell(SeatHold h, String bookingId) { for (String s : h.seatIds) { held.remove(s); sold.put(s, bookingId); } }
synchronized boolean release(SeatHold h) { if (h.paying) return false; for (String s : h.seatIds) held.remove(s, h); return true; }
// Marks the booking cancelled and puts its seats back on sale, as one step: a second cancel // finds it already done, so the customer is refunded once. synchronized Result cancel(Booking b, Instant now) { if (b.cancelled) return Result.ALREADY_CANCELLED; if (!now.isBefore(show.startsAt())) return Result.SHOW_STARTED; b.cancelled = true; for (String s : b.seatIds) sold.remove(s, b.id); return Result.OK; }
synchronized List<String> available(Instant now) { return show.screen().seats().stream().map(Seat::id).filter(s -> isFree(s, now)).toList(); }}
// Prices live behind an interface because they change for reasons the booking flow doesn't// care about: matinees, premium formats, holiday weekends.interface Pricing { long priceOf(Show show, Seat seat); // in cents}
// One cinema's prices: $12 regular, $18 premium, and a quarter off shows before 5pm.class StandardPricing implements Pricing { private final ZoneId zone; // the cinema's time zone: "before 5pm" is local time
StandardPricing(ZoneId zone) { this.zone = zone; }
public long priceOf(Show show, Seat seat) { long base = seat.type() == SeatType.PREMIUM ? 1800 : 1200; boolean matinee = show.startsAt().atZone(zone).getHour() < 17; return matinee ? base * 3 / 4 : base; }}
// The card network. Every charge carries a key, so a retry after a timeout is the same charge,// not a second one.interface PaymentGateway { boolean charge(String key, long cents, String card); void refund(String key, long cents);}
// How much comes back depends on how close to the show the customer cancels. Cinemas set// this, and change it, so it's a policy the service asks.interface RefundPolicy { long refundFor(Booking b, Instant now); // in cents; 0 means no refund}
// Full refund a day or more before the show, half from two hours before, nothing after that.class StandardRefunds implements RefundPolicy { public long refundFor(Booking b, Instant now) { long hoursBefore = Duration.between(now, b.show.startsAt()).toHours(); if (hoursBefore >= 24) return b.price; if (hoursBefore >= 2) return b.price / 2; return 0; }}# The complete design, built from every stage on this page.
from __future__ import annotations
import threadingfrom dataclasses import dataclassfrom datetime import datetime, timedeltafrom enum import Enum, autofrom typing import Callable, Protocolfrom zoneinfo import ZoneInfo
# A film: what people search for, with its running time.@dataclass(frozen=True)class Movie: id: str title: str minutes: int
# Premium seats cost more. The type is part of the seat, so pricing can read it.class SeatType(Enum): REGULAR = auto() PREMIUM = auto()
# A physical seat in an auditorium, like "C7". It exists whether or not anything is showing.@dataclass(frozen=True)class Seat: id: str type: SeatType
# An auditorium and its fixed seating plan.@dataclass(frozen=True)class Screen: id: str seats: tuple[Seat, ...]
def seat(self, seat_id: str) -> Seat | None: return next((s for s in self.seats if s.id == seat_id), None)
# One screening: a movie in a screen at a time. Seats are sold per show, so "is C7 free?"# only has an answer for a show, never for the seat alone.@dataclass(frozen=True)class Show: id: str movie: Movie screen: Screen starts_at: datetime
# A confirmed purchase. Not frozen, because it can be cancelled.@dataclass(eq=False)class Booking: id: str show: Show user_id: str seat_ids: tuple[str, ...] price: int payment_key: str # the charge's key, which a refund refers back to cancelled: bool = False
# The front door. It checks requests, prices seats, takes payment, and hands the seat changes# to each show's ShowSeats.class BookingService: def __init__(self, pricing: Pricing, refunds: RefundPolicy, payments: PaymentGateway, ids: Callable[[], str], clock: Callable[[], datetime]): self._shows: dict[str, ShowSeats] = {} self._holds: dict[str, SeatHold] = {} self._bookings: dict[str, Booking] = {} self._pricing = pricing self._refunds = refunds self._payments = payments self._ids = ids self._clock = clock
def add_show(self, s: Show) -> None: self._shows[s.id] = ShowSeats(s)
def available_seats(self, show_id: str) -> list[str]: return self._shows[show_id].available(self._clock())
# Prices the seats now and keeps that price in the hold, then tries to take every seat. def hold(self, user_id: str, show_id: str, seat_ids: list[str]) -> tuple[Result, SeatHold | None]: seats = self._shows.get(show_id) if seats is None or not _valid(seats.show, seat_ids): return Result.INVALID_SEATS, None now = self._clock() price = sum(self._pricing.price_of(seats.show, seats.show.screen.seat(s)) for s in seat_ids) h = SeatHold(self._ids(), show_id, user_id, tuple(seat_ids), now + HOLD_FOR, price) r = seats.hold(h, now) if r is not Result.OK: return r, None self._holds[h.id] = h return Result.OK, h
# Pin the hold, charge the card, then sell the seats. The hold's id is the payment key, so a # retried confirm can't charge twice. def confirm(self, hold_id: str, card: str) -> tuple[Result, Booking | None]: h = self._holds.get(hold_id) if h is None: return Result.NO_SUCH_HOLD, None seats = self._shows[h.show_id] pinned = seats.pin(h, self._clock()) if pinned is not Result.OK: return pinned, None if not self._payments.charge(h.id, h.price, card): seats.unpin(h) return Result.PAYMENT_DECLINED, None b = Booking(self._ids(), seats.show, h.user_id, h.seat_ids, h.price, h.id) seats.sell(h, b.id) self._holds.pop(hold_id, None) self._bookings[b.id] = b return Result.OK, b
# The customer gave up. A hold being paid for is not released. def release(self, hold_id: str) -> None: h = self._holds.get(hold_id) if h is not None and self._shows[h.show_id].release(h): self._holds.pop(hold_id, None)
# Seats go back on sale first, then the refund. The refund names the original charge by # its key, so if it fails, a retry job can send it again and the gateway applies it once. def cancel(self, booking_id: str) -> Result: b = self._bookings.get(booking_id) if b is None: return Result.NO_SUCH_BOOKING now = self._clock() r = self._shows[b.show.id].cancel(b, now) if r is not Result.OK: return r amount = self._refunds.refund_for(b, now) if amount > 0: self._payments.refund(b.payment_key, amount) return Result.OK
# Seats kept for one customer for a few minutes while they pay. Without it, a seat could be# sold to someone else between choosing it and paying.@dataclass(eq=False)class SeatHold: id: str show_id: str user_id: str seat_ids: tuple[str, ...] expires_at: datetime price: int # quoted at hold time; a price change mid-payment doesn't change the deal paying: bool = False # set while the card is charged; a hold being paid for doesn't expire
def expired(self, now: datetime) -> bool: return not self.paying and now >= self.expires_at
# What a request gets back, so the app can tell the customer what happened.class Result(Enum): OK = auto() INVALID_SEATS = auto() SEAT_TAKEN = auto() NO_SUCH_HOLD = auto() HOLD_EXPIRED = auto() PAYMENT_IN_PROGRESS = auto() PAYMENT_DECLINED = auto() NO_SUCH_BOOKING = auto() ALREADY_CANCELLED = auto() SHOW_STARTED = auto()
# Two people click C7 at the same moment. Without a lock, both holds run is_free("C7"), both# see True, and both take it. The fix is to make "check every seat, then take every seat" one# step for each show. Every method here runs under this show's lock, so requests for the same# show take turns and requests for different shows never wait on each other.## What stays outside the lock is the slow part. confirm() pins under the lock, charges the# card with no lock held, and sells under the lock again; the pin keeps the seats safe in# between.class ShowSeats: def __init__(self, show: Show): self.show = show self._held: dict[str, SeatHold] = {} # seat -> the hold keeping it self._sold: dict[str, str] = {} # seat -> the booking that bought it self._lock = threading.RLock() # re-entrant: hold() calls is_free() while holding it
# A seat is free if nobody bought it and any hold on it has run out. Expired holds need no # cleanup job: they simply stop counting. def is_free(self, seat_id: str, now: datetime) -> bool: with self._lock: if seat_id in self._sold: return False h = self._held.get(seat_id) return h is None or h.expired(now)
# All seats or none: check every seat first, then take them all. def hold(self, h: SeatHold, now: datetime) -> Result: with self._lock: if not all(self.is_free(s, now) for s in h.seat_ids): return Result.SEAT_TAKEN for s in h.seat_ids: self._held[s] = h return Result.OK
# Still holding every seat. Another customer may have taken one after this hold expired. def is_live(self, h: SeatHold, now: datetime) -> bool: with self._lock: return not h.expired(now) and all(self._held.get(s) is h for s in h.seat_ids)
# Marks the hold as being paid for, so it can't expire while the card is charged. def pin(self, h: SeatHold, now: datetime) -> Result: with self._lock: if h.paying: return Result.PAYMENT_IN_PROGRESS if not self.is_live(h, now): return Result.HOLD_EXPIRED h.paying = True return Result.OK
def unpin(self, h: SeatHold) -> None: with self._lock: h.paying = False
def sell(self, h: SeatHold, booking_id: str) -> None: with self._lock: for s in h.seat_ids: self._held.pop(s, None) self._sold[s] = booking_id
def release(self, h: SeatHold) -> bool: with self._lock: if h.paying: return False for s in h.seat_ids: if self._held.get(s) is h: del self._held[s] return True
# Marks the booking cancelled and puts its seats back on sale, as one step: a second cancel # finds it already done, so the customer is refunded once. def cancel(self, b: Booking, now: datetime) -> Result: with self._lock: if b.cancelled: return Result.ALREADY_CANCELLED if now >= self.show.starts_at: return Result.SHOW_STARTED b.cancelled = True for s in b.seat_ids: if self._sold.get(s) == b.id: del self._sold[s] return Result.OK
def available(self, now: datetime) -> list[str]: with self._lock: return [s.id for s in self.show.screen.seats if self.is_free(s.id, now)]
HOLD_FOR = timedelta(minutes=5) # time to pay before seats go back
MAX_SEATS = 10 # per booking, so one person can't hold a whole row
# Some seats, not too many, no seat twice, and every seat in this screen.def _valid(show: Show, seat_ids: list[str]) -> bool: return (0 < len(seat_ids) <= MAX_SEATS and len(set(seat_ids)) == len(seat_ids) and all(show.screen.seat(s) is not None for s in seat_ids))
# Prices live behind an interface because they change for reasons the booking flow doesn't# care about: matinees, premium formats, holiday weekends.class Pricing(Protocol): def price_of(self, show: Show, seat: Seat) -> int: ... # in cents
# One cinema's prices: $12 regular, $18 premium, and a quarter off shows before 5pm.class StandardPricing: def __init__(self, zone: ZoneInfo): self._zone = zone # the cinema's time zone: "before 5pm" is local time
def price_of(self, show: Show, seat: Seat) -> int: base = 1800 if seat.type is SeatType.PREMIUM else 1200 matinee = show.starts_at.astimezone(self._zone).hour < 17 return base * 3 // 4 if matinee else base
# The card network. Every charge carries a key, so a retry after a timeout is the same charge,# not a second one.class PaymentGateway(Protocol): def charge(self, key: str, cents: int, card: str) -> bool: ... def refund(self, key: str, cents: int) -> None: ...
# How much comes back depends on how close to the show the customer cancels. Cinemas set# this, and change it, so it's a policy the service asks.class RefundPolicy(Protocol): def refund_for(self, b: Booking, now: datetime) -> int: ... # in cents; 0 means no refund
# Full refund a day or more before the show, half from two hours before, nothing after that.class StandardRefunds: def refund_for(self, b: Booking, now: datetime) -> int: hours_before = (b.show.starts_at - now) / timedelta(hours=1) if hours_before >= 24: return b.price if hours_before >= 2: return b.price // 2 return 0// The complete design, built from every stage on this page.
using System.Collections.Concurrent;
// A film: what people search for, with its running time.public sealed record Movie(string Id, string Title, int Minutes);
// Premium seats cost more. The type is part of the seat, so pricing can read it.public enum SeatType { Regular, Premium }
// A physical seat in an auditorium, like "C7". It exists whether or not anything is showing.public sealed record Seat(string Id, SeatType Type);
// An auditorium and its fixed seating plan.public sealed record Screen(string Id, IReadOnlyList<Seat> Seats){ public Seat? Seat(string seatId) => Seats.FirstOrDefault(s => s.Id == seatId);}
// One screening: a movie in a screen at a time. Seats are sold per show, so "is C7 free?"// only has an answer for a show, never for the seat alone.public sealed record Show(string Id, Movie Movie, Screen Screen, DateTimeOffset StartsAt);
// A confirmed purchase. A class rather than a record because it can be cancelled.public sealed class Booking(string id, Show show, string userId, IReadOnlyList<string> seatIds, long price, string paymentKey){ public string Id { get; } = id; public Show Show { get; } = show; public string UserId { get; } = userId; public IReadOnlyList<string> SeatIds { get; } = seatIds; public long Price { get; } = price; public string PaymentKey { get; } = paymentKey; // the charge's key, which a refund refers back to public bool Cancelled { get; set; }}
// The front door. It checks requests, prices seats, takes payment, and hands the seat changes to// each show's ShowSeats.public sealed class BookingService(IPricing pricing, IRefundPolicy refunds, IPaymentGateway payments, Func<string> ids, Func<DateTimeOffset> clock){ public static readonly TimeSpan HoldFor = TimeSpan.FromMinutes(5); // time to pay before seats go back public const int MaxSeats = 10; // per booking, so one person can't hold a whole row
private readonly ConcurrentDictionary<string, ShowSeats> _shows = new(); private readonly ConcurrentDictionary<string, SeatHold> _holds = new(); private readonly ConcurrentDictionary<string, Booking> _bookings = new();
public void AddShow(Show s) => _shows[s.Id] = new ShowSeats(s);
public List<string> AvailableSeats(string showId) => _shows[showId].Available(clock());
// Prices the seats now and keeps that price in the hold, then tries to take every seat. public (Result, SeatHold?) Hold(string userId, string showId, IReadOnlyList<string> seatIds) { if (!_shows.TryGetValue(showId, out var seats) || !Valid(seats.Show, seatIds)) return (Result.InvalidSeats, null); var now = clock(); var price = seatIds.Sum(s => pricing.PriceOf(seats.Show, seats.Show.Screen.Seat(s)!)); var h = new SeatHold(ids(), showId, userId, seatIds.ToList(), now + HoldFor, price); var r = seats.Hold(h, now); if (r != Result.Ok) return (r, null); _holds[h.Id] = h; return (Result.Ok, h); }
// Pin the hold, charge the card, then sell the seats. The hold's id is the payment key, so a // retried Confirm can't charge twice. public (Result, Booking?) Confirm(string holdId, string card) { if (!_holds.TryGetValue(holdId, out var h)) return (Result.NoSuchHold, null); var seats = _shows[h.ShowId]; var pinned = seats.Pin(h, clock()); if (pinned != Result.Ok) return (pinned, null); if (!payments.Charge(h.Id, h.Price, card)) { seats.Unpin(h); return (Result.PaymentDeclined, null); } var b = new Booking(ids(), seats.Show, h.UserId, h.SeatIds, h.Price, h.Id); seats.Sell(h, b.Id); _holds.TryRemove(holdId, out _); _bookings[b.Id] = b; return (Result.Ok, b); }
// The customer gave up. A hold being paid for is not released. public void Release(string holdId) { if (_holds.TryGetValue(holdId, out var h) && _shows[h.ShowId].Release(h)) _holds.TryRemove(holdId, out _); }
// Seats go back on sale first, then the refund. The refund names the original charge by its // key, so if it fails, a retry job can send it again and the gateway applies it once. public Result Cancel(string bookingId) { if (!_bookings.TryGetValue(bookingId, out var b)) return Result.NoSuchBooking; var now = clock(); var r = _shows[b.Show.Id].Cancel(b, now); if (r != Result.Ok) return r; var amount = refunds.RefundFor(b, now); if (amount > 0) payments.Refund(b.PaymentKey, amount); return Result.Ok; }
// At least one seat, at most MaxSeats, no seat twice, and every seat in this screen. private static bool Valid(Show show, IReadOnlyList<string> seatIds) => seatIds.Count > 0 && seatIds.Count <= MaxSeats && seatIds.Distinct().Count() == seatIds.Count && seatIds.All(s => show.Screen.Seat(s) is not null);}
// Seats kept for one customer for a few minutes while they pay. Without it, a seat could be// sold to someone else between choosing it and paying.public sealed class SeatHold(string id, string showId, string userId, IReadOnlyList<string> seatIds, DateTimeOffset expiresAt, long price){ public string Id { get; } = id; public string ShowId { get; } = showId; public string UserId { get; } = userId; public IReadOnlyList<string> SeatIds { get; } = seatIds; public DateTimeOffset ExpiresAt { get; } = expiresAt; public long Price { get; } = price; // quoted at hold time; a price change mid-payment doesn't change the deal public bool Paying { get; set; } // set while the card is charged; a hold being paid for doesn't expire
public bool Expired(DateTimeOffset now) => !Paying && now >= ExpiresAt;}
// What a request gets back, so the app can tell the customer what happened.public enum Result{ Ok, InvalidSeats, SeatTaken, NoSuchHold, HoldExpired, PaymentInProgress, PaymentDeclined, NoSuchBooking, AlreadyCancelled, ShowStarted,}
// Two people click C7 at the same moment. Without a lock, both holds run IsFree("C7"), both// see true, and both take it. The fix is to make "check every seat, then take every seat" one// step for each show. Every method here locks this show's gate, so requests for the same show// take turns and requests for different shows never wait on each other.//// What stays outside the lock is the slow part. Confirm() pins under the lock, charges the// card with no lock held, and sells under the lock again; the pin keeps the seats safe in// between.public sealed class ShowSeats(Show show){ private readonly Lock _gate = new(); // re-entrant: Hold() calls IsFree() while holding it private readonly Dictionary<string, SeatHold> _held = []; // seat -> the hold keeping it private readonly Dictionary<string, string> _sold = []; // seat -> the booking that bought it
public Show Show { get; } = show;
// A seat is free if nobody bought it and any hold on it has run out. Expired holds need no // cleanup job: they simply stop counting. public bool IsFree(string seatId, DateTimeOffset now) { lock (_gate) return !_sold.ContainsKey(seatId) && (!_held.TryGetValue(seatId, out var h) || h.Expired(now)); }
// All seats or none: check every seat first, then take them all. public Result Hold(SeatHold h, DateTimeOffset now) { lock (_gate) { if (!h.SeatIds.All(s => IsFree(s, now))) return Result.SeatTaken; foreach (var s in h.SeatIds) _held[s] = h; return Result.Ok; } }
// Still holding every seat. Another customer may have taken one after this hold expired. public bool IsLive(SeatHold h, DateTimeOffset now) { lock (_gate) return !h.Expired(now) && h.SeatIds.All(s => _held.GetValueOrDefault(s) == h); }
// Marks the hold as being paid for, so it can't expire while the card is charged. public Result Pin(SeatHold h, DateTimeOffset now) { lock (_gate) { if (h.Paying) return Result.PaymentInProgress; if (!IsLive(h, now)) return Result.HoldExpired; h.Paying = true; return Result.Ok; } }
public void Unpin(SeatHold h) { lock (_gate) h.Paying = false; }
public void Sell(SeatHold h, string bookingId) { lock (_gate) { foreach (var s in h.SeatIds) { _held.Remove(s); _sold[s] = bookingId; } } }
public bool Release(SeatHold h) { lock (_gate) { if (h.Paying) return false; foreach (var s in h.SeatIds) if (_held.GetValueOrDefault(s) == h) _held.Remove(s); return true; } }
// Marks the booking cancelled and puts its seats back on sale, as one step: a second cancel // finds it already done, so the customer is refunded once. public Result Cancel(Booking b, DateTimeOffset now) { lock (_gate) { if (b.Cancelled) return Result.AlreadyCancelled; if (now >= Show.StartsAt) return Result.ShowStarted; b.Cancelled = true; foreach (var s in b.SeatIds) if (_sold.GetValueOrDefault(s) == b.Id) _sold.Remove(s); return Result.Ok; } }
public List<string> Available(DateTimeOffset now) { lock (_gate) return Show.Screen.Seats.Select(s => s.Id).Where(s => IsFree(s, now)).ToList(); }}
// Prices live behind an interface because they change for reasons the booking flow doesn't// care about: matinees, premium formats, holiday weekends.public interface IPricing{ long PriceOf(Show show, Seat seat); // in cents}
// zone is the cinema's time zone: "before 5pm" is local time, not the server's.// One cinema's prices: $12 regular, $18 premium, and a quarter off shows before 5pm.public sealed class StandardPricing(TimeZoneInfo zone) : IPricing{ public long PriceOf(Show show, Seat seat) { long baseCents = seat.Type == SeatType.Premium ? 1800 : 1200; var matinee = TimeZoneInfo.ConvertTime(show.StartsAt, zone).Hour < 17; return matinee ? baseCents * 3 / 4 : baseCents; }}
// The card network. Every charge carries a key, so a retry after a timeout is the same charge,// not a second one.public interface IPaymentGateway{ bool Charge(string key, long cents, string card); void Refund(string key, long cents);}
// How much comes back depends on how close to the show the customer cancels. Cinemas set// this, and change it, so it's a policy the service asks.public interface IRefundPolicy{ long RefundFor(Booking b, DateTimeOffset now); // in cents; 0 means no refund}
// Full refund a day or more before the show, half from two hours before, nothing after that.public sealed class StandardRefunds : IRefundPolicy{ public long RefundFor(Booking b, DateTimeOffset now) { var before = b.Show.StartsAt - now; if (before >= TimeSpan.FromHours(24)) return b.Price; if (before >= TimeSpan.FromHours(2)) return b.Price / 2; return 0; }}// The complete design, built from every stage on this page.
package movieticketbooking
import ( "slices" "sync" "time")
// Movie is a film: what people search for, with its running time.type Movie struct { ID, Title string Minutes int}
// SeatType: premium seats cost more. The type is part of the seat, so pricing can read it.type SeatType int
const ( Regular SeatType = iota Premium)
// Seat is a physical seat in an auditorium, like "C7". It exists whether or not anything is// showing.type Seat struct { ID string Type SeatType}
// Screen is an auditorium and its fixed seating plan.type Screen struct { ID string Seats []Seat}
func (s Screen) Seat(id string) (Seat, bool) { for _, x := range s.Seats { if x.ID == id { return x, true } } return Seat{}, false}
// Show is one screening: a movie in a screen at a time. Seats are sold per show, so "is C7// free?" only has an answer for a show, never for the seat alone.type Show struct { ID string Movie Movie Screen Screen StartsAt time.Time}
// Result is what a request gets back, so the app can tell the customer what happened.type Result int
const ( holdFor = 5 * time.Minute // time to pay before seats go back maxSeats = 10 // per booking, so one person can't hold a whole row)
// valid wants some seats, not too many, no seat twice, and every seat in this screen.func valid(show Show, seatIDs []string) bool { if len(seatIDs) == 0 || len(seatIDs) > maxSeats { return false } seen := map[string]bool{} for _, s := range seatIDs { if _, ok := show.Screen.Seat(s); !ok || seen[s] { return false } seen[s] = true } return true}
// Pricing lives behind an interface because prices change for reasons the booking flow// doesn't care about: matinees, premium formats, holiday weekends.type Pricing interface { PriceOf(show Show, seat Seat) int64 // in cents}
// StandardPricing is one cinema's prices: $12 regular, $18 premium, and a quarter off shows// before 5pm.type StandardPricing struct { Zone *time.Location // the cinema's time zone: "before 5pm" is local time}
func (p StandardPricing) PriceOf(show Show, seat Seat) int64 { base := int64(1200) if seat.Type == Premium { base = 1800 } if show.StartsAt.In(p.Zone).Hour() < 17 { // matinee return base * 3 / 4 } return base}
// PaymentGateway is the card network. Every charge carries a key, so a retry after a timeout// is the same charge, not a second one.type PaymentGateway interface { Charge(key string, cents int64, card string) bool Refund(key string, cents int64)}
// SeatHold keeps seats for one customer for a few minutes while they pay. Without it, a seat// could be sold to someone else between choosing it and paying.type SeatHold struct { ID, ShowID, UserID string SeatIDs []string ExpiresAt time.Time Price int64 // quoted at hold time; a price change mid-payment doesn't change the deal paying bool // set while the card is charged; a hold being paid for doesn't expire}
func (h *SeatHold) Expired(now time.Time) bool { return !h.paying && !now.Before(h.ExpiresAt) }
// Booking is a confirmed purchase, which can be cancelled.type Booking struct { ID string Show Show UserID string SeatIDs []string Price int64 PaymentKey string // the charge's key, which a refund refers back to cancelled bool}
// RefundPolicy decides how much comes back, which depends on how close to the show the// customer cancels. Cinemas set this, and change it, so it's a policy the service asks.type RefundPolicy interface { RefundFor(b *Booking, now time.Time) int64 // in cents; 0 means no refund}
// StandardRefunds gives a full refund a day or more before the show, half from two hours before,// and nothing after that.type StandardRefunds struct{}
func (StandardRefunds) RefundFor(b *Booking, now time.Time) int64 { before := b.Show.StartsAt.Sub(now) switch { case before >= 24*time.Hour: return b.Price case before >= 2*time.Hour: return b.Price / 2 default: return 0 }}
const ( OK Result = iota InvalidSeats SeatTaken NoSuchHold HoldExpired PaymentInProgress PaymentDeclined NoSuchBooking AlreadyCancelled ShowStarted)
// BookingService is the front door. It checks requests, prices seats, takes payment, and hands// the seat changes to each show's ShowSeats.type BookingService struct { mu sync.Mutex shows map[string]*ShowSeats holds map[string]*SeatHold bookings map[string]*Booking pricing Pricing refunds RefundPolicy payments PaymentGateway ids func() string clock func() time.Time}
func NewBookingService(pricing Pricing, refunds RefundPolicy, payments PaymentGateway, ids func() string, clock func() time.Time) *BookingService { return &BookingService{shows: map[string]*ShowSeats{}, holds: map[string]*SeatHold{}, bookings: map[string]*Booking{}, pricing: pricing, refunds: refunds, payments: payments, ids: ids, clock: clock}}
func (b *BookingService) AddShow(s Show) { b.mu.Lock() defer b.mu.Unlock() b.shows[s.ID] = NewShowSeats(s)}
func (b *BookingService) seats(showID string) *ShowSeats { b.mu.Lock() defer b.mu.Unlock() return b.shows[showID]}
func (b *BookingService) takeHold(id string, remove bool) *SeatHold { b.mu.Lock() defer b.mu.Unlock() h := b.holds[id] if remove { delete(b.holds, id) } return h}
func (b *BookingService) AvailableSeats(showID string) []string { return b.seats(showID).Available(b.clock())}
// Hold prices the seats now and keeps that price in the hold, then tries to take every seat.func (b *BookingService) Hold(userID, showID string, seatIDs []string) (Result, *SeatHold) { ss := b.seats(showID) if ss == nil || !valid(ss.show, seatIDs) { return InvalidSeats, nil } now := b.clock() var price int64 for _, id := range seatIDs { seat, _ := ss.show.Screen.Seat(id) price += b.pricing.PriceOf(ss.show, seat) } h := &SeatHold{ID: b.ids(), ShowID: showID, UserID: userID, SeatIDs: slices.Clone(seatIDs), ExpiresAt: now.Add(holdFor), Price: price} if r := ss.Hold(h, now); r != OK { return r, nil } b.mu.Lock() b.holds[h.ID] = h b.mu.Unlock() return OK, h}
// Confirm pins the hold, charges the card, then sells the seats. The hold's ID is the payment// key, so a retried Confirm can't charge twice.func (b *BookingService) Confirm(holdID, card string) (Result, *Booking) { h := b.takeHold(holdID, false) if h == nil { return NoSuchHold, nil } ss := b.seats(h.ShowID) if r := ss.Pin(h, b.clock()); r != OK { return r, nil } if !b.payments.Charge(h.ID, h.Price, card) { ss.Unpin(h) return PaymentDeclined, nil } bk := &Booking{ID: b.ids(), Show: ss.show, UserID: h.UserID, SeatIDs: h.SeatIDs, Price: h.Price, PaymentKey: h.ID} ss.Sell(h, bk.ID) b.mu.Lock() delete(b.holds, holdID) b.bookings[bk.ID] = bk b.mu.Unlock() return OK, bk}
// Release is for a customer who gave up. A hold being paid for is not released.func (b *BookingService) Release(holdID string) { h := b.takeHold(holdID, false) if h != nil && b.seats(h.ShowID).Release(h) { b.takeHold(holdID, true) }}
// Cancel puts the seats back on sale first, then refunds. The refund names the original// charge by its key, so if it fails, a retry job can send it again and the gateway applies// it once.func (b *BookingService) Cancel(bookingID string) Result { b.mu.Lock() bk := b.bookings[bookingID] b.mu.Unlock() if bk == nil { return NoSuchBooking } now := b.clock() if r := b.seats(bk.Show.ID).Cancel(bk, now); r != OK { return r } if amount := b.refunds.RefundFor(bk, now); amount > 0 { b.payments.Refund(bk.PaymentKey, amount) } return OK}
// ShowSeats gets a mutex because two people click C7 at the same moment. Without one, both// holds run isFree("C7"), both see true, and both take it. Every exported method locks this// show's mutex, so requests for the same show take turns and requests for different shows// never wait on each other. The lowercase helpers assume the lock is already held.//// What stays outside the lock is the slow part. Confirm pins under the lock, charges the card// with no lock held, and sells under the lock again; the pin keeps the seats safe in between.type ShowSeats struct { mu sync.Mutex show Show held map[string]*SeatHold // seat -> the hold keeping it sold map[string]string // seat -> the booking that bought it}
func NewShowSeats(s Show) *ShowSeats { return &ShowSeats{show: s, held: map[string]*SeatHold{}, sold: map[string]string{}}}
// isFree: a seat is free if nobody bought it and any hold on it has run out. Expired holds need// no cleanup job: they simply stop counting. The caller holds the mutex.func (ss *ShowSeats) isFree(seatID string, now time.Time) bool { if _, ok := ss.sold[seatID]; ok { return false } h := ss.held[seatID] return h == nil || h.Expired(now)}
// isLive means the hold still has every seat. Another customer may have taken one after it// expired.func (ss *ShowSeats) isLive(h *SeatHold, now time.Time) bool { if h.Expired(now) { return false } for _, s := range h.SeatIDs { if ss.held[s] != h { return false } } return true}
// Hold takes all seats or none: it checks every seat first, then takes them all.func (ss *ShowSeats) Hold(h *SeatHold, now time.Time) Result { ss.mu.Lock() defer ss.mu.Unlock() for _, s := range h.SeatIDs { if !ss.isFree(s, now) { return SeatTaken } } for _, s := range h.SeatIDs { ss.held[s] = h } return OK}
// Pin marks the hold as being paid for, so it can't expire while the card is charged.func (ss *ShowSeats) Pin(h *SeatHold, now time.Time) Result { ss.mu.Lock() defer ss.mu.Unlock() if h.paying { return PaymentInProgress } if !ss.isLive(h, now) { return HoldExpired } h.paying = true return OK}
func (ss *ShowSeats) Unpin(h *SeatHold) { ss.mu.Lock() defer ss.mu.Unlock() h.paying = false}
func (ss *ShowSeats) Sell(h *SeatHold, bookingID string) { ss.mu.Lock() defer ss.mu.Unlock() for _, s := range h.SeatIDs { delete(ss.held, s) ss.sold[s] = bookingID }}
func (ss *ShowSeats) Release(h *SeatHold) bool { ss.mu.Lock() defer ss.mu.Unlock() if h.paying { return false } for _, s := range h.SeatIDs { if ss.held[s] == h { delete(ss.held, s) } } return true}
// Cancel marks the booking cancelled and puts its seats back on sale, as one step: a second// cancel finds it already done, so the customer is refunded once.func (ss *ShowSeats) Cancel(bk *Booking, now time.Time) Result { ss.mu.Lock() defer ss.mu.Unlock() if bk.cancelled { return AlreadyCancelled } if !now.Before(ss.show.StartsAt) { return ShowStarted } bk.cancelled = true for _, s := range bk.SeatIDs { if ss.sold[s] == bk.ID { delete(ss.sold, s) } } return OK}
func (ss *ShowSeats) Available(now time.Time) []string { ss.mu.Lock() defer ss.mu.Unlock() var out []string for _, s := range ss.show.Screen.Seats { if ss.isFree(s.ID, now) { out = append(out, s.ID) } } return out}// The complete design, built from every stage on this page.
// A point in time as epoch milliseconds, the same thing Date.now() returns.type Instant = number;
// A film: what people search for, with its running time.interface Movie { readonly id: string; readonly title: string; readonly minutes: number;}
// Premium seats cost more. The type is part of the seat, so pricing can read it.type SeatType = 'regular' | 'premium';
// A physical seat in an auditorium, like "C7". It exists whether or not anything is showing.interface Seat { readonly id: string; readonly type: SeatType;}
// An auditorium and its fixed seating plan.interface Screen { readonly id: string; readonly seats: readonly Seat[];}
// One screening: a movie in a screen at a time. Seats are sold per show, so "is C7 free?"// only has an answer for a show, never for the seat alone.interface Show { readonly id: string; readonly movie: Movie; readonly screen: Screen; readonly startsAt: Instant;}
// A confirmed purchase. cancelled is mutable because a booking can be cancelled.interface Booking { readonly id: string; readonly show: Show; readonly userId: string; readonly seatIds: readonly string[]; readonly price: number; readonly paymentKey: string; // the charge's key, which a refund refers back to cancelled: boolean;}
function seatOf(screen: Screen, seatId: string): Seat | undefined { return screen.seats.find((s) => s.id === seatId);}
// The front door. It checks requests, prices seats, takes payment, and hands the seat changes to// each show's ShowSeats.class BookingService { private readonly shows = new Map<string, ShowSeats>(); private readonly holds = new Map<string, SeatHold>(); private readonly bookings = new Map<string, Booking>();
constructor( private readonly pricing: Pricing, private readonly refunds: RefundPolicy, private readonly payments: PaymentGateway, private readonly ids: () => string, private readonly clock: () => Instant, ) {}
addShow(s: Show): void { this.shows.set(s.id, new ShowSeats(s)); }
availableSeats(showId: string): string[] { return this.shows.get(showId)!.available(this.clock()); }
// Prices the seats now and keeps that price in the hold, then tries to take every seat. hold(userId: string, showId: string, seatIds: string[]): { result: Result; hold?: SeatHold } { const seats = this.shows.get(showId); if (!seats || !valid(seats.show, seatIds)) return { result: 'invalid-seats' }; const now = this.clock(); const price = seatIds.reduce((sum, s) => sum + this.pricing.priceOf(seats.show, seatOf(seats.show.screen, s)!), 0); const h = new SeatHold(this.ids(), showId, userId, [...seatIds], now + HOLD_FOR_MS, price); const result = seats.hold(h, now); if (result !== 'ok') return { result }; this.holds.set(h.id, h); return { result, hold: h }; }
// Pin the hold, charge the card, then sell the seats. The hold's id is the payment key, so a // retried confirm can't charge twice. async confirm(holdId: string, card: string): Promise<{ result: Result; booking?: Booking }> { const h = this.holds.get(holdId); if (!h) return { result: 'no-such-hold' }; const seats = this.shows.get(h.showId)!; const pinned = seats.pin(h, this.clock()); if (pinned !== 'ok') return { result: pinned }; if (!(await this.payments.charge(h.id, h.price, card))) { seats.unpin(h); return { result: 'payment-declined' }; } const booking: Booking = { id: this.ids(), show: seats.show, userId: h.userId, seatIds: h.seatIds, price: h.price, paymentKey: h.id, cancelled: false, }; seats.sell(h, booking.id); this.holds.delete(holdId); this.bookings.set(booking.id, booking); return { result: 'ok', booking }; }
// The customer gave up. A hold being paid for is not released. release(holdId: string): void { const h = this.holds.get(holdId); if (h && this.shows.get(h.showId)!.release(h)) this.holds.delete(holdId); }
// Seats go back on sale first, then the refund. The refund names the original charge by its // key, so if it fails, a retry job can send it again and the gateway applies it once. async cancel(bookingId: string): Promise<Result> { const b = this.bookings.get(bookingId); if (!b) return 'no-such-booking'; const now = this.clock(); const result = this.shows.get(b.show.id)!.cancel(b, now); if (result !== 'ok') return result; const amount = this.refunds.refundFor(b, now); if (amount > 0) await this.payments.refund(b.paymentKey, amount); return 'ok'; }}
const HOLD_FOR_MS = 5 * 60_000;
// time to pay before seats go backconst MAX_SEATS = 10;
// Seats kept for one customer for a few minutes while they pay. Without it, a seat could be sold// to someone else between choosing it and paying.class SeatHold { paying = false; // set while the card is charged; a hold being paid for doesn't expire
constructor( readonly id: string, readonly showId: string, readonly userId: string, readonly seatIds: readonly string[], readonly expiresAt: Instant, readonly price: number, // quoted at hold time; a price change mid-payment doesn't change the deal ) {}
expired(now: Instant): boolean { return !this.paying && now >= this.expiresAt; }}
// What a request gets back, so the app can tell the customer what happened.type Result = | 'ok' | 'invalid-seats' | 'seat-taken' | 'no-such-hold' | 'hold-expired' | 'payment-in-progress' | 'payment-declined' | 'no-such-booking' | 'already-cancelled' | 'show-started';
// Who has which seat, for one show. Each show is its own small inventory: no request ever needs// seats from two shows at once.class ShowSeats { private readonly held = new Map<string, SeatHold>(); // seat -> the hold keeping it private readonly sold = new Map<string, string>(); // seat -> the booking that bought it
constructor(readonly show: Show) {}
// A seat is free if nobody bought it and any hold on it has run out. Expired holds need no // cleanup job: they simply stop counting. isFree(seatId: string, now: Instant): boolean { if (this.sold.has(seatId)) return false; const h = this.held.get(seatId); return !h || h.expired(now); }
// All seats or none: check every seat first, then take them all. hold(h: SeatHold, now: Instant): Result { if (!h.seatIds.every((s) => this.isFree(s, now))) return 'seat-taken'; for (const s of h.seatIds) this.held.set(s, h); return 'ok'; }
// Still holding every seat. Another customer may have taken one after this hold expired. isLive(h: SeatHold, now: Instant): boolean { return !h.expired(now) && h.seatIds.every((s) => this.held.get(s) === h); }
// Marks the hold as being paid for, so it can't expire while the card is charged. pin(h: SeatHold, now: Instant): Result { if (h.paying) return 'payment-in-progress'; if (!this.isLive(h, now)) return 'hold-expired'; h.paying = true; return 'ok'; }
unpin(h: SeatHold): void { h.paying = false; }
sell(h: SeatHold, bookingId: string): void { for (const s of h.seatIds) { this.held.delete(s); this.sold.set(s, bookingId); } }
release(h: SeatHold): boolean { if (h.paying) return false; for (const s of h.seatIds) if (this.held.get(s) === h) this.held.delete(s); return true; }
// Marks the booking cancelled and puts its seats back on sale, as one step: a second cancel // finds it already done, so the customer is refunded once. cancel(b: Booking, now: Instant): Result { if (b.cancelled) return 'already-cancelled'; if (now >= this.show.startsAt) return 'show-started'; b.cancelled = true; for (const s of b.seatIds) if (this.sold.get(s) === b.id) this.sold.delete(s); return 'ok'; }
available(now: Instant): string[] { return this.show.screen.seats.map((s) => s.id).filter((s) => this.isFree(s, now)); }}
// Some seats, not too many, no seat twice, and every seat in this screen.function valid(show: Show, seatIds: string[]): boolean { return ( seatIds.length > 0 && seatIds.length <= MAX_SEATS && new Set(seatIds).size === seatIds.length && seatIds.every((s) => seatOf(show.screen, s) !== undefined) );}
// Prices live behind an interface because they change for reasons the booking flow doesn't// care about: matinees, premium formats, holiday weekends.interface Pricing { priceOf(show: Show, seat: Seat): number; // in cents}
// One cinema's prices: $12 regular, $18 premium, and a quarter off shows before 5pm.class StandardPricing implements Pricing { private readonly hourOf: Intl.DateTimeFormat;
// The cinema's time zone: "before 5pm" is local time, not the server's. constructor(timeZone: string) { this.hourOf = new Intl.DateTimeFormat('en-US', { timeZone, hour: 'numeric', hourCycle: 'h23' }); }
priceOf(show: Show, seat: Seat): number { const base = seat.type === 'premium' ? 1800 : 1200; const matinee = Number(this.hourOf.format(show.startsAt)) < 17; return matinee ? (base * 3) / 4 : base; }}
// The card network. Every charge carries a key, so a retry after a timeout is the same charge,// not a second one. It's async: the customer may be on their bank's page for a minute.interface PaymentGateway { charge(key: string, cents: number, card: string): Promise<boolean>; refund(key: string, cents: number): Promise<void>;}
// How much comes back depends on how close to the show the customer cancels. Cinemas set// this, and change it, so it's a policy the service asks.interface RefundPolicy { refundFor(b: Booking, now: Instant): number; // in cents; 0 means no refund}
const HOUR_MS = 3_600_000;
// Full refund a day or more before the show, half from two hours before, nothing after that.class StandardRefunds implements RefundPolicy { refundFor(b: Booking, now: Instant): number { const before = b.show.startsAt - now; if (before >= 24 * HOUR_MS) return b.price; if (before >= 2 * HOUR_MS) return Math.floor(b.price / 2); return 0; }}
// In one Node process ShowSeats needs no lock: hold() checks and takes every seat without an// await in between, so no other request can run in the middle. The only await is the charge in// confirm(), and the pin already covers it. The race comes back when seats live in a database// shared by many servers. Then "check every seat, then take every seat" has to be one// statement, so the database does the check and the take together.interface Db { // Runs one statement and returns how many rows it changed. run(sql: string, ...args: unknown[]): Promise<number>; transaction<T>(work: (db: Db) => Promise<T>): Promise<T>;}
// show_seats has one row per seat per show: (show_id, seat_id, hold_id, held_until, booking_id).async function holdInDb(db: Db, showId: string, seatIds: string[], holdId: string, now: Date): Promise<Result> { const until = new Date(now.getTime() + HOLD_FOR_MS); return db.transaction(async (tx) => { // One conditional update claims every seat that is free: unsold, and either never held or // held by a hold that has run out. Two requests for C7 can't both match its row, because // the first update locks it and the second re-checks the condition after it commits. const claimed = await tx.run( `UPDATE show_seats SET hold_id = $1, held_until = $2 WHERE show_id = $3 AND seat_id = ANY($4) AND booking_id IS NULL AND (held_until IS NULL OR held_until <= $5)`, holdId, until, showId, seatIds, now, ); // All or nothing: if any seat was taken, throw so the transaction undoes the partial claim. if (claimed !== seatIds.length) throw new SeatTakenError(); return 'ok' as const; }).catch((e: unknown) => { if (e instanceof SeatTakenError) return 'seat-taken' as const; throw e; });}
// Thrown inside the transaction to roll it back when a seat was already taken.class SeatTakenError extends Error {}Three ideas hold it together:
- One inventory per show. Every seat check and change goes through
ShowSeats, and no request needs two shows. So a show is the natural unit for keeping seats consistent, and for locking. The website only callsBookingService, a simple front door (a facade). - Time is compared, never scheduled. Holds run out because the clock passed their deadline, not because a job ran. Pinning is the one exception, and it’s explicit. A seat’s state (free, held, pinned, sold) is worked out from the maps and deadlines, never stored as a field.
- Every payment call is safe to repeat. The hold ID is the key for the charge and the refund. So retries, double clicks and recovery jobs never move money twice. Prices and refunds sit behind policies (Strategy).
Stress it
Section titled “Stress it”So far, requests arrive one at a time. Now a blockbuster opens, and ten thousand people are on the same seat map.
Two clicks on C7
Section titled “Two clicks on C7”A blockbuster opens. Ten thousand people are on the seat map, and two of them click C7 in the same millisecond. Walk me through it.
Whether you name the race (both check, both take). Whether you make “check every seat, then take every seat” one step per show, and keep the slow payment outside the lock. For a senior candidate, they expect the database version too: a conditional update, and why a plain read-then-write fails.
Without protection, both requests ask “is C7 free?”, both see yes, and both write their hold. The later write wins the map entry, but both customers were told “held”, and both may pay. With several seats per request it gets worse. Ana holds A1 and A2, Ben holds A2 and A3, and each can grab part of what they asked for before seeing the other.
The fix is to make “check every seat, then take every seat” one step for each show. Requests for the same show take turns. Requests for different shows never touch the same inventory, so they never wait for each other.
The clearly correct fix is a lock per show. Every ShowSeats method takes that show’s lock. The
check and the take can no longer be split. But careful: charging the card can take a minute. If
confirm held the lock the whole time, every buyer for that show would wait for the slowest bank.
So the payment happens outside the lock. Confirm takes the lock to pin, and lets go. It charges the card with no lock held. Then it takes the lock again to sell. The pin is what keeps the seats safe in between.
- Color key
- Entity
| Option | How | Correct? | Cost |
|---|---|---|---|
| 1. A lock per show (our choice in memory) | Every ShowSeats method takes the show’s lock |
Yes | Each show’s seats must live on one server |
| 2. One conditional update in the database | UPDATE ... SET hold ... WHERE seat IN (...) AND free; check the count |
Yes | The real answer with many servers; see the TypeScript tab |
| 3. A unique rule on (show, seat) | Insert one row per held seat in a transaction | Yes | Expired holds must be deleted before the seat can be held again |
| 4. Redis, one key per seat with a 5-minute expiry | SET seat NX PX 300000 |
Per seat, yes | Several seats need a script to be all or nothing |
| 5. Read the seats, then write the hold | Two separate statements | No: the race above | None, which is why it ships by mistake |
// Two people click C7 at the same moment. Without a lock, both holds run isFree("C7"), both// see true, and both take it. The fix is to make "check every seat, then take every seat" one// step for each show. Every method here is synchronized on this show's seats, so requests for// the same show take turns and requests for different shows never wait on each other.//// What stays outside the lock is the slow part. confirm() pins under the lock, charges the// card with no lock held, and sells under the lock again; the pin is what keeps the seats safe// in between.class ShowSeats { final Show show; private final Map<String, SeatHold> held = new HashMap<>(); // seat -> the hold keeping it private final Map<String, String> sold = new HashMap<>(); // seat -> the booking that bought it
ShowSeats(Show show) { this.show = show; }
// A seat is free if nobody bought it and any hold on it has run out. Expired holds need no // cleanup job: they simply stop counting. synchronized boolean isFree(String seatId, Instant now) { if (sold.containsKey(seatId)) return false; SeatHold h = held.get(seatId); return h == null || h.expired(now); }
// All seats or none: check every seat first, then take them all. synchronized Result hold(SeatHold h, Instant now) { for (String s : h.seatIds) if (!isFree(s, now)) return Result.SEAT_TAKEN; for (String s : h.seatIds) held.put(s, h); return Result.OK; }
// Still holding every seat. Another customer may have taken one after this hold expired. synchronized boolean isLive(SeatHold h, Instant now) { return !h.expired(now) && h.seatIds.stream().allMatch(s -> held.get(s) == h); }
// Marks the hold as being paid for, so it can't expire while the card is charged. synchronized Result pin(SeatHold h, Instant now) { if (h.paying) return Result.PAYMENT_IN_PROGRESS; if (!isLive(h, now)) return Result.HOLD_EXPIRED; h.paying = true; return Result.OK; }
synchronized void unpin(SeatHold h) { h.paying = false; }
synchronized void sell(SeatHold h, String bookingId) { for (String s : h.seatIds) { held.remove(s); sold.put(s, bookingId); } }
synchronized boolean release(SeatHold h) { if (h.paying) return false; for (String s : h.seatIds) held.remove(s, h); return true; }
// Marks the booking cancelled and puts its seats back on sale, as one step: a second cancel // finds it already done, so the customer is refunded once. synchronized Result cancel(Booking b, Instant now) { if (b.cancelled) return Result.ALREADY_CANCELLED; if (!now.isBefore(show.startsAt())) return Result.SHOW_STARTED; b.cancelled = true; for (String s : b.seatIds) sold.remove(s, b.id); return Result.OK; }
synchronized List<String> available(Instant now) { return show.screen().seats().stream().map(Seat::id).filter(s -> isFree(s, now)).toList(); }}import threading
# Two people click C7 at the same moment. Without a lock, both holds run is_free("C7"), both# see True, and both take it. The fix is to make "check every seat, then take every seat" one# step for each show. Every method here runs under this show's lock, so requests for the same# show take turns and requests for different shows never wait on each other.## What stays outside the lock is the slow part. confirm() pins under the lock, charges the# card with no lock held, and sells under the lock again; the pin keeps the seats safe in# between.class ShowSeats: def __init__(self, show: Show): self.show = show self._held: dict[str, SeatHold] = {} # seat -> the hold keeping it self._sold: dict[str, str] = {} # seat -> the booking that bought it self._lock = threading.RLock() # re-entrant: hold() calls is_free() while holding it
# A seat is free if nobody bought it and any hold on it has run out. Expired holds need no # cleanup job: they simply stop counting. def is_free(self, seat_id: str, now: datetime) -> bool: with self._lock: if seat_id in self._sold: return False h = self._held.get(seat_id) return h is None or h.expired(now)
# All seats or none: check every seat first, then take them all. def hold(self, h: SeatHold, now: datetime) -> Result: with self._lock: if not all(self.is_free(s, now) for s in h.seat_ids): return Result.SEAT_TAKEN for s in h.seat_ids: self._held[s] = h return Result.OK
# Still holding every seat. Another customer may have taken one after this hold expired. def is_live(self, h: SeatHold, now: datetime) -> bool: with self._lock: return not h.expired(now) and all(self._held.get(s) is h for s in h.seat_ids)
# Marks the hold as being paid for, so it can't expire while the card is charged. def pin(self, h: SeatHold, now: datetime) -> Result: with self._lock: if h.paying: return Result.PAYMENT_IN_PROGRESS if not self.is_live(h, now): return Result.HOLD_EXPIRED h.paying = True return Result.OK
def unpin(self, h: SeatHold) -> None: with self._lock: h.paying = False
def sell(self, h: SeatHold, booking_id: str) -> None: with self._lock: for s in h.seat_ids: self._held.pop(s, None) self._sold[s] = booking_id
def release(self, h: SeatHold) -> bool: with self._lock: if h.paying: return False for s in h.seat_ids: if self._held.get(s) is h: del self._held[s] return True
# Marks the booking cancelled and puts its seats back on sale, as one step: a second cancel # finds it already done, so the customer is refunded once. def cancel(self, b: Booking, now: datetime) -> Result: with self._lock: if b.cancelled: return Result.ALREADY_CANCELLED if now >= self.show.starts_at: return Result.SHOW_STARTED b.cancelled = True for s in b.seat_ids: if self._sold.get(s) == b.id: del self._sold[s] return Result.OK
def available(self, now: datetime) -> list[str]: with self._lock: return [s.id for s in self.show.screen.seats if self.is_free(s.id, now)]// Two people click C7 at the same moment. Without a lock, both holds run IsFree("C7"), both// see true, and both take it. The fix is to make "check every seat, then take every seat" one// step for each show. Every method here locks this show's gate, so requests for the same show// take turns and requests for different shows never wait on each other.//// What stays outside the lock is the slow part. Confirm() pins under the lock, charges the// card with no lock held, and sells under the lock again; the pin keeps the seats safe in// between.public sealed class ShowSeats(Show show){ private readonly Lock _gate = new(); // re-entrant: Hold() calls IsFree() while holding it private readonly Dictionary<string, SeatHold> _held = []; // seat -> the hold keeping it private readonly Dictionary<string, string> _sold = []; // seat -> the booking that bought it
public Show Show { get; } = show;
// A seat is free if nobody bought it and any hold on it has run out. Expired holds need no // cleanup job: they simply stop counting. public bool IsFree(string seatId, DateTimeOffset now) { lock (_gate) return !_sold.ContainsKey(seatId) && (!_held.TryGetValue(seatId, out var h) || h.Expired(now)); }
// All seats or none: check every seat first, then take them all. public Result Hold(SeatHold h, DateTimeOffset now) { lock (_gate) { if (!h.SeatIds.All(s => IsFree(s, now))) return Result.SeatTaken; foreach (var s in h.SeatIds) _held[s] = h; return Result.Ok; } }
// Still holding every seat. Another customer may have taken one after this hold expired. public bool IsLive(SeatHold h, DateTimeOffset now) { lock (_gate) return !h.Expired(now) && h.SeatIds.All(s => _held.GetValueOrDefault(s) == h); }
// Marks the hold as being paid for, so it can't expire while the card is charged. public Result Pin(SeatHold h, DateTimeOffset now) { lock (_gate) { if (h.Paying) return Result.PaymentInProgress; if (!IsLive(h, now)) return Result.HoldExpired; h.Paying = true; return Result.Ok; } }
public void Unpin(SeatHold h) { lock (_gate) h.Paying = false; }
public void Sell(SeatHold h, string bookingId) { lock (_gate) { foreach (var s in h.SeatIds) { _held.Remove(s); _sold[s] = bookingId; } } }
public bool Release(SeatHold h) { lock (_gate) { if (h.Paying) return false; foreach (var s in h.SeatIds) if (_held.GetValueOrDefault(s) == h) _held.Remove(s); return true; } }
// Marks the booking cancelled and puts its seats back on sale, as one step: a second cancel // finds it already done, so the customer is refunded once. public Result Cancel(Booking b, DateTimeOffset now) { lock (_gate) { if (b.Cancelled) return Result.AlreadyCancelled; if (now >= Show.StartsAt) return Result.ShowStarted; b.Cancelled = true; foreach (var s in b.SeatIds) if (_sold.GetValueOrDefault(s) == b.Id) _sold.Remove(s); return Result.Ok; } }
public List<string> Available(DateTimeOffset now) { lock (_gate) return Show.Screen.Seats.Select(s => s.Id).Where(s => IsFree(s, now)).ToList(); }}// ShowSeats gets a mutex because two people click C7 at the same moment. Without one, both// holds run isFree("C7"), both see true, and both take it. Every exported method locks this// show's mutex, so requests for the same show take turns and requests for different shows// never wait on each other. The lowercase helpers assume the lock is already held.//// What stays outside the lock is the slow part. Confirm pins under the lock, charges the card// with no lock held, and sells under the lock again; the pin keeps the seats safe in between.type ShowSeats struct { mu sync.Mutex show Show held map[string]*SeatHold // seat -> the hold keeping it sold map[string]string // seat -> the booking that bought it}
func NewShowSeats(s Show) *ShowSeats { return &ShowSeats{show: s, held: map[string]*SeatHold{}, sold: map[string]string{}}}
// isFree: a seat is free if nobody bought it and any hold on it has run out. Expired holds need// no cleanup job: they simply stop counting. The caller holds the mutex.func (ss *ShowSeats) isFree(seatID string, now time.Time) bool { if _, ok := ss.sold[seatID]; ok { return false } h := ss.held[seatID] return h == nil || h.Expired(now)}
// isLive means the hold still has every seat. Another customer may have taken one after it// expired.func (ss *ShowSeats) isLive(h *SeatHold, now time.Time) bool { if h.Expired(now) { return false } for _, s := range h.SeatIDs { if ss.held[s] != h { return false } } return true}
// Hold takes all seats or none: it checks every seat first, then takes them all.func (ss *ShowSeats) Hold(h *SeatHold, now time.Time) Result { ss.mu.Lock() defer ss.mu.Unlock() for _, s := range h.SeatIDs { if !ss.isFree(s, now) { return SeatTaken } } for _, s := range h.SeatIDs { ss.held[s] = h } return OK}
// Pin marks the hold as being paid for, so it can't expire while the card is charged.func (ss *ShowSeats) Pin(h *SeatHold, now time.Time) Result { ss.mu.Lock() defer ss.mu.Unlock() if h.paying { return PaymentInProgress } if !ss.isLive(h, now) { return HoldExpired } h.paying = true return OK}
func (ss *ShowSeats) Unpin(h *SeatHold) { ss.mu.Lock() defer ss.mu.Unlock() h.paying = false}
func (ss *ShowSeats) Sell(h *SeatHold, bookingID string) { ss.mu.Lock() defer ss.mu.Unlock() for _, s := range h.SeatIDs { delete(ss.held, s) ss.sold[s] = bookingID }}
func (ss *ShowSeats) Release(h *SeatHold) bool { ss.mu.Lock() defer ss.mu.Unlock() if h.paying { return false } for _, s := range h.SeatIDs { if ss.held[s] == h { delete(ss.held, s) } } return true}
// Cancel marks the booking cancelled and puts its seats back on sale, as one step: a second// cancel finds it already done, so the customer is refunded once.func (ss *ShowSeats) Cancel(bk *Booking, now time.Time) Result { ss.mu.Lock() defer ss.mu.Unlock() if bk.cancelled { return AlreadyCancelled } if !now.Before(ss.show.StartsAt) { return ShowStarted } bk.cancelled = true for _, s := range bk.SeatIDs { if ss.sold[s] == bk.ID { delete(ss.sold, s) } } return OK}
func (ss *ShowSeats) Available(now time.Time) []string { ss.mu.Lock() defer ss.mu.Unlock() var out []string for _, s := range ss.show.Screen.Seats { if ss.isFree(s.ID, now) { out = append(out, s.ID) } } return out}// In one Node process ShowSeats needs no lock: hold() checks and takes every seat without an// await in between, so no other request can run in the middle. The only await is the charge in// confirm(), and the pin already covers it. The race comes back when seats live in a database// shared by many servers. Then "check every seat, then take every seat" has to be one// statement, so the database does the check and the take together.interface Db { // Runs one statement and returns how many rows it changed. run(sql: string, ...args: unknown[]): Promise<number>; transaction<T>(work: (db: Db) => Promise<T>): Promise<T>;}
// show_seats has one row per seat per show: (show_id, seat_id, hold_id, held_until, booking_id).async function holdInDb(db: Db, showId: string, seatIds: string[], holdId: string, now: Date): Promise<Result> { const until = new Date(now.getTime() + HOLD_FOR_MS); return db.transaction(async (tx) => { // One conditional update claims every seat that is free: unsold, and either never held or // held by a hold that has run out. Two requests for C7 can't both match its row, because // the first update locks it and the second re-checks the condition after it commits. const claimed = await tx.run( `UPDATE show_seats SET hold_id = $1, held_until = $2 WHERE show_id = $3 AND seat_id = ANY($4) AND booking_id IS NULL AND (held_until IS NULL OR held_until <= $5)`, holdId, until, showId, seatIds, now, ); // All or nothing: if any seat was taken, throw so the transaction undoes the partial claim. if (claimed !== seatIds.length) throw new SeatTakenError(); return 'ok' as const; }).catch((e: unknown) => { if (e instanceof SeatTakenError) return 'seat-taken' as const; throw e; });}
// Thrown inside the transaction to roll it back when a seat was already taken.class SeatTakenError extends Error {}Why is this correct? Under the show’s lock, checking every requested seat and claiming them all is one step. So no other request can see a half-claimed set. Requests for different shows use different locks, so they never block each other. And the lock is never held while the bank works. The pin protects the seats instead.
What does it cost? In-memory locks only work on one server. So each show must live on one server, or the inventory moves to a database with the conditional update. And a hugely popular show is still one busy lock. Real sites handle opening-day spikes with a virtual waiting room in front of the seat map. It lets a few hundred people in at a time.
Go deeperThe database version, step by step1 min
In TypeScript, the in-memory version needs no lock. hold checks and takes every seat with no
await in between, so nothing else can run in the middle. The one await (the charge) is covered
by the pin. So the TypeScript tab shows the database version instead. That’s where the race comes
back, once seats live in a shared table.
The idea is one statement that claims every requested seat, but only the free ones:
UPDATE seats SET hold = :h WHERE show = :s AND seat IN (...) AND (free). The database runs it as
one step. Then we check how many rows changed. If we asked for three seats and only two rows
changed, someone else got one of them, so we roll back the whole thing. All or nothing, with no
lock in our code.
Go deeperWhy not lock each seat, instead of the whole show?1 min
Because a request holds several seats. Taking several locks one at a time needs a fixed order. Otherwise two requests can each hold one lock and wait for the other forever (a deadlock). And a request that gets two of its three seats has a partial claim to undo. A show is small, a few hundred seats, and each hold takes microseconds. One lock per show is simpler, and fast enough.
How you’d prove it works
Section titled “How you’d prove it works”The clock is a function the test controls, and the fake gateway can run a step in the middle of a charge. That’s how a test stands in for a slow bank page, or a second click. The tests that matter:
- A hold takes all its seats or none. Bad requests are refused.
- An expired hold frees its seats with nothing running, and its owner can’t pay for it afterwards.
- Confirm charges the quoted price once, and sells the seats. Matinees are cheaper.
- A payment that takes ten minutes keeps the seats, and another customer can’t take them meanwhile.
- A declined card can be retried. A second Pay click is turned away, with one charge made.
- Cancelling refunds by notice given, puts the seats back, and refunds once.
- Sixteen threads holding overlapping seats let exactly one win, with nothing half-held.
Go deeperThe tests in five languages2 min
The slow-payment test protects the main promise. It moves the clock past the deadline in the middle of the charge, and tries to take the seat. If anyone changes confirm to check the deadline and then charge, without pinning, this test fails.
// A card network that remembers each charge by key, declines the card "bad", and can run a// step in the middle of a charge to stand in for a slow bank page.static class FakeGateway implements PaymentGateway { final Map<String, Long> charged = new LinkedHashMap<>(); final Map<String, Long> refunded = new LinkedHashMap<>(); Runnable duringCharge = () -> {};
public boolean charge(String key, long cents, String card) { duringCharge.run(); if (card.equals("bad")) return false; charged.putIfAbsent(key, cents); // same key, same charge return true; }
public void refund(String key, long cents) { refunded.putIfAbsent(key, cents); }}
static final Movie DUNE = new Movie("m1", "Dune", 155);static final Screen SCREEN = new Screen("s1", List.of( new Seat("A1", SeatType.REGULAR), new Seat("A2", SeatType.REGULAR), new Seat("A3", SeatType.REGULAR), new Seat("P1", SeatType.PREMIUM), new Seat("P2", SeatType.PREMIUM)));static final Show EVENING = new Show("eve", DUNE, SCREEN, Instant.parse("2026-05-02T19:00:00Z"));static final Show MATINEE = new Show("mat", DUNE, SCREEN, Instant.parse("2026-05-02T14:00:00Z"));
Instant now = Instant.parse("2026-05-01T12:00:00Z"); // 31 hours before the evening showFakeGateway gateway = new FakeGateway();AtomicInteger nextId = new AtomicInteger();BookingService service;
@BeforeEachvoid setUp() { service = new BookingService(new StandardPricing(ZoneOffset.UTC), new StandardRefunds(), gateway, () -> "id" + nextId.incrementAndGet(), () -> now); service.addShow(EVENING); service.addShow(MATINEE);}
SeatHold hold(String user, String show, String... seats) { HoldResult r = service.hold(user, show, List.of(seats)); assertEquals(Result.OK, r.result()); return r.hold();}
@Testvoid aHoldTakesAllItsSeatsOrNone() { hold("ana", "eve", "A1", "A2"); assertEquals(Result.SEAT_TAKEN, service.hold("ben", "eve", List.of("A2", "A3")).result()); assertEquals(List.of("A3", "P1", "P2"), service.availableSeats("eve"));}
@Testvoid badSeatRequestsAreRefused() { assertEquals(Result.INVALID_SEATS, service.hold("ana", "eve", List.of("Z9")).result()); assertEquals(Result.INVALID_SEATS, service.hold("ana", "eve", List.of("A1", "A1")).result()); assertEquals(Result.INVALID_SEATS, service.hold("ana", "eve", List.of()).result());}
@Testvoid anExpiredHoldFreesItsSeatsWithoutAnythingRunning() { SeatHold h = hold("ana", "eve", "A1"); now = now.plus(Duration.ofMinutes(5)); assertTrue(service.availableSeats("eve").contains("A1")); hold("ben", "eve", "A1"); assertEquals(Result.HOLD_EXPIRED, service.confirm(h.id, "visa").result()); assertTrue(gateway.charged.isEmpty());}
@Testvoid confirmChargesTheQuotedPriceOnceAndSellsTheSeats() { SeatHold h = hold("ana", "eve", "A1", "P1"); BookingResult r = service.confirm(h.id, "visa"); assertEquals(Result.OK, r.result()); assertEquals(Map.of(h.id, 3000L), gateway.charged); assertEquals(List.of("A2", "A3", "P2"), service.availableSeats("eve")); assertEquals(Result.NO_SUCH_HOLD, service.confirm(h.id, "visa").result());}
@Testvoid matineesAreCheaper() { assertEquals(900, hold("ana", "mat", "A1").price);}
@Testvoid aSlowPaymentKeepsTheSeats() { SeatHold h = hold("ana", "eve", "A1"); gateway.duringCharge = () -> { now = now.plus(Duration.ofMinutes(10)); // the bank page took ten minutes assertEquals(Result.SEAT_TAKEN, service.hold("ben", "eve", List.of("A1")).result()); }; assertEquals(Result.OK, service.confirm(h.id, "visa").result());}
@Testvoid aDeclinedCardCanBeRetriedBeforeTheHoldRunsOut() { SeatHold h = hold("ana", "eve", "A1"); assertEquals(Result.PAYMENT_DECLINED, service.confirm(h.id, "bad").result()); assertEquals(Result.OK, service.confirm(h.id, "visa").result());}
@Testvoid aSecondPayClickIsTurnedAway() { SeatHold h = hold("ana", "eve", "A1"); List<Result> second = new ArrayList<>(); gateway.duringCharge = () -> second.add(service.confirm(h.id, "visa").result()); assertEquals(Result.OK, service.confirm(h.id, "visa").result()); assertEquals(List.of(Result.PAYMENT_IN_PROGRESS), second); assertEquals(1, gateway.charged.size());}
@Testvoid cancellingRefundsByHowCloseTheShowIs() { Booking early = service.confirm(hold("ana", "eve", "A1").id, "visa").booking(); assertEquals(Result.OK, service.cancel(early.id)); assertEquals(1200, (long) gateway.refunded.get(early.paymentKey)); assertEquals(Result.ALREADY_CANCELLED, service.cancel(early.id)); assertTrue(service.availableSeats("eve").contains("A1"));
Booking late = service.confirm(hold("ben", "eve", "A2").id, "visa").booking(); now = Instant.parse("2026-05-02T16:00:00Z"); // three hours before assertEquals(Result.OK, service.cancel(late.id)); assertEquals(600, (long) gateway.refunded.get(late.paymentKey));}
@Testvoid racingHoldsOnOverlappingSeatsLetExactlyOneWin() throws Exception { ExecutorService pool = Executors.newFixedThreadPool(16); CountDownLatch start = new CountDownLatch(1); List<Future<Result>> results = new ArrayList<>(); for (int i = 0; i < 16; i++) { List<String> seats = i % 2 == 0 ? List.of("A1", "A2") : List.of("A2", "A3"); String user = "u" + i; results.add(pool.submit(() -> { start.await(); return service.hold(user, "eve", seats).result(); })); } start.countDown(); int ok = 0; for (Future<Result> f : results) if (f.get() == Result.OK) ok++; pool.shutdown(); assertEquals(1, ok); assertEquals(3, service.availableSeats("eve").size()); // the winner's two seats, nothing partial}import itertoolsimport threadingfrom concurrent.futures import ThreadPoolExecutorfrom datetime import datetime, timedelta, timezonefrom zoneinfo import ZoneInfo
import pytest
# A card network that remembers each charge by key, declines the card "bad", and can run a# step in the middle of a charge to stand in for a slow bank page.class FakeGateway: def __init__(self): self.charged: dict[str, int] = {} self.refunded: dict[str, int] = {} self.during_charge = lambda: None
def charge(self, key: str, cents: int, card: str) -> bool: self.during_charge() if card == "bad": return False self.charged.setdefault(key, cents) # same key, same charge return True
def refund(self, key: str, cents: int) -> None: self.refunded.setdefault(key, cents)
DUNE = Movie("m1", "Dune", 155)SCREEN = Screen("s1", (Seat("A1", SeatType.REGULAR), Seat("A2", SeatType.REGULAR), Seat("A3", SeatType.REGULAR), Seat("P1", SeatType.PREMIUM), Seat("P2", SeatType.PREMIUM)))EVENING = Show("eve", DUNE, SCREEN, datetime(2026, 5, 2, 19, tzinfo=timezone.utc))MATINEE = Show("mat", DUNE, SCREEN, datetime(2026, 5, 2, 14, tzinfo=timezone.utc))
class World: def __init__(self): self.now = datetime(2026, 5, 1, 12, tzinfo=timezone.utc) # 31 hours before the evening show self.gateway = FakeGateway() counter = itertools.count(1) self.service = BookingService(StandardPricing(ZoneInfo("UTC")), StandardRefunds(), self.gateway, lambda: f"id{next(counter)}", lambda: self.now) self.service.add_show(EVENING) self.service.add_show(MATINEE)
def hold(self, user: str, show: str, *seats: str) -> SeatHold: r, h = self.service.hold(user, show, list(seats)) assert r is Result.OK return h
@pytest.fixturedef w(): return World()
def test_a_hold_takes_all_its_seats_or_none(w): w.hold("ana", "eve", "A1", "A2") assert w.service.hold("ben", "eve", ["A2", "A3"])[0] is Result.SEAT_TAKEN assert w.service.available_seats("eve") == ["A3", "P1", "P2"]
def test_bad_seat_requests_are_refused(w): assert w.service.hold("ana", "eve", ["Z9"])[0] is Result.INVALID_SEATS assert w.service.hold("ana", "eve", ["A1", "A1"])[0] is Result.INVALID_SEATS assert w.service.hold("ana", "eve", [])[0] is Result.INVALID_SEATS
def test_an_expired_hold_frees_its_seats_without_anything_running(w): h = w.hold("ana", "eve", "A1") w.now += timedelta(minutes=5) assert "A1" in w.service.available_seats("eve") w.hold("ben", "eve", "A1") assert w.service.confirm(h.id, "visa")[0] is Result.HOLD_EXPIRED assert w.gateway.charged == {}
def test_confirm_charges_the_quoted_price_once_and_sells_the_seats(w): h = w.hold("ana", "eve", "A1", "P1") assert w.service.confirm(h.id, "visa")[0] is Result.OK assert w.gateway.charged == {h.id: 3000} assert w.service.available_seats("eve") == ["A2", "A3", "P2"] assert w.service.confirm(h.id, "visa")[0] is Result.NO_SUCH_HOLD
def test_matinees_are_cheaper(w): assert w.hold("ana", "mat", "A1").price == 900
def test_a_slow_payment_keeps_the_seats(w): h = w.hold("ana", "eve", "A1")
def slow_bank(): w.now += timedelta(minutes=10) # the bank page took ten minutes assert w.service.hold("ben", "eve", ["A1"])[0] is Result.SEAT_TAKEN
w.gateway.during_charge = slow_bank assert w.service.confirm(h.id, "visa")[0] is Result.OK
def test_a_declined_card_can_be_retried_before_the_hold_runs_out(w): h = w.hold("ana", "eve", "A1") assert w.service.confirm(h.id, "bad")[0] is Result.PAYMENT_DECLINED assert w.service.confirm(h.id, "visa")[0] is Result.OK
def test_a_second_pay_click_is_turned_away(w): h = w.hold("ana", "eve", "A1") second = [] w.gateway.during_charge = lambda: second.append(w.service.confirm(h.id, "visa")[0]) assert w.service.confirm(h.id, "visa")[0] is Result.OK assert second == [Result.PAYMENT_IN_PROGRESS] assert len(w.gateway.charged) == 1
def test_cancelling_refunds_by_how_close_the_show_is(w): _, early = w.service.confirm(w.hold("ana", "eve", "A1").id, "visa") assert w.service.cancel(early.id) is Result.OK assert w.gateway.refunded[early.payment_key] == 1200 assert w.service.cancel(early.id) is Result.ALREADY_CANCELLED assert "A1" in w.service.available_seats("eve")
_, late = w.service.confirm(w.hold("ben", "eve", "A2").id, "visa") w.now = datetime(2026, 5, 2, 16, tzinfo=timezone.utc) # three hours before assert w.service.cancel(late.id) is Result.OK assert w.gateway.refunded[late.payment_key] == 600
def test_racing_holds_on_overlapping_seats_let_exactly_one_win(w): start = threading.Barrier(16)
def try_hold(i: int) -> Result: seats = ["A1", "A2"] if i % 2 == 0 else ["A2", "A3"] start.wait() return w.service.hold(f"u{i}", "eve", seats)[0]
with ThreadPoolExecutor(16) as pool: results = list(pool.map(try_hold, range(16))) assert results.count(Result.OK) == 1 assert len(w.service.available_seats("eve")) == 3 # the winner's two seats, nothing partial// A card network that remembers each charge by key, declines the card "bad", and can run a// step in the middle of a charge to stand in for a slow bank page.private sealed class FakeGateway : IPaymentGateway{ public readonly ConcurrentDictionary<string, long> Charged = new(); public readonly ConcurrentDictionary<string, long> Refunded = new(); public Action DuringCharge = () => { };
public bool Charge(string key, long cents, string card) { DuringCharge(); if (card == "bad") return false; Charged.TryAdd(key, cents); // same key, same charge return true; }
public void Refund(string key, long cents) => Refunded.TryAdd(key, cents);}
private static readonly Screen TestScreen = new("s1", [ new Seat("A1", SeatType.Regular), new Seat("A2", SeatType.Regular), new Seat("A3", SeatType.Regular), new Seat("P1", SeatType.Premium), new Seat("P2", SeatType.Premium)]);private static readonly Movie Dune = new("m1", "Dune", 155);private static readonly Show Evening = new("eve", Dune, TestScreen, new DateTimeOffset(2026, 5, 2, 19, 0, 0, TimeSpan.Zero));private static readonly Show Matinee = new("mat", Dune, TestScreen, new DateTimeOffset(2026, 5, 2, 14, 0, 0, TimeSpan.Zero));
private DateTimeOffset _now = new(2026, 5, 1, 12, 0, 0, TimeSpan.Zero); // 31 hours before the evening showprivate readonly FakeGateway _gateway = new();private readonly BookingService _service;private int _nextId;
// xUnit builds a fresh instance per test, so the constructor is the setup.public MovieBookingTests(){ _service = new BookingService(new StandardPricing(TimeZoneInfo.Utc), new StandardRefunds(), _gateway, () => $"id{Interlocked.Increment(ref _nextId)}", () => _now); _service.AddShow(Evening); _service.AddShow(Matinee);}
private SeatHold HoldSeats(string user, string show, params string[] seats){ var (r, h) = _service.Hold(user, show, seats); Assert.Equal(Result.Ok, r); return h!;}
[Fact]public void AHoldTakesAllItsSeatsOrNone(){ HoldSeats("ana", "eve", "A1", "A2"); Assert.Equal(Result.SeatTaken, _service.Hold("ben", "eve", ["A2", "A3"]).Item1); Assert.Equal(new[] { "A3", "P1", "P2" }, _service.AvailableSeats("eve"));}
[Fact]public void BadSeatRequestsAreRefused(){ Assert.Equal(Result.InvalidSeats, _service.Hold("ana", "eve", ["Z9"]).Item1); Assert.Equal(Result.InvalidSeats, _service.Hold("ana", "eve", ["A1", "A1"]).Item1); Assert.Equal(Result.InvalidSeats, _service.Hold("ana", "eve", []).Item1);}
[Fact]public void AnExpiredHoldFreesItsSeatsWithoutAnythingRunning(){ var h = HoldSeats("ana", "eve", "A1"); _now += TimeSpan.FromMinutes(5); Assert.Contains("A1", _service.AvailableSeats("eve")); HoldSeats("ben", "eve", "A1"); Assert.Equal(Result.HoldExpired, _service.Confirm(h.Id, "visa").Item1); Assert.Empty(_gateway.Charged);}
[Fact]public void ConfirmChargesTheQuotedPriceOnceAndSellsTheSeats(){ var h = HoldSeats("ana", "eve", "A1", "P1"); Assert.Equal(Result.Ok, _service.Confirm(h.Id, "visa").Item1); Assert.Equal(3000, _gateway.Charged[h.Id]); Assert.Equal(new[] { "A2", "A3", "P2" }, _service.AvailableSeats("eve")); Assert.Equal(Result.NoSuchHold, _service.Confirm(h.Id, "visa").Item1);}
[Fact]public void MatineesAreCheaper() => Assert.Equal(900, HoldSeats("ana", "mat", "A1").Price);
[Fact]public void ASlowPaymentKeepsTheSeats(){ var h = HoldSeats("ana", "eve", "A1"); _gateway.DuringCharge = () => { _now += TimeSpan.FromMinutes(10); // the bank page took ten minutes Assert.Equal(Result.SeatTaken, _service.Hold("ben", "eve", ["A1"]).Item1); }; Assert.Equal(Result.Ok, _service.Confirm(h.Id, "visa").Item1);}
[Fact]public void ADeclinedCardCanBeRetriedBeforeTheHoldRunsOut(){ var h = HoldSeats("ana", "eve", "A1"); Assert.Equal(Result.PaymentDeclined, _service.Confirm(h.Id, "bad").Item1); Assert.Equal(Result.Ok, _service.Confirm(h.Id, "visa").Item1);}
[Fact]public void ASecondPayClickIsTurnedAway(){ var h = HoldSeats("ana", "eve", "A1"); var second = new List<Result>(); _gateway.DuringCharge = () => second.Add(_service.Confirm(h.Id, "visa").Item1); Assert.Equal(Result.Ok, _service.Confirm(h.Id, "visa").Item1); Assert.Equal(new[] { Result.PaymentInProgress }, second); Assert.Single(_gateway.Charged);}
[Fact]public void CancellingRefundsByHowCloseTheShowIs(){ var early = _service.Confirm(HoldSeats("ana", "eve", "A1").Id, "visa").Item2!; Assert.Equal(Result.Ok, _service.Cancel(early.Id)); Assert.Equal(1200, _gateway.Refunded[early.PaymentKey]); Assert.Equal(Result.AlreadyCancelled, _service.Cancel(early.Id)); Assert.Contains("A1", _service.AvailableSeats("eve"));
var late = _service.Confirm(HoldSeats("ben", "eve", "A2").Id, "visa").Item2!; _now = new DateTimeOffset(2026, 5, 2, 16, 0, 0, TimeSpan.Zero); // three hours before Assert.Equal(Result.Ok, _service.Cancel(late.Id)); Assert.Equal(600, _gateway.Refunded[late.PaymentKey]);}
[Fact]public async Task RacingHoldsOnOverlappingSeatsLetExactlyOneWin(){ var start = new TaskCompletionSource(); var tries = Enumerable.Range(0, 16) .Select(i => Task.Run(async () => { await start.Task; string[] seats = i % 2 == 0 ? ["A1", "A2"] : ["A2", "A3"]; return _service.Hold($"u{i}", "eve", seats).Item1; })) .ToArray(); start.SetResult(); // release all sixteen at once var results = await Task.WhenAll(tries); Assert.Single(results, r => r == Result.Ok); Assert.Equal(3, _service.AvailableSeats("eve").Count); // the winner's two seats, nothing partial}// fakeGateway remembers each charge by key, declines the card "bad", and can run a step in// the middle of a charge to stand in for a slow bank page.type fakeGateway struct { mu sync.Mutex charged map[string]int64 refunded map[string]int64 duringCharge func()}
func (g *fakeGateway) Charge(key string, cents int64, card string) bool { if g.duringCharge != nil { g.duringCharge() } if card == "bad" { return false } g.mu.Lock() defer g.mu.Unlock() if _, seen := g.charged[key]; !seen { // same key, same charge g.charged[key] = cents } return true}
func (g *fakeGateway) Refund(key string, cents int64) { g.mu.Lock() defer g.mu.Unlock() if _, seen := g.refunded[key]; !seen { g.refunded[key] = cents }}
var ( screen = Screen{"s1", []Seat{{"A1", Regular}, {"A2", Regular}, {"A3", Regular}, {"P1", Premium}, {"P2", Premium}}} dune = Movie{"m1", "Dune", 155} eve = Show{"eve", dune, screen, time.Date(2026, 5, 2, 19, 0, 0, 0, time.UTC)} mat = Show{"mat", dune, screen, time.Date(2026, 5, 2, 14, 0, 0, 0, time.UTC)})
type world struct { mu sync.Mutex now time.Time gateway *fakeGateway svc *BookingService}
// newWorld starts 31 hours before the evening show.func newWorld() *world { w := &world{now: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC), gateway: &fakeGateway{charged: map[string]int64{}, refunded: map[string]int64{}}} var next atomic.Int64 ids := func() string { return fmt.Sprintf("id%d", next.Add(1)) } clock := func() time.Time { w.mu.Lock(); defer w.mu.Unlock(); return w.now } w.svc = NewBookingService(StandardPricing{time.UTC}, StandardRefunds{}, w.gateway, ids, clock) w.svc.AddShow(eve) w.svc.AddShow(mat) return w}
func (w *world) advance(d time.Duration) { w.mu.Lock(); w.now = w.now.Add(d); w.mu.Unlock() }
func (w *world) hold(t *testing.T, user, show string, seats ...string) *SeatHold { t.Helper() r, h := w.svc.Hold(user, show, seats) if r != OK { t.Fatalf("hold %v: %v", seats, r) } return h}
func expect[T comparable](t *testing.T, want, got T) { t.Helper() if want != got { t.Fatalf("want %v, got %v", want, got) }}
func TestAHoldTakesAllItsSeatsOrNone(t *testing.T) { w := newWorld() w.hold(t, "ana", "eve", "A1", "A2") r, _ := w.svc.Hold("ben", "eve", []string{"A2", "A3"}) expect(t, SeatTaken, r) expect(t, "[A3 P1 P2]", fmt.Sprint(w.svc.AvailableSeats("eve")))}
func TestBadSeatRequestsAreRefused(t *testing.T) { w := newWorld() for _, seats := range [][]string{{"Z9"}, {"A1", "A1"}, {}} { r, _ := w.svc.Hold("ana", "eve", seats) expect(t, InvalidSeats, r) }}
func TestAnExpiredHoldFreesItsSeatsWithoutAnythingRunning(t *testing.T) { w := newWorld() h := w.hold(t, "ana", "eve", "A1") w.advance(5 * time.Minute) expect(t, true, slices.Contains(w.svc.AvailableSeats("eve"), "A1")) w.hold(t, "ben", "eve", "A1") r, _ := w.svc.Confirm(h.ID, "visa") expect(t, HoldExpired, r) expect(t, 0, len(w.gateway.charged))}
func TestConfirmChargesTheQuotedPriceOnceAndSellsTheSeats(t *testing.T) { w := newWorld() h := w.hold(t, "ana", "eve", "A1", "P1") r, _ := w.svc.Confirm(h.ID, "visa") expect(t, OK, r) expect(t, int64(3000), w.gateway.charged[h.ID]) expect(t, "[A2 A3 P2]", fmt.Sprint(w.svc.AvailableSeats("eve"))) r, _ = w.svc.Confirm(h.ID, "visa") expect(t, NoSuchHold, r)}
func TestMatineesAreCheaper(t *testing.T) { w := newWorld() expect(t, int64(900), w.hold(t, "ana", "mat", "A1").Price)}
func TestASlowPaymentKeepsTheSeats(t *testing.T) { w := newWorld() h := w.hold(t, "ana", "eve", "A1") w.gateway.duringCharge = func() { w.advance(10 * time.Minute) // the bank page took ten minutes r, _ := w.svc.Hold("ben", "eve", []string{"A1"}) expect(t, SeatTaken, r) } r, _ := w.svc.Confirm(h.ID, "visa") expect(t, OK, r)}
func TestADeclinedCardCanBeRetriedBeforeTheHoldRunsOut(t *testing.T) { w := newWorld() h := w.hold(t, "ana", "eve", "A1") r, _ := w.svc.Confirm(h.ID, "bad") expect(t, PaymentDeclined, r) r, _ = w.svc.Confirm(h.ID, "visa") expect(t, OK, r)}
func TestASecondPayClickIsTurnedAway(t *testing.T) { w := newWorld() h := w.hold(t, "ana", "eve", "A1") var second []Result w.gateway.duringCharge = func() { r, _ := w.svc.Confirm(h.ID, "visa") second = append(second, r) } r, _ := w.svc.Confirm(h.ID, "visa") expect(t, OK, r) expect(t, PaymentInProgress, second[0]) expect(t, 1, len(w.gateway.charged))}
func TestCancellingRefundsByHowCloseTheShowIs(t *testing.T) { w := newWorld() _, early := w.svc.Confirm(w.hold(t, "ana", "eve", "A1").ID, "visa") expect(t, OK, w.svc.Cancel(early.ID)) expect(t, int64(1200), w.gateway.refunded[early.PaymentKey]) expect(t, AlreadyCancelled, w.svc.Cancel(early.ID)) expect(t, true, slices.Contains(w.svc.AvailableSeats("eve"), "A1"))
_, late := w.svc.Confirm(w.hold(t, "ben", "eve", "A2").ID, "visa") w.advance(28 * time.Hour) // three hours before expect(t, OK, w.svc.Cancel(late.ID)) expect(t, int64(600), w.gateway.refunded[late.PaymentKey])}
func TestRacingHoldsOnOverlappingSeatsLetExactlyOneWin(t *testing.T) { w := newWorld() var ok atomic.Int32 var wg sync.WaitGroup start := make(chan struct{}) for i := range 16 { seats := []string{"A1", "A2"} if i%2 == 1 { seats = []string{"A2", "A3"} } wg.Add(1) go func() { defer wg.Done() <-start if r, _ := w.svc.Hold(fmt.Sprintf("u%d", i), "eve", seats); r == OK { ok.Add(1) } }() } close(start) wg.Wait() expect(t, int32(1), ok.Load()) expect(t, 3, len(w.svc.AvailableSeats("eve"))) // the winner's two seats, nothing partial}import { test } from 'node:test';import assert from 'node:assert/strict';
// A card network that remembers each charge by key, declines the card "bad", and can run a// step in the middle of a charge to stand in for a slow bank page.class FakeGateway implements PaymentGateway { charged = new Map<string, number>(); refunded = new Map<string, number>(); duringCharge: () => Promise<void> = async () => {};
async charge(key: string, cents: number, card: string): Promise<boolean> { await this.duringCharge(); if (card === 'bad') return false; if (!this.charged.has(key)) this.charged.set(key, cents); // same key, same charge return true; }
async refund(key: string, cents: number): Promise<void> { if (!this.refunded.has(key)) this.refunded.set(key, cents); }}
const MOVIE: Movie = { id: 'm1', title: 'Dune', minutes: 155 };const SCREEN: Screen = { id: 's1', seats: [ { id: 'A1', type: 'regular' }, { id: 'A2', type: 'regular' }, { id: 'A3', type: 'regular' }, { id: 'P1', type: 'premium' }, { id: 'P2', type: 'premium' }, ],};const EVENING: Show = { id: 'eve', movie: MOVIE, screen: SCREEN, startsAt: Date.parse('2026-05-02T19:00:00Z') };const MATINEE: Show = { id: 'mat', movie: MOVIE, screen: SCREEN, startsAt: Date.parse('2026-05-02T14:00:00Z') };
// 31 hours before the evening show.function world() { const w = { now: Date.parse('2026-05-01T12:00:00Z'), gateway: new FakeGateway() } as { now: Instant; gateway: FakeGateway; service: BookingService; hold: (user: string, show: string, ...seats: string[]) => SeatHold; }; let next = 0; w.service = new BookingService(new StandardPricing('UTC'), new StandardRefunds(), w.gateway, () => `id${++next}`, () => w.now); w.service.addShow(EVENING); w.service.addShow(MATINEE); w.hold = (user, show, ...seats) => { const r = w.service.hold(user, show, seats); assert.equal(r.result, 'ok'); return r.hold!; }; return w;}
test('a hold takes all its seats or none', () => { const w = world(); w.hold('ana', 'eve', 'A1', 'A2'); assert.equal(w.service.hold('ben', 'eve', ['A2', 'A3']).result, 'seat-taken'); assert.deepEqual(w.service.availableSeats('eve'), ['A3', 'P1', 'P2']);});
test('bad seat requests are refused', () => { const w = world(); for (const seats of [['Z9'], ['A1', 'A1'], []]) { assert.equal(w.service.hold('ana', 'eve', seats).result, 'invalid-seats'); }});
test('an expired hold frees its seats without anything running', async () => { const w = world(); const h = w.hold('ana', 'eve', 'A1'); w.now += 5 * 60_000; assert.ok(w.service.availableSeats('eve').includes('A1')); w.hold('ben', 'eve', 'A1'); assert.equal((await w.service.confirm(h.id, 'visa')).result, 'hold-expired'); assert.equal(w.gateway.charged.size, 0);});
test('confirm charges the quoted price once and sells the seats', async () => { const w = world(); const h = w.hold('ana', 'eve', 'A1', 'P1'); assert.equal((await w.service.confirm(h.id, 'visa')).result, 'ok'); assert.deepEqual([...w.gateway.charged], [[h.id, 3000]]); assert.deepEqual(w.service.availableSeats('eve'), ['A2', 'A3', 'P2']); assert.equal((await w.service.confirm(h.id, 'visa')).result, 'no-such-hold');});
test('matinees are cheaper', () => { assert.equal(world().hold('ana', 'mat', 'A1').price, 900);});
test('a slow payment keeps the seats', async () => { const w = world(); const h = w.hold('ana', 'eve', 'A1'); w.gateway.duringCharge = async () => { w.now += 10 * 60_000; // the bank page took ten minutes assert.equal(w.service.hold('ben', 'eve', ['A1']).result, 'seat-taken'); }; assert.equal((await w.service.confirm(h.id, 'visa')).result, 'ok');});
test('a declined card can be retried before the hold runs out', async () => { const w = world(); const h = w.hold('ana', 'eve', 'A1'); assert.equal((await w.service.confirm(h.id, 'bad')).result, 'payment-declined'); assert.equal((await w.service.confirm(h.id, 'visa')).result, 'ok');});
test('a second pay click is turned away', async () => { const w = world(); const h = w.hold('ana', 'eve', 'A1'); const second: Result[] = []; w.gateway.duringCharge = async () => { second.push((await w.service.confirm(h.id, 'visa')).result); }; assert.equal((await w.service.confirm(h.id, 'visa')).result, 'ok'); assert.deepEqual(second, ['payment-in-progress']); assert.equal(w.gateway.charged.size, 1);});
test('cancelling refunds by how close the show is', async () => { const w = world(); const early = (await w.service.confirm(w.hold('ana', 'eve', 'A1').id, 'visa')).booking!; assert.equal(await w.service.cancel(early.id), 'ok'); assert.equal(w.gateway.refunded.get(early.paymentKey), 1200); assert.equal(await w.service.cancel(early.id), 'already-cancelled'); assert.ok(w.service.availableSeats('eve').includes('A1'));
const late = (await w.service.confirm(w.hold('ben', 'eve', 'A2').id, 'visa')).booking!; w.now = Date.parse('2026-05-02T16:00:00Z'); // three hours before assert.equal(await w.service.cancel(late.id), 'ok'); assert.equal(w.gateway.refunded.get(late.paymentKey), 600);});Go deeperEvery edge case, and where it is handled2 min
| Case | What happens | Where |
|---|---|---|
| Unknown seat, repeated seat, too many seats | INVALID_SEATS, nothing held |
BookingService.valid |
| One of several seats taken | SEAT_TAKEN, none held |
ShowSeats.hold |
| Customer abandons checkout | Seats free at the deadline; nothing runs | SeatHold.expired |
| Pay after the deadline | HOLD_EXPIRED, no charge |
ShowSeats.pin |
| Pay after someone else held the seat again | HOLD_EXPIRED: the seat points at the other hold |
ShowSeats.isLive |
| Bank page takes ten minutes | Hold is pinned, seats stay | ShowSeats.pin |
| Double-clicked Pay | PAYMENT_IN_PROGRESS, one charge |
ShowSeats.pin |
| Charge request times out and is sent again | Same key, same charge | PaymentGateway |
| Card declined | Hold unpinned, original deadline, retry allowed | BookingService.confirm |
| Release from another tab while paying | Ignored; the confirm decides | ShowSeats.release |
| Cancel twice | ALREADY_CANCELLED, refund sent once |
ShowSeats.cancel |
| Cancel after the show started | SHOW_STARTED |
ShowSeats.cancel |
| Server crashes between pin and sell | Pin deadline plus a reconciler asking the gateway by key | an extension, stage 3 |
Wrap up
Section titled “Wrap up”The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| Decision | We chose | We gave up |
|---|---|---|
| Where seat state lives | One inventory per show | Nothing; seat state without a show is meaningless |
| Abandoned checkouts | Holds with a deadline, expired by comparison | Expired holds stay in memory until a cleanup |
| Paying | Pin, charge outside the lock, then sell | A crash after pinning needs a reconciler |
| Double charges | The hold ID as the payment key | The gateway must support idempotency keys |
| Price | Quoted when held, stored on the hold | A price drop mid-checkout isn’t passed on |
| Refunds | A policy, and cancel as one step per show | One more interface |
| Two clicks, one seat | Lock per show; a conditional update in the database | Each show’s seats live in one place |
Go deeperGenuinely different designs, and when to pick them2 min
Timers instead of comparisons. Many designs schedule a task per hold that frees the seats at the deadline. It makes “seats came back” an event, which suits a waitlist. The cost is that a delayed or lost task keeps seats off sale, and a confirm has to cancel its task. Comparing times is simpler and stays correct when the scheduler is slow. A cleanup job can add the event when needed.
Seat state in Redis. Use one key per seat per show, set with NX and a 5-minute expiry. Holds
then run out by themselves, and the load spreads well. Holding several seats as one step needs a
Lua script. And the final sale still belongs in the database, so seat state now lives in two
places. Large ticket sites use it in front of the database for the busiest shows.
A queue in front of busy shows. Some releases sell out in seconds. For those, sites put buyers in a virtual waiting room, and let a few hundred at a time onto the seat map. The booking design doesn’t change. The queue just keeps the pressure on one show’s lock at a level it can handle.
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 0 to 3. Assigned seats, hold length, payment, cancellations. Skip search and reviews.
- 3 to 5. The promises. Name “no seat sold twice” as the hard one.
- 5 to 25. Shows and seats, holds by comparison, pin and charge, cancel. Code
ShowSeats.hold,pinandconfirm, and describe pricing and refunds. - 25 to 27. Walk through the full diagram: one inventory per show, time compared not scheduled.
- 27 to 34. Two clicks on C7: the lock per show, the payment outside it, and the database update.
- 34 to 45. The waitlist or a busy release, then your own questions.
If time is short, skip cancellations and spend it on stage 3 and the race. Holding and paying safely is what this question is about.
Follow-up questions
Section titled “Follow-up questions”Add a waitlist for sold-out shows: when seats come back, offer them to people waiting.
Whether you see that seats come back in two ways: a cancel, and a hold that runs out with nothing running. The second one has no moment to hook into.
Most extensions fit into one place:
| Extension | What changes | Touches the seat logic? |
|---|---|---|
| Waitlist | Cancels notify directly; expired holds are found by the cleanup job, which then notifies | The cleanup only |
| Prices that change with demand | A different Pricing |
No |
| Coupons and gift cards | A discount step between quote and charge, with its own key | No |
| Food and drink | Extra lines on the hold’s quote | No |
| Groups over 10 | A higher limit for some users, or a separate sales flow | valid only |
| Blocked seats (broken, VIP) | Sold to a “house” booking when the show is created | No |
| No single empty seat between groups | valid refuses a request that would leave one |
valid only |
Go deeperAdding a waitlist, step by step2 min
Seats come back on sale in two ways, and the waitlist must catch both.
A cancel is easy. Something happens at a known moment: cancel runs. So cancel can tell the
waitlist “these seats are free for this show”.
A hold that runs out is the tricky one. Nothing happens at 19:05. The seats just start looking free to anyone who checks. That was the whole point of stage 2, and it means there’s no moment to hook into.
So the waitlist hangs off the cleanup job. Here’s the plan:
- A queue per show. Members join the waitlist for a show, with how many seats they want.
- The cleanup job notices. It already runs every minute or so, deleting expired holds to save memory. Now, when it deletes one, it also tells the waitlist which seats came free.
- Offer, don’t sell. For the first person waiting, the system creates a new hold in their name, for those seats, with its own five-minute deadline, and sends them a message: “Your seats are ready. Pay within 5 minutes.”
- If they don’t pay, the hold runs out like any other, the cleanup job sees it, and the seats go to the next person.
Notice the trade-off. A waitlisted customer can see seats a minute late, because the cleanup job runs every minute. That’s fine for a waitlist. And correctness still never depends on the job: a customer browsing the seat map sees the seat free at 19:05 exactly, as before.
Go deeperAdding coupons and gift cards, step by step2 min
A coupon changes the price, and a gift card changes how the price is paid. Both touch money, so both need the same care as the card charge. They must be safe to repeat. And they must not be used up by a purchase that never finishes.
- Apply the coupon to the quote. When the customer enters a coupon, the system checks it. It stores the discount on the hold, next to the price quoted in stage 3. The customer sees the new total. Nothing is used up yet. If the hold runs out, the coupon is still unused.
- Reserve the coupon when pinning. A single-use coupon must not work on two holds at once. So when the hold is pinned, the coupon is marked “reserved for this hold ID”. A second hold trying the same coupon is refused while the first is paying.
- Use it when the seats are sold. After the charge succeeds and the seats are sold, the coupon becomes “used”. If the card is declined, the reservation is released, exactly like the unpin.
- Gift cards work like a second payment. The gift card is charged first, with the hold ID as its key, and the card covers the rest. If the card charge fails, the gift card charge is reversed, with the same key.
Notice the pattern: every new kind of money follows the same three moments as the seats. Reserve when pinning, finish when selling, undo when the payment fails. And the hold ID ties them all together, so a reconciler can always ask “what happened to this purchase?” in one place.
Go deeperThree more follow-up questions2 min
Interviewer asksHow does the seat map stay up to date for ten thousand viewers?
It doesn’t need to be exact. The map is a hint, refreshed every few seconds or pushed to the
browser, and the hold is the truth. If a seat was taken since the map was drawn, the hold says
SEAT_TAKEN and the map refreshes. Making every viewer’s map exact would cost far more than the
occasional “sorry, just taken”.
Interviewer asksWhy is the hold ID a good payment key?
Because it names exactly one attempt to buy exactly these seats. A retried confirm, a double click, or a recovery job all carry the same ID, so the gateway sees one charge. A new attempt after a hold runs out gets a new hold and a new ID, which is correct: it’s a different purchase.
Interviewer asksWhat if the payment succeeds but the server crashes before selling?
The customer has paid, and the seats are pinned, not sold. A reconciler finds pins older than their own deadline, and asks the gateway about each hold ID. It sells the seats if the charge went through, or unpins them if it didn’t. Without the key, there would be no way to ask.
Further reading
Section titled “Further reading”- Strategy pattern, State pattern
- Design an ATM, the same hold, then charge, then settle shape with cash instead of seats
- Design a parking lot, another per-unit inventory with concurrent claims
- Stripe: idempotent requests, how payment keys work in practice
- PostgreSQL: UPDATE, the row count a conditional update returns
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.