A real BookMyShow-style booking — Seat-locking with TTL, Saga payment flow, dynamic pricing Strategy, full Java code & the concurrency story Grokking glosses over.
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.
Unauthenticated browser. Can search; must register to book.
The booker. Searches, locks seats, pays, cancels.
In-cinema staff. Books for walk-ins; can override seat locks.
Configures movies, shows, halls, pricing.
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.
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.
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 picks 3 seats but never pays. They stay "booked" forever. Inventory dies. No TTL on the lock.
20 cities, 1000 cinemas, 50 movies. Naive search is a Cartesian linear scan. No indexing.
Recliners cost 2× regular. Friday evening shows cost 1.5× weekday morning. Pricing is hardcoded as a column. No strategy.
Cancel 5 min before show — full refund? 90% refund? Nothing? No cancellation rules.
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 lifecycle, payment, refund, notifications. Saga across 3 services (Booking, Payment, Notification). Idempotent, with compensations.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Every lock, book, refund — append-only.
Solves: "I never got my seat" disputes become evidence, not he-said-she-said.
| Entity | Responsibility | Key Fields |
|---|---|---|
City | City listing | String name, String state, String zipCode |
Cinema | One physical multiplex | String cinemaId, City city, List<CinemaHall> halls |
CinemaHall | One auditorium | String hallId, int capacity, List<CinemaHallSeat> seats |
CinemaHallSeat | Physical seat (template) | String seatNumber, SeatType type, String row, col |
Movie | One movie | String movieId, String title, List<String> languages, String genre, LocalDate releaseDate |
Show | One screening | String showId, Movie movie, CinemaHall hall, LocalDateTime startTime |
ShowSeat | One bookable seat for one show — the stateful unit | String showSeatId, SeatStatus status, String lockedBy, LocalDateTime lockedUntil, BigDecimal price |
Booking | One purchase | String bookingId, String customerId, List<String> seatIds, BookingStatus status |
Payment | One payment attempt | String paymentId, BigDecimal amount, PaymentStatus status, String gatewayRef |
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.
SeatLockManagerThe atomic lock primitive. SETNX EX in Redis, or UPDATE ... WHERE status = 'AVAILABLE' in SQL. Returns true exactly once per (seat, time-window).
ShowSeat4 states, illegal transitions throw. Centralizes the rules.
PricingStrategyFlatRate as base; SeatType / PeakHour stack on top. Adding "first-show-discount" is a new decorator.
BookingServicelock → quote → pay → confirm. Each step has a compensation: release locks if quote fails; refund if confirm fails.
PaymentAdapterHides Razorpay/Stripe behind charge / refund. Idempotent via booking ID.
NotificationServiceListens to BookingConfirmedEvent, BookingCancelledEvent. Multiple subscribers (email, SMS, push).
Three users, same seat, same millisecond. Watch how SeatLockManager picks exactly one winner.
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 }
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; } }
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; } }
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); } }
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)); } }
Lead with seat-locking. "Two users on the same seat" is THE question.
If users could lock forever, abandoned carts kill the business. 8 minutes is the industry default.
Lock → pay → book. Not pay-after-book. Otherwise you'd have a "paid but no seat" scenario.
Lock → quote → pay → confirm; each step has a compensation. Failure is a first-class concern.
Base + seat type + peak time. Each is a decorator; new rules add a layer.
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.
SMTP slow → don't block booking. Pub/sub.
Repository over inverted index. "Thrillers in Mumbai this weekend" returns in 50ms, not 5s.
| Area | Grokking | This design | Why it matters |
|---|---|---|---|
| Seat lock | Not addressed at all | SeatLockManager with atomic SETNX + TTL | The entire interview is about this race; not modeling it = automatic fail |
| Payment hold | None — booking and payment lumped | Lock → pay → book Saga with 8-minute hold | Allows the user to actually finish typing their card number without losing the seat |
| Lock expiry | Not modeled | LockExpiryWorker sweeps stale locks | Without this, abandoned carts permanently freeze inventory |
| Pricing | Single price field on ShowSeat | PricingStrategy with stackable decorators (SeatType, PeakHour) | Real cinemas charge differently for premium / IMAX / weekend; one column won't capture it |
| Multi-seat atomicity | No mention | tryLockAll — 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 |
| Cancellation | Status enum has CANCELED but no policy | 30-min cutoff + tiered refund + automatic seat release | Real cancellation policies are time-bound; no cutoff = abuse |
| Search | HashMap by exact title | Repository over inverted index | "thrillers in Mumbai this weekend" needs multi-field, not exact-match |
| Payment | Direct Payment class | PaymentAdapter with idempotent charge via bookingId | Network retries don't double-charge; gateway swap is one class |
| Notifications | Not mentioned | Observer via EventBus, async, multi-channel | Decouples + scalable; doesn't block booking endpoint |
New CouponPricingDecorator wraps the existing chain. Subtracts discount before total. No edits to BookingService.
New endpoint accepting a group lease (longer TTL, larger seat batch). The lock manager already supports both.
New BookingItem abstraction with SeatItem and SnackItem subclasses. Booking total stacks across both.
| Decision | Alternative | Why this choice |
|---|---|---|
| TTL lock (8 min) | Pessimistic DB row-lock for the whole flow | Row-lock blocks other readers; TTL lock + state machine lets the seat display as locked while still being introspectable |
| Lock-then-pay | Pay-then-confirm | Pay-first risks "paid but no seat"; lock-first fences the inventory while payment runs |
| Saga pattern | Single transaction across services | Distributed transactions don't work across Razorpay + your DB. Saga + idempotent compensations is the realistic shape |
| Decorator pricing | Single big formula | Decorators stack; formula doesn't — and the formula is the thing that grows the most over time |
| Sort-then-lock | Lock in user-given order | Sorted order prevents A-then-B vs B-then-A deadlocks across concurrent bookings |
| Idempotency via bookingId | Idempotency via timestamp | BookingId is stable across retries; timestamp drifts |
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.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.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.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.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.PaymentAdapter.refund(paymentId, amount).