← Back to Design & Development
Low-Level Design

Movie Ticket Booking

A real BookMyShow-style booking — Seat-locking with TTL, Saga payment flow, dynamic pricing Strategy, full Java code & the concurrency story Grokking glosses over.

Step 1

Clarify Requirements

It's a Friday at 6 PM. Spider-Man just released. Across India, 200,000 people have the BookMyShow app open. Three of them — Sarah in Mumbai, Raj in Delhi, Priya in Bangalore — all click on seat F-12 of the 9 PM show at PVR Phoenix at exactly the same instant. Two of them must lose. The third must complete checkout and pay within 8 minutes — or release the seat. That is what this design has to handle.

✅ Functional Requirements

  • List cities; list cinemas in a city; list shows in a cinema
  • Search movies by title, language, genre, release date, city
  • Display seating arrangement of a hall with per-seat status
  • Reserve N seats in a single transaction with an 8-minute payment hold
  • Multiple seat types (Regular / Premium / Recliner / Accessible) with different prices
  • Cancel a confirmed booking up to 30 min before show; refund per policy
  • Notifications: confirmation, reminder 1 hour before show, cancellation
  • Admin adds/removes movies, shows; FrontDesk books on behalf of walk-ins

🎯 Non-Functional & 🚫 Out of Scope

  • Concurrency: two users grabbing the same seat — exactly one wins, no double-booking
  • Latency < 300 ms for the seat-grab API at peak load
  • Availability: a single hall's data outage must not bring down the whole site
  • Auditability: every seat lock / unlock / book / refund logged

🚫 Out of scope

  • The actual movie streaming (this is just tickets)
  • Food & beverage ordering inside the cinema
  • Loyalty / coupon engine (extension point)
  • The payment gateway internals (we adapt to it)
Interviewer trick: the entire interview hinges on "what happens if two users click the same seat?". If your design doesn't have a per-seat lock with a TTL, you've already failed. Lead with it.
Step 2

Actors & Use Cases

flowchart LR G([Guest]) C([Customer]) F([FrontDesk Officer]) A([Admin]) S([System]) G --> SE[Search Movies] G --> VW[View Show Times] C --> SE C --> BK[Book Seats] C --> PY[Pay] C --> CN[Cancel] F --> WK[Walk-in Booking] A --> AM[Add/Remove Movie] A --> ASH[Add/Remove Show] A --> AC[Block/Unblock Account] S --> EX[Expire Seat Locks] S --> NT[Send Notifications] S --> RF[Process Refunds] style G fill:#7b8599,stroke:#7b8599,color:#fff style C fill:#e8743b,stroke:#e8743b,color:#fff style F fill:#4a90d9,stroke:#4a90d9,color:#fff style A fill:#9b72cf,stroke:#9b72cf,color:#fff style S fill:#38b265,stroke:#38b265,color:#fff

Guest

Unauthenticated browser. Can search; must register to book.

Customer

The booker. Searches, locks seats, pays, cancels.

FrontDesk

In-cinema staff. Books for walk-ins; can override seat locks.

Admin

Configures movies, shows, halls, pricing.

Step 3

Story — From a Box Office to BookMyShow

Pass 1 — The naive design

One method book(showId, seats[], userId). It checks if any of the seats are taken, marks them booked, calls the payment gateway, returns a ticket. Done in 40 lines.

flowchart LR User --> API[book API] API --> DB[(seats table)] API --> PAY[Payment gateway] style API fill:#e05252,stroke:#e05252,color:#fff

💥 Race

Sarah, Raj, Priya all click seat F-12 at the same millisecond. All three pass the "is it taken?" check. All three try to book. Without a database row-lock, all three "succeed" — the cinema overbooks. Read-then-write race.

💥 Slow payment

Sarah's card is being processed; the seat shows "available" to other users. While she's typing her OTP, Raj books her seat. Now what? No payment hold.

💥 Sarah abandons

Sarah picks 3 seats but never pays. They stay "booked" forever. Inventory dies. No TTL on the lock.

💥 Search at scale

20 cities, 1000 cinemas, 50 movies. Naive search is a Cartesian linear scan. No indexing.

💥 Pricing changes

Recliners cost 2× regular. Friday evening shows cost 1.5× weekday morning. Pricing is hardcoded as a column. No strategy.

💥 Refund policy

Cancel 5 min before show — full refund? 90% refund? Nothing? No cancellation rules.

Pass 2 — The mental split

🪑 Inventory plane

What seats exist, what state each is in, who currently holds it. Hot, contended, must support row-level atomic transitions. ShowSeat as a state machine with 4 states, locked by SET-IF-AVAILABLE primitive.

💳 Booking plane

Booking lifecycle, payment, refund, notifications. Saga across 3 services (Booking, Payment, Notification). Idempotent, with compensations.

Pass 3 — The production shape

flowchart TB C([Customer]) C --> AG[① API Gateway] AG --> SS[② SearchService
Repository] AG --> BS[③ BookingService
Saga orchestrator] BS --> SLM[④ SeatLockManager
TTL locks] BS --> SS2[⑤ ShowSeat
State machine] BS --> PR[⑥ PricingStrategy] BS --> PA[⑦ PaymentAdapter] BS --> EXP[⑧ LockExpiryWorker] BS --> NT[⑨ NotificationService] BS --> AL[⑩ AuditLog] style AG fill:#e8743b,stroke:#e8743b,color:#fff style SS fill:#9b72cf,stroke:#9b72cf,color:#fff style BS fill:#4a90d9,stroke:#4a90d9,color:#fff style SLM fill:#d4a838,stroke:#d4a838,color:#000 style SS2 fill:#3cbfbf,stroke:#3cbfbf,color:#fff style PR fill:#9b72cf,stroke:#9b72cf,color:#fff style PA fill:#38b265,stroke:#38b265,color:#fff style EXP fill:#e05252,stroke:#e05252,color:#fff style NT fill:#38b265,stroke:#38b265,color:#fff style AL fill:#e05252,stroke:#e05252,color:#fff

API Gateway

Auth + rate-limit + traffic shaping. The seat-grab endpoint is the most-hit path on the entire site at 6 PM Friday — it must shed load gracefully.

Solves: Without rate-limiting, a runaway script user can lock thousands of seats simultaneously.

SearchService

Repository over an indexed catalog (Elasticsearch / Postgres FTS). Routes "thrillers in Mumbai this weekend" without a full scan.

Solves: Grokking's HashMap-by-string search degenerates to O(N) on real catalogs.

BookingService (Saga)

Orchestrates the multi-step booking: lock seats → quote price → take payment → confirm seats → notify. Each step has a compensation for failure.

Solves: A naive single-method book() can't recover from "payment succeeded but confirm-seat call failed" — Saga makes the failure modes explicit.

SeatLockManager (TTL locks)

The atomic "claim this seat for 8 minutes" primitive. Backed by Redis SETNX with EX 480, or by a database row lock with an locked_until column. Either way, the contract is: at most one client can hold a given seat at a given time.

Solves: The race between Sarah / Raj / Priya — exactly one of them wins the SETNX. The other two see "seat unavailable" instantly.

ShowSeat (State Machine)

Four states: AVAILABLE → LOCKED → BOOKED → AVAILABLE (after cancel). Each transition is method on ShowSeat that throws on illegal moves.

Solves: Stops "book a seat that wasn't locked" or "cancel a non-booked seat" at the type level.

PricingStrategy

Plug-in. FlatRate is base; SeatTypeMultiplier wraps it for premium/recliner; PeakHourMultiplier wraps that for weekend/evening. Stack as decorators.

Solves: "Spider-Man on Friday at 9pm in IMAX" pricing is composable; new rules don't touch the booking code.

PaymentAdapter

Hides Razorpay / Stripe / cash-at-counter. Calls are idempotent via the booking ID; on timeout, retries don't double-charge.

Solves: Real payment gateways flake. Idempotency is non-negotiable.

LockExpiryWorker

Background scan over locked seats whose locked_until has passed. Releases them back to AVAILABLE and emits an event so the UI updates.

Solves: Sarah abandoning her cart can't kill inventory. After 8 minutes, her seats are everybody's seats again.

NotificationService

Subscribes to booking events. Sends confirmation + 1-hour reminder + cancellation receipts. Email, SMS, push — Strategy per channel.

Solves: Inline calls would block the booking endpoint at peak; pub/sub keeps it snappy.

AuditLog

Every lock, book, refund — append-only.

Solves: "I never got my seat" disputes become evidence, not he-said-she-said.

So in short — SeatLockManager is the heart. Every booking starts with a TTL lock. Saga orchestrates the multi-step flow with compensations. Pricing is pluggable strategies. The expiry worker is the reaper that keeps inventory healthy.
Step 4

Core Entities

EntityResponsibilityKey Fields
CityCity listingString name, String state, String zipCode
CinemaOne physical multiplexString cinemaId, City city, List<CinemaHall> halls
CinemaHallOne auditoriumString hallId, int capacity, List<CinemaHallSeat> seats
CinemaHallSeatPhysical seat (template)String seatNumber, SeatType type, String row, col
MovieOne movieString movieId, String title, List<String> languages, String genre, LocalDate releaseDate
ShowOne screeningString showId, Movie movie, CinemaHall hall, LocalDateTime startTime
ShowSeatOne bookable seat for one show — the stateful unitString showSeatId, SeatStatus status, String lockedBy, LocalDateTime lockedUntil, BigDecimal price
BookingOne purchaseString bookingId, String customerId, List<String> seatIds, BookingStatus status
PaymentOne payment attemptString paymentId, BigDecimal amount, PaymentStatus status, String gatewayRef
Step 5

ER / Schema Diagram

erDiagram CITY { string city_id PK string name } CINEMA { string cinema_id PK string city_id FK string name } HALL { string hall_id PK string cinema_id FK int capacity } HALL_SEAT { string seat_id PK string hall_id FK string row_col string type } MOVIE { string movie_id PK string title string genre date release } SHOW { string show_id PK string movie_id FK string hall_id FK datetime start } SHOW_SEAT { string show_seat_id PK string show_id FK string seat_id FK string status string locked_by datetime locked_until decimal price } BOOKING { string booking_id PK string customer_id FK string show_id FK string status decimal total } BOOKING_SEAT { string booking_id FK string show_seat_id FK } PAYMENT { string payment_id PK string booking_id FK decimal amount string status string gateway_ref } CUSTOMER { string customer_id PK string name string email } CITY ||--o{ CINEMA : has CINEMA ||--o{ HALL : has HALL ||--o{ HALL_SEAT : has MOVIE ||--o{ SHOW : screened_as HALL ||--o{ SHOW : hosts SHOW ||--o{ SHOW_SEAT : has HALL_SEAT ||--o{ SHOW_SEAT : "templates" BOOKING ||--o{ BOOKING_SEAT : holds SHOW_SEAT ||--o{ BOOKING_SEAT : "in" BOOKING ||--o{ PAYMENT : "paid via" CUSTOMER ||--o{ BOOKING : made

Key insight: ShowSeat is a separate row per (show, seat) — not a join. This is what lets us put a unique constraint on it and use SET-IF-AVAILABLE for atomic locking.

Step 6

Class Diagram

classDiagram class BookingService { -SeatLockManager locker -PricingStrategy pricer -PaymentAdapter pay +book(showId,seatIds,custId) Booking +cancel(bookingId) } class SeatLockManager { +tryLock(seatIds, custId, ttl) boolean +release(seatIds, custId) +confirm(seatIds, custId) } class ShowSeat { -SeatStatus status -String lockedBy -LocalDateTime lockedUntil +lock(custId) +book() +release() } class PricingStrategy { <> +price(ShowSeat, Show) BigDecimal } class FlatRate class SeatTypeMultiplier class PeakHourMultiplier class PaymentAdapter { <> +charge(amount, idempKey) PaymentResult +refund(paymentId) } class LockExpiryWorker { +run() } class NotificationService class Account { <> } class Customer class Admin class FrontDeskOfficer Account <|-- Customer Account <|-- Admin Account <|-- FrontDeskOfficer PricingStrategy <|.. FlatRate PricingStrategy <|.. SeatTypeMultiplier PricingStrategy <|.. PeakHourMultiplier BookingService --> SeatLockManager BookingService --> PricingStrategy BookingService --> PaymentAdapter BookingService --> ShowSeat
Step 7

Design Patterns Used

🔒 SET-IF-AVAILABLE / TTL Lock — SeatLockManager

The atomic lock primitive. SETNX EX in Redis, or UPDATE ... WHERE status = 'AVAILABLE' in SQL. Returns true exactly once per (seat, time-window).

🚦 State — ShowSeat

4 states, illegal transitions throw. Centralizes the rules.

🎯 Strategy + Decorator — PricingStrategy

FlatRate as base; SeatType / PeakHour stack on top. Adding "first-show-discount" is a new decorator.

🧩 Saga — BookingService

lock → quote → pay → confirm. Each step has a compensation: release locks if quote fails; refund if confirm fails.

🛡️ Adapter — PaymentAdapter

Hides Razorpay/Stripe behind charge / refund. Idempotent via booking ID.

📡 Observer — NotificationService

Listens to BookingConfirmedEvent, BookingCancelledEvent. Multiple subscribers (email, SMS, push).

Step 8

Sequence — Sarah, Raj, Priya all click F-12

Three users, same seat, same millisecond. Watch how SeatLockManager picks exactly one winner.

sequenceDiagram actor Sarah actor Raj actor Priya participant BS as BookingService participant SLM as SeatLockManager participant SS as ShowSeat F-12 participant PA as PaymentAdapter par All click at the same millisecond Sarah->>BS: lock(F-12, sarah) Raj->>BS: lock(F-12, raj) Priya->>BS: lock(F-12, priya) end BS->>SLM: tryLock(F-12, sarah, 480s) SLM->>SS: SETNX(locked_by=sarah, until=now+8m) SS-->>SLM: ok (atomic) SLM-->>BS: WIN BS-->>Sarah: lock granted, 8 min to pay BS->>SLM: tryLock(F-12, raj, 480s) SLM->>SS: SETNX (already locked) SS-->>SLM: fail SLM-->>BS: LOSE BS-->>Raj: "seat just taken, pick another" BS->>SLM: tryLock(F-12, priya, 480s) SLM-->>BS: LOSE BS-->>Priya: "seat just taken, pick another" Note over Sarah: 4 minutes later Sarah->>BS: pay(bookingId) BS->>PA: charge(amount, idemKey=bookingId) PA-->>BS: SUCCESS BS->>SS: book() SS->>SS: state = BOOKED BS-->>Sarah: confirmed
The trick: the SETNX is atomic in Redis (or row-lock in SQL). Three concurrent calls — exactly one wins. This single primitive is the difference between a working ticket system and a class-action lawsuit.
Step 9

State Diagram — ShowSeat

stateDiagram-v2 [*] --> AVAILABLE : show created AVAILABLE --> LOCKED : tryLock(custId) success LOCKED --> BOOKED : payment success LOCKED --> AVAILABLE : lock expires (8 min) LOCKED --> AVAILABLE : user cancels checkout BOOKED --> AVAILABLE : booking cancelled (with refund) BOOKED --> [*] : show finished
Step 10

Java Implementation

Enums
public enum SeatStatus { AVAILABLE, LOCKED, BOOKED }
public enum SeatType { REGULAR, PREMIUM, RECLINER, ACCESSIBLE, EMERGENCY_EXIT }
public enum BookingStatus { PENDING, CONFIRMED, CANCELED, EXPIRED }
public enum PaymentStatus { PENDING, SUCCESS, FAILED, REFUNDED }
ShowSeat — State Machine + atomic lock
public class ShowSeat {
  private final String id;
  private SeatStatus status = SeatStatus.AVAILABLE;
  private String lockedBy;
  private LocalDateTime lockedUntil;
  private final SeatType type;

  public synchronized boolean tryLock(String custId, Duration ttl) {
    if (status == SeatStatus.LOCKED && LocalDateTime.now().isAfter(lockedUntil)) {
      expireLock();  // stale lock, sweep it
    }
    if (status != SeatStatus.AVAILABLE) return false;
    status = SeatStatus.LOCKED;
    lockedBy = custId;
    lockedUntil = LocalDateTime.now().plus(ttl);
    return true;
  }

  public synchronized void book(String custId) {
    if (status != SeatStatus.LOCKED) throw new IllegalStateException("not locked");
    if (!custId.equals(lockedBy)) throw new IllegalStateException("wrong holder");
    status = SeatStatus.BOOKED;
    lockedUntil = null;
  }

  public synchronized void release(String custId) {
    if (status == SeatStatus.LOCKED && custId.equals(lockedBy)) expireLock();
  }

  public synchronized void cancelBooking() {
    if (status != SeatStatus.BOOKED) throw new IllegalStateException();
    status = SeatStatus.AVAILABLE;
  }

  private void expireLock() { status = SeatStatus.AVAILABLE; lockedBy = null; lockedUntil = null; }
}
SeatLockManager — atomic batch lock
public class SeatLockManager {
  public List<ShowSeat> tryLockAll(List<ShowSeat> seats, String custId, Duration ttl) {
    // always lock in canonical (sorted) order to prevent deadlock
    seats.sort(Comparator.comparing(ShowSeat::getId));
    List<ShowSeat> locked = new ArrayList<>();
    for (ShowSeat s : seats) {
      if (s.tryLock(custId, ttl)) locked.add(s);
      else {
        // rollback any partial locks — all-or-nothing
        locked.forEach(l -> l.release(custId));
        throw new SeatNotAvailableException(s.getId());
      }
    }
    return locked;
  }
}
PricingStrategy — Strategy + Decorator
public interface PricingStrategy { BigDecimal price(ShowSeat s, Show sh); }

public class FlatRate implements PricingStrategy {
  private final BigDecimal base;
  public FlatRate(BigDecimal b) { base = b; }
  public BigDecimal price(ShowSeat s, Show sh) { return base; }
}

public class SeatTypeMultiplier implements PricingStrategy {
  private final PricingStrategy inner;
  public SeatTypeMultiplier(PricingStrategy i) { inner = i; }
  public BigDecimal price(ShowSeat s, Show sh) {
    BigDecimal mult = switch (s.getType()) {
      case RECLINER -> new BigDecimal("2.5");
      case PREMIUM  -> new BigDecimal("1.5");
      default      -> BigDecimal.ONE;
    };
    return inner.price(s, sh).multiply(mult);
  }
}

public class PeakHourMultiplier implements PricingStrategy {
  private final PricingStrategy inner;
  public PeakHourMultiplier(PricingStrategy i) { inner = i; }
  public BigDecimal price(ShowSeat s, Show sh) {
    boolean peak = sh.isWeekend() || sh.getStartTime().getHour() >= 18;
    return peak ? inner.price(s, sh).multiply(new BigDecimal("1.3")) : inner.price(s, sh);
  }
}
BookingService — Saga
public class BookingService {
  private final SeatLockManager locker;
  private final PricingStrategy pricer = new PeakHourMultiplier(new SeatTypeMultiplier(new FlatRate(new BigDecimal("200"))));
  private final PaymentAdapter pay;
  private final EventBus bus;

  public Booking book(String showId, List<String> seatIds, String custId, PaymentMethod method) {
    Show sh = ShowRepo.find(showId);
    List<ShowSeat> seats = sh.getSeats(seatIds);
    // 1. lock
    locker.tryLockAll(seats, custId, Duration.ofMinutes(8));
    try {
      // 2. quote
      BigDecimal total = seats.stream().map(s -> pricer.price(s, sh))
        .reduce(BigDecimal.ZERO, BigDecimal::add);
      Booking b = Booking.create(custId, showId, seatIds, total);
      // 3. pay (idempotent via bookingId)
      PaymentResult pr = pay.charge(total, b.getId(), method);
      if (!pr.isSuccess()) throw new PaymentFailedException(pr);
      // 4. confirm
      seats.forEach(s -> s.book(custId));
      b.confirm();
      bus.publish(new BookingConfirmedEvent(b));
      return b;
    } catch (Exception ex) {
      seats.forEach(s -> s.release(custId));  // compensate
      throw ex;
    }
  }

  public void cancel(String bookingId) {
    Booking b = BookingRepo.find(bookingId);
    if (b.minutesUntilShow() < 30) throw new CancellationCutoffException();
    pay.refund(b.getPaymentId(), refundAmount(b));
    b.getSeats().forEach(ShowSeat::cancelBooking);
    b.cancel();
    bus.publish(new BookingCanceledEvent(b));
  }
}
Step 11

Intuition — How to arrive at this design from scratch

1️⃣ The interview is about the race

Lead with seat-locking. "Two users on the same seat" is THE question.

2️⃣ Lock has a TTL

If users could lock forever, abandoned carts kill the business. 8 minutes is the industry default.

3️⃣ Pay before book

Lock → pay → book. Not pay-after-book. Otherwise you'd have a "paid but no seat" scenario.

4️⃣ Saga for multi-step

Lock → quote → pay → confirm; each step has a compensation. Failure is a first-class concern.

5️⃣ Pricing decorates

Base + seat type + peak time. Each is a decorator; new rules add a layer.

6️⃣ All-or-nothing on multi-seat

If user wants 4 seats and only 3 are available, fail the whole batch — don't lock 3 and stick them with a partial booking.

7️⃣ Notifications are async

SMTP slow → don't block booking. Pub/sub.

8️⃣ Search at scale

Repository over inverted index. "Thrillers in Mumbai this weekend" returns in 50ms, not 5s.

Step 12

Improvements over the Grokking Reference Design

AreaGrokkingThis designWhy it matters
Seat lockNot addressed at allSeatLockManager with atomic SETNX + TTLThe entire interview is about this race; not modeling it = automatic fail
Payment holdNone — booking and payment lumpedLock → pay → book Saga with 8-minute holdAllows the user to actually finish typing their card number without losing the seat
Lock expiryNot modeledLockExpiryWorker sweeps stale locksWithout this, abandoned carts permanently freeze inventory
PricingSingle price field on ShowSeatPricingStrategy with stackable decorators (SeatType, PeakHour)Real cinemas charge differently for premium / IMAX / weekend; one column won't capture it
Multi-seat atomicityNo mentiontryLockAll — all-or-nothing batch lock with rollback"Lock 3 of 4 seats" is worse than failing — user pays for 3, can't seat the family
CancellationStatus enum has CANCELED but no policy30-min cutoff + tiered refund + automatic seat releaseReal cancellation policies are time-bound; no cutoff = abuse
SearchHashMap by exact titleRepository over inverted index"thrillers in Mumbai this weekend" needs multi-field, not exact-match
PaymentDirect Payment classPaymentAdapter with idempotent charge via bookingIdNetwork retries don't double-charge; gateway swap is one class
NotificationsNot mentionedObserver via EventBus, async, multi-channelDecouples + scalable; doesn't block booking endpoint
Step 13

Extension Points

🎟️ Coupons

New CouponPricingDecorator wraps the existing chain. Subtracts discount before total. No edits to BookingService.

👨‍👩‍👧 Group bookings

New endpoint accepting a group lease (longer TTL, larger seat batch). The lock manager already supports both.

🍿 Food add-ons

New BookingItem abstraction with SeatItem and SnackItem subclasses. Booking total stacks across both.

Step 14

Trade-offs & Talking Points

DecisionAlternativeWhy this choice
TTL lock (8 min)Pessimistic DB row-lock for the whole flowRow-lock blocks other readers; TTL lock + state machine lets the seat display as locked while still being introspectable
Lock-then-payPay-then-confirmPay-first risks "paid but no seat"; lock-first fences the inventory while payment runs
Saga patternSingle transaction across servicesDistributed transactions don't work across Razorpay + your DB. Saga + idempotent compensations is the realistic shape
Decorator pricingSingle big formulaDecorators stack; formula doesn't — and the formula is the thing that grows the most over time
Sort-then-lockLock in user-given orderSorted order prevents A-then-B vs B-then-A deadlocks across concurrent bookings
Idempotency via bookingIdIdempotency via timestampBookingId is stable across retries; timestamp drifts
Step 15

Interview Q & A

Two users click the same seat at the same instant. What happens?
SeatLockManager.tryLock calls a primitive that's atomic — Redis SETNX EX, or in SQL, UPDATE show_seat SET status='LOCKED' WHERE id=? AND status='AVAILABLE'. Exactly one of the two SQL statements affects 1 row; the other affects 0. The 0-affected user sees "seat unavailable" and is told to pick a different seat.
User locks seats but never pays. How does the seat come back?
Each lock has a locked_until timestamp set at now() + 8 min. LockExpiryWorker runs every 30 seconds, finds rows where status='LOCKED' AND locked_until < now(), flips them back to AVAILABLE, and emits a SeatReleasedEvent so the UI updates.
What if payment succeeds but the confirm-seat call fails?
Saga compensation. The seats are still LOCKED at this point (we haven't called book() yet — the order is: lock → pay → book). If book() throws, we keep retrying it; the seats are still ours because we hold the lock. If retries exhaust, we call refund(paymentId) and release the locks. The customer's money is not lost.
Why lock-then-pay and not pay-then-book?
Pay-first means a user could pay and then someone else's booking races in and takes their seat. Lock-first ensures the seat is already ours when payment runs. The TTL on the lock prevents abandonment from breaking inventory.
User wants 4 seats but only 3 are available. What happens?
tryLockAll is all-or-nothing. It locks seats one by one in sorted order (sorting prevents deadlock between two concurrent multi-seat bookings). If any seat fails to lock, it releases all the previously-locked ones and throws. The user sees "not all 4 seats available; please pick again" — they don't get charged for 3.
How do you handle peak-hour pricing without rewriting?
Decorator. PeakHourMultiplier wraps any inner PricingStrategy and multiplies by 1.3 if the show is on a weekend or after 6pm. To enable, swap the bean: new PeakHourMultiplier(new SeatTypeMultiplier(new FlatRate(...))). To disable, drop the outer wrapper. Zero edits to BookingService.
Cancellation 5 minutes before the show — refund?
Configurable cutoff. Default policy: > 24h before = full refund, 1-24h = 50%, 30min-1h = 25%, < 30min = no cancellation. The cutoff lives on a CancellationPolicy class, swappable per cinema chain. The refund amount is computed at cancel-time and called via PaymentAdapter.refund(paymentId, amount).
Search "thriller in Mumbai this weekend" — how?
SearchService maintains an inverted index keyed on (city, genre, language, date-range). The query is a multi-field intersection. For 1M shows / 200 cities / 30 days the index fits in memory; otherwise Elasticsearch. Either way, the Repository facade lets the storage engine swap without callers changing.