Skip to content

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.

Senior29 min core · 45 min full depth

“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.

If you only have a minute, this is the design. The rest of the page explains how we get here.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.

Interviewerminute 0

Design a movie ticket booking system.

What they are testing

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

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”
Interviewerminute 4

Start simple: a customer picks two seats for tonight’s show and books them. What are your classes?

What they are testing

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.

Stage 1: catalog data and a booking service

makes

BookingService

-sold: Map<show,SeatMap>

+addShow(Show)

+availableSeats(showId) : List

+book(userId, showId, seatIds) : Booking

Show

+id

+movie: Movie

+screen: Screen

+startsAt

Screen

+id

+seats: List<Seat>

«value»

Seat

+id

+type: SeatType

«value»

Movie

+title

+minutes

Booking

+id

+show

+userId

+seatIds

makes

BookingService

-sold: Map<show,SeatMap>

+addShow(Show)

+availableSeats(showId) : List

+book(userId, showId, seatIds) : Booking

Show

+id

+movie: Movie

+screen: Screen

+startsAt

Screen

+id

+seats: List<Seat>

«value»

Seat

+id

+type: SeatType

«value»

Movie

+title

+minutes

Booking

+id

+show

+userId

+seatIds

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
BookingService.javabooking_service.pyBookingService.csbooking_service.gobooking-service.ts
// 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;
}
}

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.

Interviewerminute 8

A customer picks seats and then closes the tab without paying. What happens to the seats?

What they are testing

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.

Stage 2: a seat's life within one show

hold

confirm before the deadline

release

deadline passes (nothing runs)

cancel

Free

Held

Sold

hold

confirm before the deadline

release

deadline passes (nothing runs)

cancel

Free

Held

Sold

Scroll or pinch to zoom, drag to pan
  • Color key
  • State
Stage 2: holds and a per-show inventory

one per show

returns

BookingService

-holds: Map<id,SeatHold>

+hold(userId, showId, seatIds) : HoldResult

+confirm(holdId) : BookingResult

+release(holdId)

ShowSeats

-held: Map<seat,SeatHold>

-sold: Map<seat,bookingId>

+isFree(seatId, now) : bool

+hold(SeatHold, now) : Result

+isLive(SeatHold, now) : bool

+sell(SeatHold, bookingId)

+release(SeatHold)

SeatHold

+id

+showId

+seatIds

+expiresAt

+expired(now) : bool

Show

«enum»

Result

OK

INVALID_SEATS

SEAT_TAKEN

NO_SUCH_HOLD

HOLD_EXPIRED

one per show

returns

BookingService

-holds: Map<id,SeatHold>

+hold(userId, showId, seatIds) : HoldResult

+confirm(holdId) : BookingResult

+release(holdId)

ShowSeats

-held: Map<seat,SeatHold>

-sold: Map<seat,bookingId>

+isFree(seatId, now) : bool

+hold(SeatHold, now) : Result

+isLive(SeatHold, now) : bool

+sell(SeatHold, bookingId)

+release(SeatHold)

SeatHold

+id

+showId

+seatIds

+expiresAt

+expired(now) : bool

Show

«enum»

Result

OK

INVALID_SEATS

SEAT_TAKEN

NO_SUCH_HOLD

HOLD_EXPIRED

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
BookingService.javabooking_service.pyBookingService.csbooking_service.gobooking-service.ts
// 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());
}
}

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.

Interviewerminute 14

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?

What they are testing

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:

  1. 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.
  2. 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.
  3. 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.

Sequence: a slow payment that keeps its seats
BenPaymentGatewayShowSeatsBookingServiceAnaBenPaymentGatewayShowSeatsBookingServiceAnabank page, 60 secondsconfirm(h7, card)pin(h7)ok (h7 can't expire now)charge(key h7, 30.00)hold(C7)hold(C7)SEAT_TAKEN (h7 is pinned)approvedsell(h7)booking b3BenPaymentGatewayShowSeatsBookingServiceAnaBenPaymentGatewayShowSeatsBookingServiceAnabank page, 60 secondsconfirm(h7, card)pin(h7)ok (h7 can't expire now)charge(key h7, 30.00)hold(C7)hold(C7)SEAT_TAKEN (h7 is pinned)approvedsell(h7)booking b3
Scroll or pinch to zoom, drag to pan
  • Color key
  • Interface
  • Entity
  • Facade
Stage 3: pricing, the gateway, and pinning

BookingService

+hold(userId, showId, seatIds) : HoldResult

+confirm(holdId, card) : BookingResult

+release(holdId)

ShowSeats

+pin(SeatHold, now) : Result

+unpin(SeatHold)

+sell(SeatHold, bookingId)

+release(SeatHold) : bool

SeatHold

+price

+paying

+expired(now) : bool

«interface»

Pricing

+priceOf(Show, Seat) : long

StandardPricing

«interface»

PaymentGateway

+charge(key, cents, card) : bool

+refund(key, cents)

BookingService

+hold(userId, showId, seatIds) : HoldResult

+confirm(holdId, card) : BookingResult

+release(holdId)

ShowSeats

+pin(SeatHold, now) : Result

+unpin(SeatHold)

+sell(SeatHold, bookingId)

+release(SeatHold) : bool

SeatHold

+price

+paying

+expired(now) : bool

«interface»

Pricing

+priceOf(Show, Seat) : long

StandardPricing

«interface»

PaymentGateway

+charge(key, cents, card) : bool

+refund(key, cents)

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
BookingService.javashow_seats.pyShowSeats.csbooking_service.gobooking-service.ts
// 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());
}
}

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.

Interviewerminute 20

Customers can cancel until the show starts: full refund a day ahead, half refund up to two hours before, nothing after. Add it.

What they are testing

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.

Stage 4: bookings that can be cancelled

BookingService

+cancel(bookingId) : Result

ShowSeats

+cancel(Booking, now) : Result

Booking

+id

+seatIds

+price

+paymentKey

+cancelled

«interface»

RefundPolicy

+refundFor(Booking, now) : long

StandardRefunds

«interface»

PaymentGateway

+refund(key, cents)

BookingService

+cancel(bookingId) : Result

ShowSeats

+cancel(Booking, now) : Result

Booking

+id

+seatIds

+price

+paymentKey

+cancelled

«interface»

RefundPolicy

+refundFor(Booking, now) : long

StandardRefunds

«interface»

PaymentGateway

+refund(key, cents)

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
BookingService.javabooking_service.pyBookingService.csbooking_service.gobooking-service.ts
// 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());
}
}

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.

Here is everything together. Each class is here because a requirement needed it.

Movie ticket booking: full class diagram

returns

BookingService

-shows: Map<id,ShowSeats>

-holds: Map<id,SeatHold>

-bookings: Map<id,Booking>

+availableSeats(showId) : List

+hold(userId, showId, seatIds) : HoldResult

+confirm(holdId, card) : BookingResult

+release(holdId)

+cancel(bookingId) : Result

ShowSeats

-held: Map<seat,SeatHold>

-sold: Map<seat,bookingId>

+hold(SeatHold, now) : Result

+pin(SeatHold, now) : Result

+unpin(SeatHold)

+sell(SeatHold, bookingId)

+release(SeatHold) : bool

+cancel(Booking, now) : Result

SeatHold

+id

+seatIds

+expiresAt

+price

+paying

+expired(now) : bool

Booking

+id

+seatIds

+price

+paymentKey

+cancelled

Show

+id

+startsAt

Screen

+seats

«value»

Seat

+id

+type

«value»

Movie

+title

«interface»

Pricing

+priceOf(Show, Seat) : long

«interface»

RefundPolicy

+refundFor(Booking, now) : long

«interface»

PaymentGateway

+charge(key, cents, card) : bool

+refund(key, cents)

StandardPricing

StandardRefunds

«enum»

SeatType

REGULAR

PREMIUM

«enum»

Result

returns

BookingService

-shows: Map<id,ShowSeats>

-holds: Map<id,SeatHold>

-bookings: Map<id,Booking>

+availableSeats(showId) : List

+hold(userId, showId, seatIds) : HoldResult

+confirm(holdId, card) : BookingResult

+release(holdId)

+cancel(bookingId) : Result

ShowSeats

-held: Map<seat,SeatHold>

-sold: Map<seat,bookingId>

+hold(SeatHold, now) : Result

+pin(SeatHold, now) : Result

+unpin(SeatHold)

+sell(SeatHold, bookingId)

+release(SeatHold) : bool

+cancel(Booking, now) : Result

SeatHold

+id

+seatIds

+expiresAt

+price

+paying

+expired(now) : bool

Booking

+id

+seatIds

+price

+paymentKey

+cancelled

Show

+id

+startsAt

Screen

+seats

«value»

Seat

+id

+type

«value»

Movie

+title

«interface»

Pricing

+priceOf(Show, Seat) : long

«interface»

RefundPolicy

+refundFor(Booking, now) : long

«interface»

PaymentGateway

+charge(key, cents, card) : bool

+refund(key, cents)

StandardPricing

StandardRefunds

«enum»

SeatType

REGULAR

PREMIUM

«enum»

Result

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
movie-ticket-booking· read only
AllInOne.java
AllInOne.java
// 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;
}
}
Read onlyJava 21Every file builds in CI18 files282 lines
all_in_one.py
all_in_one.py
Read onlyPython 3.12Every file builds in CI17 files308 lines
AllInOne.cs
AllInOne.cs
Read onlyC# · .NET 10Every file builds in CI16 files279 lines
all_in_one.go
all_in_one.go
Read onlyGo 1.24Every file builds in CI16 files417 lines
all-in-one.ts
all-in-one.ts
Read onlyTypeScript, strictEvery file builds in CI19 files335 lines

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 calls BookingService, 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).

So far, requests arrive one at a time. Now a blockbuster opens, and ten thousand people are on the same seat map.

Interviewerminute 26

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.

What they are testing

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.

Sequence: two clicks on C7, with a lock per show
BenShowSeats (eve)AnaBenShowSeats (eve)AnaAna's request holds the show lock, Ben's waitsBen's request runs: C7 is heldhold(C7)hold(C7)OK, held until 19:05SEAT_TAKENBenShowSeats (eve)AnaBenShowSeats (eve)AnaAna's request holds the show lock, Ben's waitsBen's request runs: C7 is heldhold(C7)hold(C7)OK, held until 19:05SEAT_TAKEN
Scroll or pinch to zoom, drag to pan
  • 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
ShowSeats.javashow_seats.pyShowSeats.csshow_seats.godb.ts
// 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();
}
}

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.

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.

FakeGateway.javaworld.pyFakeGateway.csworld.gofake-gateway.ts
// 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 show
FakeGateway gateway = new FakeGateway();
AtomicInteger nextId = new AtomicInteger();
BookingService service;
@BeforeEach
void 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();
}
@Test
void 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"));
}
@Test
void 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());
}
@Test
void 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());
}
@Test
void 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());
}
@Test
void matineesAreCheaper() {
assertEquals(900, hold("ana", "mat", "A1").price);
}
@Test
void 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());
}
@Test
void 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());
}
@Test
void 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());
}
@Test
void 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));
}
@Test
void 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
}
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
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.

  • 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, pin and confirm, 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.

Interviewerminute 33

Add a waitlist for sold-out shows: when seats come back, offer them to people waiting.

What they are testing

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:

  1. A queue per show. Members join the waitlist for a show, with how many seats they want.
  2. 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.
  3. 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.”
  4. 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.