System Design Road

Medium LLD interview problem

Design a movie ticket booking system

“Design BookMyShow” is one of the most common LLD questions, and it’s a good one: a familiar app, a handful of obvious classes, and then three hard problems hiding under them. Two people want the same seat at the same moment. A seat must be held while you pay, and freed if you wander off. And the payment gateway is slow, sometimes declines, and sometimes answers after your time is up. This walkthrough builds the whole thing from nothing, with every result on the page coming from running the code.

24 min read4 diagrams15 sections

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.

StepWhat you doWhat comes out of it
1. Understand the problemAsk, number the requirementsWhat booking must guarantee
2. Break it into entitiesNouns, verbs, what changesMovies, screens, shows, seats, holds, bookings
3. Choose a directionTop-down to design, bottom-up to codeThe order to work in
4. Design each classKnows, does, doesn’t doA card per class
5. Connect the classesRelationshipsWho owns whom
6. Draw the diagramThree versionsThe full picture
7. Name the patternsWhere they earned a placeStrategy, Decorator, a small state machine
8. Walk the flowsHold, pay, expire, cancelGaps found before coding
9. Write the codeBottom-upCode that compiles and runs
10. Make it safeSame seat, double clicks, deadlocksOne lock per show, and why
11. Extend itPeak pricing, no single gaps, moreNew 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

Interviewer: Design a movie ticket booking system, like BookMyShow.

You

You: Let me check the shape first. Cities have cinemas, cinemas have screens, and each screen runs several shows a day?

Interviewer

Interviewer: Yes.

You

You: Do people pick exact seats?

Interviewer

Interviewer: Yes, from a seat map.

You

You: Do prices depend on the seat?

Interviewer

Interviewer: Yes: regular, premium and recliner seats cost different amounts. And weekend evenings may cost more.

You

You: How long can someone hold seats while paying?

Interviewer

Interviewer: Ten minutes.

You

You: Can a booking be cancelled?

Interviewer

Interviewer: Before the show starts, with a full refund.

You

You: How do payments work?

Interviewer

Interviewer: Through a payment gateway. It can decline, be slow, or time out.

You

You: How many seats in one booking?

Interviewer

Interviewer: Up to ten.

You

You: Is the same seat ever sold to two people?

Interviewer

Interviewer: Never.

You

You: How busy does it get?

Interviewer

Interviewer: A big release on a Friday: thousands of people on the same show.

You

You: Food, offers, reviews?

Interviewer

Interviewer: Out of scope.

QuestionWhat the answer decided
City → cinema → screen → showThe structure, and that a show is what’s actually sold
Exact seatsEvery seat is a small piece of contested data
Price by seat, weekends morePricing is a strategy, and rules stack
Ten-minute holdHolds need an expiry that works even if nothing cleans up
Cancel before the showBookings change state; refunds go through the gateway
Gateway can be slow or declineNever hold a lock while paying; handle late payments
Up to ten seatsHolds are multi-seat and all-or-nothing
Never sold twiceThe one guarantee that can’t bend
Thousands on one showContention on one show’s seats; locks must be short

Functional requirements

  1. FR1. Cinemas have screens; each screen has a fixed layout of seats of different types.
  2. FR2. A show is one movie on one screen at one time. Users see which seats are free for a show.
  3. FR3. A user can hold 1 to 10 seats for a show for 10 minutes, all or nothing.
  4. FR4. The user pays; on success, the hold becomes a confirmed booking.
  5. FR5. Price depends on seat type and may depend on when the show is.
  6. FR6. A booking can be cancelled before the show starts, with a refund.
  7. FR7. Something can react to new bookings (tickets, emails).

Non-functional requirements

  1. NFR1: Never sell a seat twice. Even with thousands of users on one show.
  2. NFR2: Holds expire on time, even if no background job runs.
  3. NFR3: Payment is safe: never charged twice for one hold, and refunded if the seats were lost.
  4. NFR4: Fast under contention: locks are held for microseconds, never across a network call.
  5. NFR5: Easy to extend: new pricing rules and seat rules are added, not edited in.
  6. NFR6: Testable: time and payments can be faked.

Assumptions we’ll write down

AssumptionWhy it’s reasonable
Money is whole paise in a longExact; see Splitwise
The clock is passed inTests can move time forward ten minutes instantly
A hold gets 5 extra minutes once payment startsSo a normal payment never fails because the timer ran out mid-payment
One hold, one payment attempt at a timeTwo clicks on Pay shouldn’t start two payments
Everything in memoryThe 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

NounOwn data?Own rules?Changes alone?Becomes
Movietitle, length——Movie record
Cinema, cityname, screens——outside our core (a list of screens is enough)
Screenits seats——Screen record
Seatid, type——Seat record: no status (the trap below)
Seat type———SeatType enum
Showmovie, screen, start time——Show record
Seat map for a showwho holds or owns each seatone owner per seat; holds expire—ShowSeats class
Hold, timeruser, seats, expiry——Hold record
Booking, ticketuser, seats, amount, payment, statuscan only be cancelled once—Booking record
Price—how a seat is pricedyesPricingStrategy interface
Payment, refund——yes: gateways changePaymentGateway interface
User———an id (accounts belong to another service)

The trap: a status on the seat

This is the mistake almost everyone makes first:

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

  • Seat is the physical seat: an id and a type. It never changes. Shared by every show on that screen.
  • ShowSeats is 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 caseWho starts itVerbs inside it
UC1: See the seat mapa userlist free seats for a show
UC2: Hold seatsa usercheck all are free, claim all, set an expiry
UC3: Pay and confirma userstart paying once, extend the hold, charge, confirm or refund
UC4: Give upa user, or timerelease the hold, or let it expire
UC5: Cancela usercheck the show hasn’t started, cancel once, refund, free the seats

Give each job to the class that knows the most

JobInformation neededOwner
Is this seat free for this show?that show’s claims, and the timeShowSeats
Claim several seats, all or nonethat show’s claimsShowSeats
How much is this seat for this show?seat type, show time, a policyPricingStrategy
Charge and refundthe outside worldPaymentGateway
Which state is this hold in? Is payment already running?every hold’s stateBookingService
Turn a hold into a bookingthe hold, the seat map, the gatewayBookingService

CRC cards

ClassResponsibilitiesCollaborators
ShowSeatsclaim, extend, book, release, cancel seats for one show; list free seats—
PricingStrategyprice a seat for a showShow, Seat
PaymentGatewaycharge with an idempotency key; refund—
BookingServicehold, confirm, release, cancel; hold states; tell listenersShowSeats, PricingStrategy, PaymentGateway, Clock

Find what will change

Will changeHintBecomes
Pricing rules“weekend evenings may cost more”PricingStrategy (and decorators)
The gatewaygateways get swapped; tests need a fakePaymentGateway
Who reacts to bookingstickets, emails, loyalty pointsBookingListener
Timetests can’t wait ten minutesjava.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.

KnowsDoesDoesn’t do
shows, and a ShowSeats per showhold(user, show, seats) → Holddecide if a seat is free (ShowSeats does)
holds, and each hold’s stateconfirm(hold, user) → Bookingprice seats (pricing does)
bookings, by id and by holdrelease(hold), cancel(booking, user)talk to banks (the gateway does)
clock, pricing, gateway, hold timesfreeSeats(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.

KnowsDoesDoesn’t do
the screen’s seat idstryHold(hold, seats, now, until)know about users, money or movies
for each taken seat: which hold, until when, booked or notextend, 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

RelationshipIn this design
Compositiona screen is made of its seats; the service owns each show’s ShowSeats
Aggregationa show uses a screen that many shows share
Associationa show refers to its movie; a booking refers to its hold
RealizationSeatTypePricing is a PricingStrategy
Dependencythe service uses a clock to read the time

Step 6The class diagram, built up

Version 1: the catalogue, and the seat trap avoided

Show- movie: Movie- screen: Screen- start: LocalDateTimeScreen- seats: ListSeat- id: String- type: SeatTypeShowSeats- claims: Map+ tryHold(hold, seats, now, until): boolean+ free(now): List1..*by id

Seat has no status. ShowSeats is where “taken for this show” lives.

Version 2: holding and paying

BookingService- holds: Map- state: Map+ hold(user, show, seats): Hold+ confirm(holdId, user): BookingShowSeats+ tryHold(...): boolean+ extend(...): boolean+ book(...): boolean«interface»PaymentGateway+ charge(key, amount): PaymentResult+ refund(paymentId): voidHold- seatIds: List- expiresAt: Instantper show

Version 3: the whole picture

«interface»PricingStrategy+ price(show, seat): longSeatTypePricingBookingService- holds: Map- state: Map- bookings: Map- clock: Clock+ hold(user, show, seats): Hold+ confirm(holdId, user): Booking+ release(holdId): void+ cancel(bookingId, user): Booking«interface»PaymentGateway+ charge(key, amount): PaymentResult+ refund(paymentId): void«interface»BookingListener+ booked(b: Booking): voidShowSeats- claims: Map+ tryHold(...): boolean+ extend(...): boolean+ book(...): boolean+ cancel(...): voidBooking- seatIds: List- amount: long- status: BookingStatusnotifiesper show

Movie, Screen, Seat, Show and Hold are left off; versions 1 and 2 show them.

Check it against the requirements

RequirementWhere it lives
FR1: screens with seat typesScreen, Seat, SeatType
FR2: shows; free seatsShow; ShowSeats.free
FR3: hold up to 10 seats, all or nothingBookingService.hold → ShowSeats.tryHold
FR4: pay and confirmBookingService.confirm → gateway → ShowSeats.book
FR5: pricingPricingStrategy, SeatTypePricing, PeakPricing
FR6: cancel with refundBookingService.cancel → gateway refund → ShowSeats.cancel
FR7: react to bookingsBookingListener
NFR1: never twiceone lock per show; claims checked and written in one step
NFR2: holds expire on timeevery check compares the claim’s time with now
NFR3: payment safetyhold states with compare-and-set; idempotency key; refund if late
NFR4: fast under contentionmicrosecond locks; no lock during payment
NFR5: extendstrategies and decorators
NFR6: testableClock and PaymentGateway passed in

Step 7The patterns, and why each one is here

PatternWhereThe problem it solved
FacadeBookingServicethe app sees four methods, not seat maps and gateways
StrategyPricingStrategy, PaymentGatewaypricing and payment providers vary
DecoratorPeakPricing (step 11)add a rule on top of pricing without editing it
ObserverBookingListenertickets and emails react to bookings
State machineHoldState: held → paying → booked or closedexactly 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

AppBookingServiceShowSeats1 hold(Riya, show s1, [B2, B3])2 1 to 10 seats? no repeats?3 tryHold(h1, [B2, B3], now, now + 10 min)4 lock; any seat claimed and not expired? no5 claim both for h1; unlock6 true7 remember h1, state HELD8 Hold h1, expires 17:10
  1. Cheap checks first, outside any lock.
  2. tryHold checks every seat before claiming any. If B3 was taken, B2 is never claimed: all or nothing, with nothing to undo.
  3. A claim stores its expiry time. Nothing needs to remember to remove it.

Paying

AppBookingServiceShowSeatsGateway1 confirm(h1, Riya)2 already booked? no3 state HELD → PAYING (compare-and-set)4 extend(h1, now + 5 min)5 true6 charge("h1/1", ₹700)7 ok, pay_18 book(h1, now)9 true10 create booking; state BOOKED11 Booking b3, CONFIRMED
  1. A retry after success gets the same booking back.
  2. 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.
  3. 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.
  4. 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.
  5. 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”.

BookingServiceGatewayShowSeats1 charge("h7/1", ₹400)2 slow... 6 minutes3 h7 expired; Zoya holds A24 ok, pay_35 book(h7, now)6 false (A2 is Zoya's now)7 refund(pay_3)8 state CLOSED

The user is refunded and told plainly what happened. From our run:

Text
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 walkingChange
A lock held during payment would block the whole showClaim, unlock, pay, lock again to book
The hold can expire during a normal paymentExtend by a payment window when paying starts
A payment can still finish too lateCheck again when booking; refund if the seats are gone
A double click could start two paymentsHeld → paying with compare-and-set
A declined card must be retryableAn attempt number in the idempotency key
Expiry can’t depend on a cleanup jobClaims carry their own expiry time

The life of a hold

Hold isMoves toWhen
(none)Heldall seats claimed
HeldPayingconfirm wins the compare-and-set
HeldClosedthe user releases it
PayingHeldthe card is declined (try again while time lasts)
PayingBookedpaid, and the seats were still ours
PayingClosedthe hold had expired: refunded
Held(expired)time passes; its seats count as free even before anything changes the state

When things go wrong

SituationWhat 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 Payone booking; the other click sees “payment already in progress”
Cancel after the show startedrefused
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

Java
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

Java
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

Java
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;
    }
}
  1. 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.
  2. Check every seat before claiming any: all or nothing.
  3. 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

Java
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

Java
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

Java
    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;
    }
  1. A retry after a successful confirm returns the same booking.
  2. One compare-and-set: only one caller moves the hold from held to paying.
  3. Keep the seats for the payment window, but only if the hold still has them.
  4. The network call, with no lock held anywhere.
  5. Check again. If the seats are gone, refund.

9.7 BookingService: release, cancel and helpers

Java
    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;
    }
}
  1. 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:

Text
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: true

Read 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

Interviewer: A big release, thousands of people on the same Friday show. Walk me through what’s shared.

Shared stateChanged byDangerProtection
a show’s seat claimsevery hold, book, release, cancelone seat sold twiceShowSeats‘ lock (one per show)
a hold’s stateconfirm, releasetwo payments for one holdcompare-and-set on a concurrent map
bookingsconfirm, cancelrefunded twicereplace(key, old, new)
the gatewayconfirmcharged twiceidempotency key per attempt
the cataloguenobody after setupnoneimmutable 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:

RunBookingsSeats soldDistinct seats soldHolds refusedSeats left free
1841681681,91632
2851701701,91530
3841681681,91632

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

Interviewer: Wouldn’t a lock per seat be faster? Two people booking different seats wouldn’t wait for each other.

You

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:

TimeRiya’s requestArjun’s request
1locks B2locks B3
2waits for B3 (Arjun has it)waits for B2 (Riya has it)
3waits…waits…

Each holds what the other needs. Neither will ever let go. That’s a deadlock. Try it:

Riya wants B2+B3, Arjun wants B3+B2
t3
Riyawaiting foreverArjunwaiting foreverSeat B2locked by RiyaSeat B3locked by Arjun

A deadlock: each holds what the other needs, and neither will let go. In our test, one in five attempts got stuck like this and had to time out.

Take seat locks in

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

Text
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 ms

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

Text
double-click Pay, 2000 trials: two different bookings 0 times, always charged once

One 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

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

Java
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)
    }
}
  1. Whole paise. ₹350 plus 25% is ₹437.50 exactly.
Text
premium seat: Saturday 7:30pm ₹437.50, Tuesday 7:30pm ₹350

new PeakPricing(new SeatTypePricing(...), 25). Rules stack by wrapping, and BookingService never knows.

Don’t leave single seats

Interviewer

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 status field on Seat,* 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 BookingManager Singleton* that every test shares.

How this answer gets scored

MomentWhat earns creditWhat costs you
The first five minutesAsking about holds, payment failures, cancellation and loadListing classes before requirements
EntitiesSeat without status; a per-show seat mapSeat.status
HoldingAll-or-nothing in one step, with an expiry timeSeat-by-seat claims; a cleanup job for correctness
PayingNo lock during payment; extend, pay, re-check, refundOne big lock around everything
Double clicksHeld → paying with compare-and-set; idempotency keys“The UI disables the button”
ConcurrencyOne lock per show, and why per-seat locks deadlock without orderingNo locking, or a global lock
TestingClock and gateway passed in; time moved in testsInstant.now() everywhere
Follow-upsPricing decorators; seat rules next to the seat mapif statements added to existing classes

The whole design on one page

  1. Catalogue: Movie, Screen of Seats (id and type, no status), Show = movie + screen + time. All immutable.
  2. Seat map: one ShowSeats per show. Each taken seat has a claim: which hold, until when, booked or not. Expired claims count as free.
  3. Hold: check every seat, then claim all, under the show’s lock. All or nothing.
  4. 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.
  5. Cancel: before the show, once (compare-and-set on the booking), refund, free the seats.
  6. Safety: one short lock per show; never lock across a network call; if you ever take several locks, take them in a fixed order.