This gives away every stage. For a clean attempt, practise it first, then come back.
The short version
- A physical seat is the same every night. Whether it’s free depends on the show. So availability lives in a per-show seat map, never on the Seat class.
- Holding seats is all-or-nothing, done in one step under the show’s lock. A hold carries an expiry time, and an expired hold simply counts as free: no cleanup job decides correctness.
- Never hold a lock while calling a payment gateway. Claim, unlock, pay, then lock again to confirm, and refund if the hold ran out meanwhile.
- A hold moves through states: held, paying, booked, closed. Moving from held to paying with one compare-and-set means a double-click on Pay can only charge once.
- If you ever lock seats one by one, lock them in a fixed order. In request order, two people picking the same two seats got stuck 849 times in 4,000 tries.
How this walkthrough is built
If you’ve done the ticket booking system design question, you’ve seen this problem from the architecture side: waiting rooms, caches, databases. This is the other half: the classes inside the booking service, and the code that keeps seats correct. The two fit together.
| Step | What you do | What comes out of it |
|---|---|---|
| 1. Understand the problem | Ask, number the requirements | What booking must guarantee |
| 2. Break it into entities | Nouns, verbs, what changes | Movies, screens, shows, seats, holds, bookings |
| 3. Choose a direction | Top-down to design, bottom-up to code | The order to work in |
| 4. Design each class | Knows, does, doesn’t do | A card per class |
| 5. Connect the classes | Relationships | Who owns whom |
| 6. Draw the diagram | Three versions | The full picture |
| 7. Name the patterns | Where they earned a place | Strategy, Decorator, a small state machine |
| 8. Walk the flows | Hold, pay, expire, cancel | Gaps found before coding |
| 9. Write the code | Bottom-up | Code that compiles and runs |
| 10. Make it safe | Same seat, double clicks, deadlocks | One lock per show, and why |
| 11. Extend it | Peak pricing, no single gaps, more | New classes, no edits |
Step 1Understand the problem
Picture the real thing first
You open the app on a Saturday afternoon. You pick a film, a cinema, and the 7:30 pm show. A seat map appears: grey seats are taken, the back rows cost more, there are two recliners at the very back. You tap B2 and B3, and a timer starts: ten minutes to pay. You pay by UPI. A ticket appears. On Monday you can’t go after all, so you cancel and get your money back.
Hints: film, cinema, show, seat map, taken, cost more, tap, timer, pay, ticket, cancel, money back.
Ask before you design
Group the questions: what’s being sold, choosing seats, holding and paying, cancelling, and how busy it gets.
Interviewer: Design a movie ticket booking system, like BookMyShow.
You: Let me check the shape first. Cities have cinemas, cinemas have screens, and each screen runs several shows a day?
Interviewer: Yes.
You: Do people pick exact seats?
Interviewer: Yes, from a seat map.
You: Do prices depend on the seat?
Interviewer: Yes: regular, premium and recliner seats cost different amounts. And weekend evenings may cost more.
You: How long can someone hold seats while paying?
Interviewer: Ten minutes.
You: Can a booking be cancelled?
Interviewer: Before the show starts, with a full refund.
You: How do payments work?
Interviewer: Through a payment gateway. It can decline, be slow, or time out.
You: How many seats in one booking?
Interviewer: Up to ten.
You: Is the same seat ever sold to two people?
Interviewer: Never.
You: How busy does it get?
Interviewer: A big release on a Friday: thousands of people on the same show.
You: Food, offers, reviews?
Interviewer: Out of scope.
| Question | What the answer decided |
|---|---|
| City → cinema → screen → show | The structure, and that a show is what’s actually sold |
| Exact seats | Every seat is a small piece of contested data |
| Price by seat, weekends more | Pricing is a strategy, and rules stack |
| Ten-minute hold | Holds need an expiry that works even if nothing cleans up |
| Cancel before the show | Bookings change state; refunds go through the gateway |
| Gateway can be slow or decline | Never hold a lock while paying; handle late payments |
| Up to ten seats | Holds are multi-seat and all-or-nothing |
| Never sold twice | The one guarantee that can’t bend |
| Thousands on one show | Contention on one show’s seats; locks must be short |
Functional requirements
- FR1. Cinemas have screens; each screen has a fixed layout of seats of different types.
- FR2. A show is one movie on one screen at one time. Users see which seats are free for a show.
- FR3. A user can hold 1 to 10 seats for a show for 10 minutes, all or nothing.
- FR4. The user pays; on success, the hold becomes a confirmed booking.
- FR5. Price depends on seat type and may depend on when the show is.
- FR6. A booking can be cancelled before the show starts, with a refund.
- FR7. Something can react to new bookings (tickets, emails).
Non-functional requirements
- NFR1: Never sell a seat twice. Even with thousands of users on one show.
- NFR2: Holds expire on time, even if no background job runs.
- NFR3: Payment is safe: never charged twice for one hold, and refunded if the seats were lost.
- NFR4: Fast under contention: locks are held for microseconds, never across a network call.
- NFR5: Easy to extend: new pricing rules and seat rules are added, not edited in.
- NFR6: Testable: time and payments can be faked.
Assumptions we’ll write down
| Assumption | Why it’s reasonable |
|---|---|
Money is whole paise in a long | Exact; see Splitwise |
| The clock is passed in | Tests can move time forward ten minutes instantly |
| A hold gets 5 extra minutes once payment starts | So a normal payment never fails because the timer ran out mid-payment |
| One hold, one payment attempt at a time | Two clicks on Pay shouldn’t start two payments |
| Everything in memory | The system design walkthrough covers the database version |
Step 2Break it into entities
Pull out the nouns
City, cinema, screen, seat, seat type, seat map, movie, show, show time, price, user, hold, timer, payment, booking, ticket, refund.
Three questions that decide if a noun is a class
| Noun | Own data? | Own rules? | Changes alone? | Becomes |
|---|---|---|---|---|
| Movie | title, length | — | — | Movie record |
| Cinema, city | name, screens | — | — | outside our core (a list of screens is enough) |
| Screen | its seats | — | — | Screen record |
| Seat | id, type | — | — | Seat record: no status (the trap below) |
| Seat type | — | — | — | SeatType enum |
| Show | movie, screen, start time | — | — | Show record |
| Seat map for a show | who holds or owns each seat | one owner per seat; holds expire | — | ShowSeats class |
| Hold, timer | user, seats, expiry | — | — | Hold record |
| Booking, ticket | user, seats, amount, payment, status | can only be cancelled once | — | Booking record |
| Price | — | how a seat is priced | yes | PricingStrategy interface |
| Payment, refund | — | — | yes: gateways change | PaymentGateway interface |
| User | — | — | — | an id (accounts belong to another service) |
The trap: a status on the seat
This is the mistake almost everyone makes first:
class Seat {
String id;
SeatType type;
SeatStatus status; // FREE, HELD, BOOKED
}Ask: is seat B2 free? The only honest answer is “for which show?” B2 is booked for the 7:30 pm show, free for the 10:45 pm show, and held by someone else for tomorrow’s matinee. The physical seat never changes. What changes is who has it for a particular show.
Put status on Seat and every show on that screen shares one status. The first booking of the day marks B2 booked for every show. Copying the screen’s seats into each show “fixes” it, but now you have thousands of copies of every seat, and the word Seat means two different things.
So split them cleanly:
Seatis the physical seat: an id and a type. It never changes. Shared by every show on that screen.ShowSeatsis one show’s seat map: for each seat, who holds or owns it, and until when. One per show.
That one decision makes the whole rest of the design simpler. Say it out loud early.
Pull out the verbs
List free seats, hold seats, check they’re free, start paying, extend the hold, charge, confirm, issue a ticket, let a hold expire, release a hold, cancel, refund, price a seat.
| Use case | Who starts it | Verbs inside it |
|---|---|---|
| UC1: See the seat map | a user | list free seats for a show |
| UC2: Hold seats | a user | check all are free, claim all, set an expiry |
| UC3: Pay and confirm | a user | start paying once, extend the hold, charge, confirm or refund |
| UC4: Give up | a user, or time | release the hold, or let it expire |
| UC5: Cancel | a user | check the show hasn’t started, cancel once, refund, free the seats |
Give each job to the class that knows the most
| Job | Information needed | Owner |
|---|---|---|
| Is this seat free for this show? | that show’s claims, and the time | ShowSeats |
| Claim several seats, all or none | that show’s claims | ShowSeats |
| How much is this seat for this show? | seat type, show time, a policy | PricingStrategy |
| Charge and refund | the outside world | PaymentGateway |
| Which state is this hold in? Is payment already running? | every hold’s state | BookingService |
| Turn a hold into a booking | the hold, the seat map, the gateway | BookingService |
CRC cards
| Class | Responsibilities | Collaborators |
|---|---|---|
ShowSeats | claim, extend, book, release, cancel seats for one show; list free seats | — |
PricingStrategy | price a seat for a show | Show, Seat |
PaymentGateway | charge with an idempotency key; refund | — |
BookingService | hold, confirm, release, cancel; hold states; tell listeners | ShowSeats, PricingStrategy, PaymentGateway, Clock |
Find what will change
| Will change | Hint | Becomes |
|---|---|---|
| Pricing rules | “weekend evenings may cost more” | PricingStrategy (and decorators) |
| The gateway | gateways get swapped; tests need a fake | PaymentGateway |
| Who reacts to bookings | tickets, emails, loyalty points | BookingListener |
| Time | tests can’t wait ten minutes | java.time.Clock, passed in |
Step 3Top-down or bottom-up?
Top-down: the app calls hold(user, show, seats) and confirm(hold). Holding needs a per-show seat map that can claim several seats at once. Confirming needs pricing, a gateway, and a way to turn a hold into a booking only if it’s still valid.
Bottom-up: first ShowSeats, the piece that must never be wrong, tested with a fake clock until holds, expiry and booking behave exactly. Then the records, pricing and gateway, then the service.
Step 4Design each class
BookingService: the front door
Job: the API the app talks to. It coordinates; the seat map, pricing and gateway do the real work.
| Knows | Does | Doesn’t do |
|---|---|---|
shows, and a ShowSeats per show | hold(user, show, seats) → Hold | decide if a seat is free (ShowSeats does) |
| holds, and each hold’s state | confirm(hold, user) → Booking | price seats (pricing does) |
| bookings, by id and by hold | release(hold), cancel(booking, user) | talk to banks (the gateway does) |
| clock, pricing, gateway, hold times | freeSeats(show) |
Each hold moves through four states: held (seats claimed, timer running), paying (a payment is in progress), booked, and closed (released, expired or failed). The service keeps these in a concurrent map and moves between them with compare-and-set, which step 8 shows is what stops a double click from paying twice.
ShowSeats: one show’s seat map
Job: for one show, know who has each seat and until when, and change that only in safe, all-or-nothing steps.
| Knows | Does | Doesn’t do |
|---|---|---|
| the screen’s seat ids | tryHold(hold, seats, now, until) | know about users, money or movies |
| for each taken seat: which hold, until when, booked or not | extend, book, release, cancel | |
free(now) |
A seat with no claim is free. A seat claimed by a hold whose time has passed is also free: every method checks the time. So expiry is exact even if nothing ever cleans up old claims. Booked seats never expire.
Every method is synchronized on the ShowSeats object. One lock per show, held for microseconds. Step 10 explains why per show, and what goes wrong with a lock per seat.
Seat, Screen, Movie, Show: the catalogue
Plain records that never change. Screen holds its seats and can find one by id. Show ties a movie to a screen and a start time. Because they never change, every thread can read them freely.
Hold and Booking: what the user has
Hold is a record: id, user, show, seats, and when it expires. Booking is a record with a status, confirmed or cancelled. Cancelling makes a new Booking with the new status rather than changing the old one, so a booking someone already has in hand never changes under them.
PricingStrategy and PaymentGateway: the outside rules
PricingStrategy.price(show, seat) returns paise. SeatTypePricing looks the price up by seat type. Peak pricing (step 11) wraps it rather than replacing it.
PaymentGateway has charge(idempotencyKey, amount) and refund(paymentId). The key means “if you’ve seen this key before, give me the same answer and don’t charge again”. Real gateways support exactly this.
Step 5How the classes relate
| Relationship | In this design |
|---|---|
| Composition | a screen is made of its seats; the service owns each show’s ShowSeats |
| Aggregation | a show uses a screen that many shows share |
| Association | a show refers to its movie; a booking refers to its hold |
| Realization | SeatTypePricing is a PricingStrategy |
| Dependency | the service uses a clock to read the time |
Step 6The class diagram, built up
Version 1: the catalogue, and the seat trap avoided
Seat has no status. ShowSeats is where “taken for this show” lives.
Version 2: holding and paying
Version 3: the whole picture
Movie, Screen, Seat, Show and Hold are left off; versions 1 and 2 show them.
Check it against the requirements
| Requirement | Where it lives |
|---|---|
| FR1: screens with seat types | Screen, Seat, SeatType |
| FR2: shows; free seats | Show; ShowSeats.free |
| FR3: hold up to 10 seats, all or nothing | BookingService.hold → ShowSeats.tryHold |
| FR4: pay and confirm | BookingService.confirm → gateway → ShowSeats.book |
| FR5: pricing | PricingStrategy, SeatTypePricing, PeakPricing |
| FR6: cancel with refund | BookingService.cancel → gateway refund → ShowSeats.cancel |
| FR7: react to bookings | BookingListener |
| NFR1: never twice | one lock per show; claims checked and written in one step |
| NFR2: holds expire on time | every check compares the claim’s time with now |
| NFR3: payment safety | hold states with compare-and-set; idempotency key; refund if late |
| NFR4: fast under contention | microsecond locks; no lock during payment |
| NFR5: extend | strategies and decorators |
| NFR6: testable | Clock and PaymentGateway passed in |
Step 7The patterns, and why each one is here
| Pattern | Where | The problem it solved |
|---|---|---|
| Facade | BookingService | the app sees four methods, not seat maps and gateways |
| Strategy | PricingStrategy, PaymentGateway | pricing and payment providers vary |
| Decorator | PeakPricing (step 11) | add a rule on top of pricing without editing it |
| Observer | BookingListener | tickets and emails react to bookings |
| State machine | HoldState: held → paying → booked or closed | exactly one payment per hold; clear rules for each step |
Why the hold states aren’t State-pattern classes
The State pattern would make HeldState, PayingState and so on, each a class with methods. Here each state allows exactly one or two moves, and the moves must be atomic (a compare-and-set on a concurrent map) because two threads can try them at once. An enum plus ConcurrentHashMap.replace(id, HELD, PAYING) says exactly that in one line. Classes per state would hide the atomic step inside method calls, which is the opposite of what we want here.
Why not a Singleton
One BookingService per process is natural, but pass it in rather than reaching it through getInstance(). Every test in step 10 creates a fresh service with its own fake clock and gateway; with a Singleton they’d interfere.
Patterns we deliberately didn’t use
- Builder for
Show. Three fields. - Factory for seats. A seat is a record with an id and a type.
- Repository interfaces. Everything is in memory; a database is a follow-up, and the in-memory maps are where repositories would go.
Step 8Walk the flows
Holding seats
- Cheap checks first, outside any lock.
tryHoldchecks every seat before claiming any. If B3 was taken, B2 is never claimed: all or nothing, with nothing to undo.- A claim stores its expiry time. Nothing needs to remember to remove it.
Paying
- A retry after success gets the same booking back.
- Held → paying happens with one compare-and-set. If two requests arrive together (a double click), exactly one wins; the other is told a payment is already in progress. That’s what makes “charged twice” impossible from our side.
- The hold is extended for the payment window, under the show’s lock, but only if the hold still owns every seat and hasn’t expired.
- The gateway call happens with no lock held. It might take seconds. Holding a lock across it would stall every other user of this show.
- Booking re-checks everything under the lock: still ours? still not expired? Only then are the seats marked booked.
When the payment arrives too late
What if step 4 takes longer than the payment window? The hold expires during the call, someone else holds the seats, and then the gateway says “paid”.
The user is refunded and told plainly what happened. From our run:
Kabir: payment arrived after the hold ended; refunded (refunds so far: 1)You can’t make this case impossible: the gateway is outside your control. You can make it rare (the extra five minutes) and safe (check again, refund if lost).
Why each idempotency key has an attempt number
The gateway key is h1/1: the hold id and an attempt number. If the gateway declines (insufficient funds), the hold goes back to held and the attempt number goes up, so when the user tries another card the gateway sees a new key. With just h1, the gateway would replay the old “declined” answer forever. Within one attempt the key stays the same, so a network retry never charges twice.
What walking the flows taught us
| Found while walking | Change |
|---|---|
| A lock held during payment would block the whole show | Claim, unlock, pay, lock again to book |
| The hold can expire during a normal payment | Extend by a payment window when paying starts |
| A payment can still finish too late | Check again when booking; refund if the seats are gone |
| A double click could start two payments | Held → paying with compare-and-set |
| A declined card must be retryable | An attempt number in the idempotency key |
| Expiry can’t depend on a cleanup job | Claims carry their own expiry time |
The life of a hold
| Hold is | Moves to | When |
|---|---|---|
| (none) | Held | all seats claimed |
| Held | Paying | confirm wins the compare-and-set |
| Held | Closed | the user releases it |
| Paying | Held | the card is declined (try again while time lasts) |
| Paying | Booked | paid, and the seats were still ours |
| Paying | Closed | the hold had expired: refunded |
| Held | (expired) | time passes; its seats count as free even before anything changes the state |
When things go wrong
| Situation | What the user sees |
|---|---|
| A seat was just taken | “some of [B3, B4] were just taken”: pick again |
| Hold expired before paying | “hold expired” |
| Card declined | “payment declined; seats still held” |
| Paid too late | “refunded” |
| Two clicks on Pay | one booking; the other click sees “payment already in progress” |
| Cancel after the show started | refused |
| Cancel twice | “already cancelled” |
Step 9Write the code, bottom-up
Complete Java that compiles and runs. Every output on this page comes from running it.
9.1 The catalogue
enum SeatType { REGULAR, PREMIUM, RECLINER }
record Seat(String id, SeatType type) { }
record Screen(String id, List seats) {
Screen {
seats = List.copyOf(seats);
}
Optional seat(String seatId) {
return seats.stream().filter(s -> s.id().equals(seatId)).findFirst();
}
}
record Movie(String id, String title, Duration length) { }
record Show(String id, Movie movie, Screen screen, LocalDateTime start) { } 9.2 Holds, bookings and errors
record Hold(String id, String userId, String showId, List seatIds,
Instant expiresAt) { }
enum BookingStatus { CONFIRMED, CANCELLED }
record Booking(String id, String holdId, String userId, String showId,
List seatIds, long amount, String paymentId,
BookingStatus status) {
Booking cancelled() {
return new Booking(id, holdId, userId, showId, seatIds, amount, paymentId,
BookingStatus.CANCELLED);
}
}
class BookingException extends RuntimeException {
BookingException(String message) { super(message); }
}
class SeatsUnavailableException extends BookingException {
SeatsUnavailableException(String m) { super(m); }
}
class HoldExpiredException extends BookingException {
HoldExpiredException(String m) { super(m); }
}
class PaymentFailedException extends BookingException {
PaymentFailedException(String m) { super(m); }
} Exceptions here, where tic-tac-toe used result values? The principle is the same (an expected outcome, with a clear reason), but this is a service API, and each exception type maps neatly to an HTTP answer: 409 for seats taken, 410 for an expired hold, 402 for a declined payment.
9.3 ShowSeats
final class ShowSeats {
private record Claim(String holdId, Instant until, boolean booked) {
boolean blocks(Instant now) { return booked || until.isAfter(now); } // (1)
}
private final Set seatIds;
private final Map claims = new HashMap<>();
ShowSeats(Screen screen) {
Set ids = new LinkedHashSet<>();
for (Seat s : screen.seats()) ids.add(s.id());
this.seatIds = Collections.unmodifiableSet(ids);
}
synchronized boolean tryHold(String holdId, List ids, Instant now,
Instant until) {
for (String id : ids) {
if (!seatIds.contains(id))
throw new IllegalArgumentException("no seat " + id);
Claim c = claims.get(id);
if (c != null && c.blocks(now)) return false; // (2)
}
for (String id : ids) claims.put(id, new Claim(holdId, until, false));
return true;
}
synchronized boolean extend(String holdId, List ids, Instant now,
Instant until) {
if (!ownsAll(holdId, ids, now)) return false;
for (String id : ids) claims.put(id, new Claim(holdId, until, false));
return true;
}
synchronized boolean book(String holdId, List ids, Instant now) {
if (!ownsAll(holdId, ids, now)) return false;
for (String id : ids) claims.put(id, new Claim(holdId, Instant.MAX, true));
return true;
}
synchronized void release(String holdId, List ids) {
for (String id : ids) {
Claim c = claims.get(id);
if (c != null && c.holdId().equals(holdId)) claims.remove(id);
}
}
synchronized void cancel(String holdId, List ids) {
for (String id : ids) {
Claim c = claims.get(id);
if (c != null && c.holdId().equals(holdId) && c.booked()) claims.remove(id);
}
}
synchronized List free(Instant now) {
List out = new ArrayList<>();
for (String id : seatIds) {
Claim c = claims.get(id);
if (c == null || !c.blocks(now)) out.add(id);
}
return out;
}
private boolean ownsAll(String holdId, List ids, Instant now) {
for (String id : ids) {
Claim c = claims.get(id);
if (c == null || !c.holdId().equals(holdId) || c.booked()
|| !c.until().isAfter(now))
return false; // (3)
}
return true;
}
} - The heart of expiry: a held seat only blocks others until its time. After that it’s free, whether or not anyone has tidied it up.
- Check every seat before claiming any: all or nothing.
- Someone else’s claim, a booked seat, or an expired hold all mean “not yours any more”.
release and cancel only remove claims that belong to the given hold. A late release from an old hold can never free seats that someone else has since claimed.
9.4 Pricing, payments and listeners
interface PricingStrategy {
long price(Show show, Seat seat); // paise
}
final class SeatTypePricing implements PricingStrategy {
private final Map prices;
SeatTypePricing(Map prices) { this.prices = Map.copyOf(prices); }
public long price(Show show, Seat seat) { return prices.get(seat.type()); }
}
record PaymentResult(boolean ok, String paymentId) { }
interface PaymentGateway {
PaymentResult charge(String idempotencyKey, long amount);
void refund(String paymentId);
}
interface BookingListener {
void booked(Booking booking);
} 9.5 BookingService: fields, shows and holding
final class BookingService {
private enum HoldState { HELD, PAYING, BOOKED, CLOSED }
private final Map shows = new ConcurrentHashMap<>();
private final Map seats = new ConcurrentHashMap<>();
private final Map holds = new ConcurrentHashMap<>();
private final Map state = new ConcurrentHashMap<>();
private final Map bookings = new ConcurrentHashMap<>();
private final Map byHold = new ConcurrentHashMap<>();
private final Map attempts = new ConcurrentHashMap<>();
private final List listeners = new CopyOnWriteArrayList<>();
private final AtomicLong ids = new AtomicLong();
private final Clock clock;
private final PricingStrategy pricing;
private final PaymentGateway gateway;
private final Duration holdFor, payFor;
BookingService(Clock clock, PricingStrategy pricing, PaymentGateway gateway,
Duration holdFor, Duration payFor) {
this.clock = clock;
this.pricing = pricing;
this.gateway = gateway;
this.holdFor = holdFor;
this.payFor = payFor;
}
void addShow(Show show) {
shows.put(show.id(), show);
seats.put(show.id(), new ShowSeats(show.screen()));
}
void subscribe(BookingListener l) { listeners.add(l); }
List freeSeats(String showId) {
return seatsOf(showId).free(clock.instant());
}
Hold hold(String userId, String showId, List seatIds) {
if (seatIds.isEmpty() || seatIds.size() > 10)
throw new IllegalArgumentException("pick 1 to 10 seats");
if (new HashSet<>(seatIds).size() != seatIds.size())
throw new IllegalArgumentException("same seat twice");
Instant now = clock.instant();
Hold h = new Hold("h" + ids.incrementAndGet(), userId, showId,
List.copyOf(seatIds), now.plus(holdFor));
if (!seatsOf(showId).tryHold(h.id(), h.seatIds(), now, h.expiresAt()))
throw new SeatsUnavailableException(
"some of " + seatIds + " were just taken");
holds.put(h.id(), h);
state.put(h.id(), HoldState.HELD);
return h;
} 9.6 BookingService: confirming
Booking confirm(String holdId, String userId) {
Hold h = holds.get(holdId);
if (h == null || !h.userId().equals(userId))
throw new BookingException("no such hold");
Booking done = byHold.get(holdId);
if (done != null) return done; // (1)
if (!state.replace(holdId, HoldState.HELD, HoldState.PAYING)) // (2)
throw new BookingException("payment already in progress, or hold closed");
ShowSeats ss = seatsOf(h.showId());
Instant now = clock.instant();
if (!ss.extend(holdId, h.seatIds(), now, now.plus(payFor))) { // (3)
state.put(holdId, HoldState.CLOSED);
throw new HoldExpiredException("hold " + holdId + " expired");
}
long amount = priceOf(h);
String key = holdId + "/" + attempts.getOrDefault(holdId, 1);
PaymentResult paid = gateway.charge(key, amount); // (4)
if (!paid.ok()) {
attempts.merge(holdId, 2, (a, b) -> a + 1);
state.put(holdId, HoldState.HELD);
throw new PaymentFailedException("payment declined; seats still held");
}
if (!ss.book(holdId, h.seatIds(), clock.instant())) { // (5)
gateway.refund(paid.paymentId());
state.put(holdId, HoldState.CLOSED);
throw new HoldExpiredException(
"payment arrived after the hold ended; refunded");
}
Booking b = new Booking("b" + ids.incrementAndGet(), holdId, userId, h.showId(),
h.seatIds(), amount, paid.paymentId(),
BookingStatus.CONFIRMED);
bookings.put(b.id(), b);
byHold.put(holdId, b);
state.put(holdId, HoldState.BOOKED);
for (BookingListener l : listeners) l.booked(b);
return b;
}- A retry after a successful confirm returns the same booking.
- One compare-and-set: only one caller moves the hold from held to paying.
- Keep the seats for the payment window, but only if the hold still has them.
- The network call, with no lock held anywhere.
- Check again. If the seats are gone, refund.
9.7 BookingService: release, cancel and helpers
void release(String holdId) {
Hold h = holds.get(holdId);
if (h == null) return;
if (state.replace(holdId, HoldState.HELD, HoldState.CLOSED))
seatsOf(h.showId()).release(holdId, h.seatIds());
}
Booking cancel(String bookingId, String userId) {
Booking b = bookings.get(bookingId);
if (b == null || !b.userId().equals(userId))
throw new BookingException("no such booking");
Show show = shows.get(b.showId());
if (!clock.instant().isBefore(show.start().atZone(clock.getZone()).toInstant()))
throw new BookingException("the show has started");
Booking c = b.cancelled();
if (!bookings.replace(bookingId, b, c)) // (6)
throw new BookingException("already cancelled");
gateway.refund(b.paymentId());
seatsOf(b.showId()).cancel(b.holdId(), b.seatIds());
return c;
}
long priceOf(Hold h) {
Show show = shows.get(h.showId());
long total = 0;
for (String id : h.seatIds())
total += pricing.price(show, show.screen().seat(id).get());
return total;
}
Optional booking(String id) { return Optional.ofNullable(bookings.get(id)); }
private ShowSeats seatsOf(String showId) {
ShowSeats ss = seats.get(showId);
if (ss == null) throw new IllegalArgumentException("no show " + showId);
return ss;
}
} replace(key, old, new)only succeeds if the booking is still the confirmed one, so two cancel requests at once refund only once. The same compare-and-set idea as the hold states.
9.8 Putting it together
A 10-seat screen (A1–A4 regular at ₹200, B1–B4 premium at ₹350, C1–C2 recliners at ₹600), a 7:30 pm show, and a fake clock starting at 5 pm. The fake gateway and clock come from the test helpers. Highlights from the run:
Riya holds [B2, B3] as h1, price ₹700
Arjun: some of [B3, B4] were just taken
free now: [A1, A2, A3, A4, B1, B4, C1, C2]
[ticket] b3 seats [B2, B3] ₹700
Riya booked b3 CONFIRMED
retry of confirm returns the same booking: true
11 minutes later, Meera holds [C1]
Arjun pays: hold h4 expired
Meera: payment declined; seats still held
[ticket] b6 seats [C1] ₹600
Meera retries: CONFIRMED
Kabir: payment arrived after the hold ended; refunded (refunds so far: 1)
Riya cancels: CANCELLED, refunds: 2, B2 free again: trueRead it as a story. Arjun loses B3 to Riya by a moment. His later hold on the recliners expires while he’s away, and Meera takes C1 without any cleanup job having run: Arjun’s expired claim simply stopped counting. Meera’s card is declined, her seat stays held, and her second card works. Kabir’s payment comes back after his window, so he’s refunded. Riya cancels, gets her money back, and B2 is on sale again.
Step 10Make it safe
Interviewer: A big release, thousands of people on the same Friday show. Walk me through what’s shared.
| Shared state | Changed by | Danger | Protection |
|---|---|---|---|
| a show’s seat claims | every hold, book, release, cancel | one seat sold twice | ShowSeats‘ lock (one per show) |
| a hold’s state | confirm, release | two payments for one hold | compare-and-set on a concurrent map |
| bookings | confirm, cancel | refunded twice | replace(key, old, new) |
| the gateway | confirm | charged twice | idempotency key per attempt |
| the catalogue | nobody after setup | none | immutable records |
Thousands of fans, one show
We sent 2,000 users at a 200-seat show on 16 threads, each trying to hold and pay for two adjacent seats chosen at random:
| Run | Bookings | Seats sold | Distinct seats sold | Holds refused | Seats left free |
|---|---|---|---|---|---|
| 1 | 84 | 168 | 168 | 1,916 | 32 |
| 2 | 85 | 170 | 170 | 1,915 | 30 |
| 3 | 84 | 168 | 168 | 1,916 | 32 |
Seats sold always equals distinct seats: nothing was sold twice. Look at something else, though: 30 to 32 seats were left unsold every time, even with 2,000 people trying. They were single seats stranded between pairs. Nobody wanted a pair that included a taken seat. That’s a real product problem, and step 11 has a follow-up about it.
Why one lock per show, not per seat
Interviewer: Wouldn’t a lock per seat be faster? Two people booking different seats wouldn’t wait for each other.
You: In theory. But a hold is several seats, all or nothing, so I’d need to take several locks at once. And taking several locks is how deadlocks happen.
Riya taps B2 then B3. Arjun, at the same moment, taps B3 then B2. If each request locks seats in the order they were tapped:
| Time | Riya’s request | Arjun’s request |
|---|---|---|
| 1 | locks B2 | locks B3 |
| 2 | waits for B3 (Arjun has it) | waits for B2 (Riya has it) |
| 3 | waits… | waits… |
Each holds what the other needs. Neither will ever let go. That’s a deadlock. Try it:
We measured it with per-seat locks that give up after 5 ms. Two threads, 2,000 holds each, one asking for [A1, A2] and the other for [A2, A1]:
locks in request order: 4000 holds, stuck and gave up 849 times, took 2898 ms
locks in seat order: 4000 holds, stuck and gave up 0 times, took 9 msIn request order, one in five attempts deadlocked and had to time out. (Our first version of this test waited 50 ms before giving up, and it ran for so long we thought the test had hung.) The fix, if you ever take several locks, is the classic rule: always take locks in the same global order, here sorted by seat id. Then two requests can never each hold what the other needs.
But the simplest fix is not to need several locks. A show’s seat map is small, and each ShowSeats method takes microseconds. One lock per show means different shows never wait for each other, and within a show the waits are tiny. Per-seat locking would only matter for a stadium-sized show with enormous traffic, and even then you’d sort.
Double clicks on Pay
We ran 2,000 trials of two threads calling confirm on the same hold at the same instant:
double-click Pay, 2000 trials: two different bookings 0 times, always charged onceOne request always won the held → paying step; the other was told a payment was already in progress. The fake gateway counted exactly one charge every time.
Never call out while holding a lock
The rule worth repeating: confirm holds no lock while the gateway thinks. Every ShowSeats call is a few microseconds; the payment in between can take seconds. If the lock were held across the payment, one slow UPI app would freeze seat selection for everyone at that show.
Step 11Follow-ups, extended without editing
Weekend evening prices
Interviewer: Friday to Sunday after 6 pm, tickets cost 25% more.
Bad solutionAdd an if to SeatTypePricing
Now the class has two jobs, and the next rule (school holidays, premieres) adds another if.
Good solutionA new strategy that copies the seat prices and adds the rule
Works, but duplicates the seat-type lookup.
Great solutionA decorator that wraps any pricing
final class PeakPricing implements PricingStrategy {
private final PricingStrategy base;
private final int percentExtra;
PeakPricing(PricingStrategy base, int percentExtra) {
this.base = base;
this.percentExtra = percentExtra;
}
public long price(Show show, Seat seat) {
long p = base.price(show, seat);
DayOfWeek d = show.start().getDayOfWeek();
boolean weekend = d == DayOfWeek.FRIDAY || d == DayOfWeek.SATURDAY
|| d == DayOfWeek.SUNDAY;
boolean evening = show.start().getHour() >= 18;
return weekend && evening ? p + p * percentExtra / 100 : p; // (1)
}
}- Whole paise. ₹350 plus 25% is ₹437.50 exactly.
premium seat: Saturday 7:30pm ₹437.50, Tuesday 7:30pm ₹350new PeakPricing(new SeatTypePricing(...), 25). Rules stack by wrapping, and BookingService never knows.
Don’t leave single seats
Interviewer: Our stress test left 30 seats empty. Stop people stranding single seats.
A rule that checks a hold before it’s placed: “after this hold, would any seat in the row be left alone between two taken seats?” It belongs next to the seat map, because it needs the row layout and current claims. Add a SeatRule interface (boolean allows(layout, taken, wanted)) that ShowSeats consults inside tryHold. Cinemas that don’t care pass a rule that always allows. Many real apps do exactly this, and it’s why they sometimes say “please don’t leave a single seat”.
Holds that cleanly disappear
Expired claims are harmless, but they take memory. A background job can call release for holds past their expiry every minute. It’s purely tidying: correctness never depends on it, because every check already compares times.
Saving to a database
Each map in BookingService becomes a repository. The ShowSeats lock becomes a database transaction: one UPDATE … WHERE seat is free or expired, checking the row count, exactly as in the system design walkthrough. The class design doesn’t change, only where the “one step” happens.
Several cinemas and cities
Searching (“which shows of this movie near me tonight?”) is a read-only catalogue problem, separate from booking. Keep it out of BookingService entirely: a ShowCatalog that can be cached heavily, because shows change rarely and a few seconds’ staleness is fine.
Mistakes people make on this one
- *A
statusfield onSeat,* shared by every show on that screen. - Holding seats one at a time, leaving half a hold behind when the third seat is taken.
- Relying on a cleanup job to expire holds.
- Holding a lock while calling the payment gateway.
- No plan for a payment that succeeds after the hold expired.
- An idempotency key without an attempt number, so a declined card can never be retried.
- Locking seats one by one in request order, and finding deadlocks under load.
- *A
BookingManagerSingleton* that every test shares.
How this answer gets scored
| Moment | What earns credit | What costs you |
|---|---|---|
| The first five minutes | Asking about holds, payment failures, cancellation and load | Listing classes before requirements |
| Entities | Seat without status; a per-show seat map | Seat.status |
| Holding | All-or-nothing in one step, with an expiry time | Seat-by-seat claims; a cleanup job for correctness |
| Paying | No lock during payment; extend, pay, re-check, refund | One big lock around everything |
| Double clicks | Held → paying with compare-and-set; idempotency keys | “The UI disables the button” |
| Concurrency | One lock per show, and why per-seat locks deadlock without ordering | No locking, or a global lock |
| Testing | Clock and gateway passed in; time moved in tests | Instant.now() everywhere |
| Follow-ups | Pricing decorators; seat rules next to the seat map | if statements added to existing classes |
The whole design on one page
- Catalogue:
Movie,ScreenofSeats (id and type, no status),Show= movie + screen + time. All immutable. - Seat map: one
ShowSeatsper show. Each taken seat has a claim: which hold, until when, booked or not. Expired claims count as free. - Hold: check every seat, then claim all, under the show’s lock. All or nothing.
- Pay: held → paying by compare-and-set; extend the hold; charge with no lock held (key = hold + attempt); book only if still ours, else refund.
- Cancel: before the show, once (compare-and-set on the booking), refund, free the seats.
- Safety: one short lock per show; never lock across a network call; if you ever take several locks, take them in a fixed order.
Stay in the loop
Get new problems and walkthroughs by email
One short email when something new is published: a deep-dive walkthrough, an LLD problem, or a real outage retold. No spam, unsubscribe any time.