Why this problem is about one race condition
A movie ticket booking system, in the style of BookMyShow, looks like a catalogue problem: cities, theatres, screens, movies, shows and seats. The catalogue is the easy part. The interview is really about one moment: two people tap the same seat at the same time. If both are told "yours", one of them arrives at the cinema to find a stranger in their seat. That is a double booking, and the whole design exists to make it impossible.
Interviewers use this problem to see whether you can:
- Model a catalogue with clean relationships and avoid storing per-show seat state on the physical seat.
- Explain the seat hold: reserving seats for a few minutes while the user pays, and releasing them if the user walks away.
- Show the double-booking race step by step and fix it with pessimistic locking (lock first, then check) or optimistic locking (check versions and retry), and explain when each is better.
- Handle the awkward edges: payment declined, payment succeeding after the hold expired, cancellation with a refund policy.
This lesson builds all of that in Java. The program first demonstrates the double booking with a deliberately broken implementation, then runs 200 threads against both a pessimistic and an optimistic inventory and checks that no seat is ever held twice. A shorter, high-level view of booking systems is in Booking and payments design; this lesson stays at the class and code level.
Problem statement
Design an online movie ticket booking system. Users choose a city, see movies playing there, pick a show (a movie on a screen at a time), view the seat map, select seats, pay, and receive a booking. Users can cancel bookings under a refund policy. The system must never sell the same seat for the same show twice.
Clarifying questions and assumed requirements
| Question | Assumed answer | Effect on the design |
|---|---|---|
| Multiple cities and theatres? | Yes | City and Theatre entities |
| Seats differ in price? | Yes, by category: regular and premium | SeatCategory, price per category per show |
| Can users pick specific seats? | Yes, from a seat map | Per-seat state per show |
| How long can a user hold seats while paying? | 10 minutes | Hold with an expiry time |
| Maximum seats per booking? | 10 | Validation |
| What if payment fails? | Seats are released immediately | Release on decline |
| Cancellation? | Allowed before the show with a tiered refund | RefundPolicy strategy |
| Partial success (some seats free, some taken)? | All or nothing | Atomic multi-seat hold |
| Food, offers, loyalty points? | Out of scope | Extensions |
| One server or many? | Design in memory, explain the database version | Concurrency section |
Functional requirements
- Browse shows by city and movie.
- Show the seat map with each seat's availability for a chosen show.
- Hold a set of seats for 10 minutes, all or nothing.
- Confirm the booking after successful payment.
- Release held seats when payment is declined or the hold expires.
- Cancel a booking and refund according to a policy.
Non-functional requirements
- No double booking, ever, under any interleaving of requests.
- High concurrency for popular shows: thousands of users may open the same seat map when bookings open for a big release.
- Fairness and responsiveness: a user who loses a race should learn quickly and choose again, not wait on a lock for seconds.
- Consistency between money and seats: nobody is charged without a booking, and every booking is paid.
Core use cases
- Search: "Bengaluru, The Long Monsoon" returns tonight's shows.
- View seats: show which seats are free right now.
- Hold: user selects A3 and A4; both are held for 10 minutes or the request fails.
- Book: user pays; the hold turns into a booking.
- Decline: payment fails; seats go back on sale immediately.
- Expire: user abandons the payment page; after 10 minutes the seats are free again.
- Cancel: user cancels 28 hours before the show and gets a full refund.
Entities and relationships
The key modelling decision: a physical seat and a seat in a show are different things. Seat A5 in Screen 1 exists for years. Whether A5 is free depends on which show you mean. Storing isBooked on Seat is the classic mistake, because the 6 pm and 9 pm shows share the same physical seat.
City 1---* Theatre 1---* Screen 1---* Seat (id, category)
|
Movie 1---* Show *---------+ Show = movie + screen + start + prices
|
| 1
*
ShowSeat state (show, seat) -> AVAILABLE | HELD | BOOKED
^ owner id, hold expiry, version
|
SeatInventory <--- BookingService ---> PaymentGateway
^ ^ | \--> RefundPolicy
| | v
Pessimistic Optimistic Hold (id, user, show, seats, expiry)
Booking (id, user, show, seats, amount, status)
| Class | Responsibility |
|---|---|
City, Movie, Theatre, Screen, Seat | Static catalogue; immutable records |
Show | A movie on a screen at a start time, with a price per seat category |
SeatState | Immutable snapshot of one seat in one show: status, owner, hold expiry, version |
SeatInventory | The concurrency-critical contract: hold, confirm, release |
PessimisticInventory, OptimisticInventory | Two ways to make hold and confirm atomic |
Hold | Seats reserved for a user until an expiry time |
Booking | Confirmed, paid seats; can be cancelled |
BookingService | Orchestrates search, hold, pay, confirm, cancel |
PaymentGateway | External payment provider behind an interface |
RefundPolicy | How much to refund given the time until the show |
Seat state machine
hold() ok confirm() ok
AVAILABLE --------------> HELD ------------------------> BOOKED
^ | | |
| payment declined | | hold expiry passes |
+---------------------+ | (lazy: treated as free) |
^ | |
+-------------------------+ |
| cancel() |
+-------------------------------------------------------+
Design decisions and patterns
Holds instead of booking on click
Payment takes time: OTPs, bank pages, UPI app switches. If seats were only marked on successful payment, two users could both pay for A5, and one would need a refund and an apology. If seats were booked on click, abandoned payments would lock seats forever. The hold is the middle ground: a time-limited reservation that blocks others while one user pays.
Lazy expiry
How do expired holds become free? Two options:
- Active expiry: a background job scans for expired holds every few seconds and marks them
AVAILABLE. - Lazy expiry: never update expired holds; whenever you read a seat, treat a hold whose expiry has passed as free.
The code uses lazy expiry through one method:
boolean isFreeAt(Instant now) {
return status == SeatStatus.AVAILABLE
|| (status == SeatStatus.HELD && !now.isBefore(holdExpiresAt));
}
Lazy expiry is correct the instant a hold expires, needs no extra thread, and cannot fall behind under load. A sweeper job can still run occasionally to tidy up, but correctness no longer depends on it. Redis key expiry uses a similar combination of lazy checks and periodic sampling.
Immutable seat snapshots
Each seat's state is a Java record (an immutable data class) held inside an AtomicReference. A change never edits a snapshot; it swaps in a new one with version + 1. This makes optimistic locking natural, because "has anyone changed this seat since I read it?" becomes "is the reference still the same object?", and makes it safe to hand snapshots to readers without copying.
Strategy for the inventory and refund policy
SeatInventory is an interface with two implementations, pessimistic and optimistic, so you can switch approach or test both against the same scenario. RefundPolicy is a Strategy: theatres and festivals change refund rules, and the booking service should not change with them. PaymentGateway is an interface so that tests use a fake and production uses a real provider.
The booking service as a coordinator
BookingService holds no seat logic. It validates input, asks the inventory for a hold, calls the gateway, asks the inventory to confirm, and records bookings. This is sometimes called a facade or application service: one entry point that coordinates several components.
The double-booking race, step by step
Here is the broken version. It checks that every seat is free, then marks them held:
for (String id : ids) if (!state(showId, id).isFreeAt(now)) return false; // check
for (String id : ids) ref(showId, id).set(held(...)); // act
Two users, Alice and Bob, both want A5:
time Alice's request Bob's request
t1 read A5 -> AVAILABLE
t2 read A5 -> AVAILABLE
t3 write A5 = HELD by alice
t4 write A5 = HELD by bob (overwrites)
t5 "A5 is yours" "A5 is yours"
Both checks passed because neither write had happened yet. Both users are told they have the seat. Alice's hold has been silently overwritten; she pays and confirm will fail, or worse, with a less careful confirm both bookings go through.
Races like this are hard to reproduce because the gap between t1 and t3 is usually nanoseconds. The demo's NaiveInventory makes the race certain: it puts a CyclicBarrier (a meeting point that blocks until a set number of threads arrive) between the check and the act, so both threads always finish checking before either writes. The output line alice got A5: true, bob got A5: true is the bug, reproduced on demand. Forcing a race with a barrier or latch is a useful technique in your own tests.
The fix must make "check all seats free, then mark all held" behave as one indivisible step for any two requests that share a seat. Background on races and atomicity is in Synchronization, and the database view is in Concurrency control.
Pessimistic locking
Pessimistic locking assumes conflicts are likely, so it locks data before reading it. Nobody else can change the seat while you hold its lock.
time Alice Bob
t1 lock(A5)
t2 read A5 -> AVAILABLE lock(A5) ... waits
t3 write A5 = HELD by alice ... waits
t4 unlock(A5) acquires lock
t5 read A5 -> HELD: return false
Lock granularity
What should one lock cover?
| Granularity | Effect |
|---|---|
| One lock for the whole system | Correct; every booking in every city waits on one lock. Unacceptable. |
| One lock per show | Correct and simple; two users booking different seats in the same show still wait for each other. Often acceptable. |
| One lock per seat in a show | Users with disjoint seats proceed in parallel. Needs care with multi-seat requests. |
The code locks per seat. A multi-seat hold must lock several seats, and that introduces deadlock risk. If Alice locks A3 then wants A4, while Bob locks A4 then wants A3, each waits for the other forever. The fix is a global lock order: always lock seats sorted by id. Both users then lock A3 first; whoever gets it also gets A4, and the other waits. A circular wait becomes impossible (see Deadlocks). The demo deliberately submits half the requests with seats in reverse order to prove the sorting works: 200 threads finish without hanging.
List<String> sorted = new ArrayList<>(ids);
Collections.sort(sorted); // global order: seat id
for (String id : sorted) locks.get(id).lock();
Locks are released in a finally block so an exception never leaves a seat locked.
Pessimistic locking in a database
With a relational database the same idea is:
BEGIN;
SELECT status, hold_expires_at FROM show_seat
WHERE show_id = 42 AND seat_id IN ('A3','A4')
ORDER BY seat_id
FOR UPDATE; -- row locks, taken in seat order
-- if every row is free:
UPDATE show_seat SET status = 'HELD', owner = 'H17', hold_expires_at = now() + interval '10 minutes'
WHERE show_id = 42 AND seat_id IN ('A3','A4');
COMMIT;
SELECT ... FOR UPDATE locks the rows until commit. The ORDER BY matters for the same deadlock reason, although databases also detect deadlocks and abort one transaction.
Optimistic locking
Optimistic locking assumes conflicts are rare, so it takes no locks. It reads the data along with a version number, does its work, and writes only if the version is unchanged. If someone else changed the row in the meantime, the write fails and the caller retries or reports a conflict.
time Alice Bob
t1 read A5 (AVAILABLE, v7)
t2 read A5 (AVAILABLE, v7)
t3 CAS A5: v7 -> HELD v8 success
t4 CAS A5: v7 -> HELD v8 FAILS
t5 "A5 is yours" "seat just taken"
CAS (compare-and-set) is the in-memory version: AtomicReference.compareAndSet(expected, next) replaces the value only if it is still the exact object you read. In SQL, the same thing is a conditional update:
UPDATE show_seat
SET status = 'HELD', owner = 'H17', version = version + 1,
hold_expires_at = now() + interval '10 minutes'
WHERE show_id = 42 AND seat_id = 'A5' AND version = 7;
-- 1 row updated: you won. 0 rows updated: someone else changed it.
Multi-seat optimistic holds need compensation
With several seats, Alice may succeed on A3 and then fail on A4. She must then undo A3, restoring it as she found it, before reporting failure. That undo is a compensating action. The code's swapAll does exactly this. In a database you would instead put all the conditional updates in one transaction and roll back if any affects zero rows, which gives the all-or-nothing behaviour for free.
A side effect of in-memory compensation: for a moment, A3 looks held, so a third user checking A3 in that instant may be told it is taken when it is about to be freed. That is a false "taken", never a double booking, so it is safe; the user simply tries again.
Which one to choose
| Pessimistic | Optimistic | |
|---|---|---|
| Assumes | Conflicts are common | Conflicts are rare |
| Cost when no conflict | Lock and unlock | One extra version check |
| Cost on conflict | Loser waits, then fails | Loser fails fast, may retry |
| Deadlock risk | Yes, unless locks are ordered | None |
| Holding locks across slow work | Dangerous | Not applicable |
| Fits | One hot show at bookings opening; short critical sections | Most shows on most days; read-heavy seat maps |
For a typical show, optimistic locking is a great default: almost every seat is contested by nobody. For the first minutes after bookings open for a blockbuster, many users fight over the same few rows; optimistic attempts then fail repeatedly, and some systems put users in a virtual waiting room (a queue that admits users in batches) before letting them pick seats. Many real systems combine approaches: optimistic or atomic conditional updates at the database, plus a short-lived distributed hold in a cache.
Interview tip
Do not just say "use locks". Draw the two-user interleaving, show where it breaks, then show the same interleaving with your fix. Then compare optimistic and pessimistic in two sentences and pick one for the stated load. This sequence is what interviewers are listening for.
Common mistake
Holding a lock (or a database transaction) while calling the payment gateway. Payment can take a minute. A lock held that long blocks every other user of those seats, and a database transaction held that long ties up a connection and its row locks. The hold exists precisely so that payment happens outside any lock.
Payment, expiry and the awkward edges
The booking flow in BookingService.book is:
1. Look up the hold; fail if unknown.
2. If the hold already expired: fail, ask the user to choose again.
3. Charge the user.
- Declined: release the seats now (do not wait for expiry), fail.
4. Confirm the hold in the inventory (atomic: still HELD, still mine, not expired).
- If confirm fails (hold expired while paying, or was taken): refund in full.
5. Record the booking.
Step 4 handles a subtle race: the user's payment can finish after the hold expired and someone else took the seat. You cannot un-charge a card by wishing, so you issue a refund, another compensating action. Real systems reduce how often this happens by extending the hold when payment starts, or by making the hold a little longer than the payment page timeout.
Refund policy, worked out
The demo uses a tiered policy:
more than 24 h before the show -> 100%
more than 4 h, up to 24 h -> 50%
4 h or less -> 0%
In the demo, the show starts at 13:00 UTC on 12 October (18:30 in India).
- Hari books B2 and B3 (regular, Rs 200 each, total Rs 400) at 08:11 on 11 October, then cancels. Time to show is 28 h 49 min, more than 24 h, so the refund is 100% of 400 = Rs 400.
- The clock moves 20 hours to 04:11 on 12 October. Dev cancels A3 and A4 (premium, Rs 350 each, total Rs 700). Time to show is 8 h 49 min, between 4 and 24 h, so the refund is 50% of 700 = Rs 350.
The refund is computed with integers: amount * pct / 100, so 700 × 50 / 100 = 350 exactly. Cancellation is guarded by synchronized (booking) and a status check, so a double tap on "Cancel" cannot refund twice.
Complete Java implementation
Save as MovieBookingDemo.java, compile with javac MovieBookingDemo.java, run with java MovieBookingDemo. Java 17 or later.
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.concurrent.locks.ReentrantLock;
// ---------- Catalogue: cities, theatres, screens, shows ----------
record City(String name) {}
record Movie(String id, String title, int minutes) {}
enum SeatCategory { REGULAR, PREMIUM }
record Seat(String id, SeatCategory category) {} // id like "A5"
record Screen(String id, List<Seat> seats) {}
record Theatre(String id, String name, City city, List<Screen> screens) {}
record Show(String id, Movie movie, Theatre theatre, Screen screen, Instant start,
Map<SeatCategory, Long> price) {}
// ---------- Seat state for one show ----------
enum SeatStatus { AVAILABLE, HELD, BOOKED }
/** Immutable snapshot of one seat in one show. A new object replaces it on every change. */
record SeatState(SeatStatus status, String ownerId, Instant holdExpiresAt, long version) {
static SeatState available(long version) { return new SeatState(SeatStatus.AVAILABLE, null, null, version); }
/** Lazy expiry: a hold past its deadline counts as available without any background job. */
boolean isFreeAt(Instant now) {
return status == SeatStatus.AVAILABLE
|| (status == SeatStatus.HELD && !now.isBefore(holdExpiresAt));
}
}
/** The contract every locking approach implements. */
interface SeatInventory {
/** Hold all seats or none. Returns false if any seat is taken. */
boolean hold(String showId, List<String> seatIds, String holdId, Instant now, Instant expiresAt);
/** Turn a still-valid hold into a booking. */
boolean confirm(String showId, List<String> seatIds, String holdId, String bookingId, Instant now);
void release(String showId, List<String> seatIds, String ownerId);
SeatState state(String showId, String seatId);
String name();
}
abstract class BaseInventory implements SeatInventory {
protected final Map<String, AtomicReference<SeatState>> seats = new ConcurrentHashMap<>();
void addShow(Show show) {
for (Seat s : show.screen().seats()) seats.put(key(show.id(), s.id()), new AtomicReference<>(SeatState.available(0)));
}
static String key(String showId, String seatId) { return showId + "/" + seatId; }
AtomicReference<SeatState> ref(String showId, String seatId) {
AtomicReference<SeatState> r = seats.get(key(showId, seatId));
if (r == null) throw new NoSuchElementException("no seat " + seatId + " in " + showId);
return r;
}
public SeatState state(String showId, String seatId) { return ref(showId, seatId).get(); }
public void release(String showId, List<String> seatIds, String ownerId) {
for (String id : seatIds) {
AtomicReference<SeatState> r = ref(showId, id);
SeatState cur = r.get();
if (ownerId.equals(cur.ownerId())) r.compareAndSet(cur, SeatState.available(cur.version() + 1));
}
}
}
/** Deliberately broken: check, then act, with a gap in between. Used only to show the race. */
final class NaiveInventory extends BaseInventory {
private final CyclicBarrier gap;
NaiveInventory(CyclicBarrier gap) { this.gap = gap; }
public String name() { return "naive"; }
public boolean hold(String showId, List<String> ids, String holdId, Instant now, Instant exp) {
for (String id : ids) if (!state(showId, id).isFreeAt(now)) return false; // check
try { gap.await(); } catch (Exception e) { throw new RuntimeException(e); } // both threads get here
for (String id : ids) { // act
AtomicReference<SeatState> r = ref(showId, id);
r.set(new SeatState(SeatStatus.HELD, holdId, exp, r.get().version() + 1));
}
return true;
}
public boolean confirm(String s, List<String> ids, String h, String b, Instant now) { return false; }
}
/** Pessimistic: lock each seat before reading it. Locks are taken in sorted order, so no deadlock. */
final class PessimisticInventory extends BaseInventory {
private final Map<String, ReentrantLock> locks = new ConcurrentHashMap<>();
public String name() { return "pessimistic"; }
private List<ReentrantLock> lockAll(String showId, List<String> ids) {
List<String> sorted = new ArrayList<>(ids);
Collections.sort(sorted); // global order: seat id
List<ReentrantLock> taken = new ArrayList<>();
for (String id : sorted) {
ReentrantLock l = locks.computeIfAbsent(key(showId, id), k -> new ReentrantLock());
l.lock();
taken.add(l);
}
return taken;
}
private static void unlockAll(List<ReentrantLock> taken) {
for (int i = taken.size() - 1; i >= 0; i--) taken.get(i).unlock();
}
public boolean hold(String showId, List<String> ids, String holdId, Instant now, Instant exp) {
List<ReentrantLock> taken = lockAll(showId, ids);
try {
for (String id : ids) if (!state(showId, id).isFreeAt(now)) return false;
for (String id : ids) {
AtomicReference<SeatState> r = ref(showId, id);
r.set(new SeatState(SeatStatus.HELD, holdId, exp, r.get().version() + 1));
}
return true;
} finally { unlockAll(taken); }
}
public boolean confirm(String showId, List<String> ids, String holdId, String bookingId, Instant now) {
List<ReentrantLock> taken = lockAll(showId, ids);
try {
for (String id : ids) {
SeatState s = state(showId, id);
if (s.status() != SeatStatus.HELD || !holdId.equals(s.ownerId()) || !now.isBefore(s.holdExpiresAt())) return false;
}
for (String id : ids) {
AtomicReference<SeatState> r = ref(showId, id);
r.set(new SeatState(SeatStatus.BOOKED, bookingId, null, r.get().version() + 1));
}
return true;
} finally { unlockAll(taken); }
}
}
/** Optimistic: no locks. Read versions, then compare-and-set each seat; on any conflict undo and fail. */
final class OptimisticInventory extends BaseInventory {
final AtomicInteger conflicts = new AtomicInteger();
public String name() { return "optimistic"; }
private boolean swapAll(String showId, List<String> ids, Map<String, SeatState> expected,
java.util.function.Function<SeatState, SeatState> change) {
List<String> done = new ArrayList<>();
for (String id : ids) {
SeatState old = expected.get(id);
if (ref(showId, id).compareAndSet(old, change.apply(old))) { done.add(id); continue; }
conflicts.incrementAndGet(); // someone changed it since we read it
for (String d : done) { // compensate: put back what we changed
AtomicReference<SeatState> r = ref(showId, d);
SeatState mine = r.get();
r.compareAndSet(mine, new SeatState(expected.get(d).status(), expected.get(d).ownerId(),
expected.get(d).holdExpiresAt(), mine.version() + 1));
}
return false;
}
return true;
}
public boolean hold(String showId, List<String> ids, String holdId, Instant now, Instant exp) {
Map<String, SeatState> read = new HashMap<>();
for (String id : ids) {
SeatState s = state(showId, id);
if (!s.isFreeAt(now)) return false;
read.put(id, s);
}
return swapAll(showId, ids, read, old -> new SeatState(SeatStatus.HELD, holdId, exp, old.version() + 1));
}
public boolean confirm(String showId, List<String> ids, String holdId, String bookingId, Instant now) {
Map<String, SeatState> read = new HashMap<>();
for (String id : ids) {
SeatState s = state(showId, id);
if (s.status() != SeatStatus.HELD || !holdId.equals(s.ownerId()) || !now.isBefore(s.holdExpiresAt())) return false;
read.put(id, s);
}
return swapAll(showId, ids, read, old -> new SeatState(SeatStatus.BOOKED, bookingId, null, old.version() + 1));
}
}
// ---------- Payment and refunds ----------
interface PaymentGateway { boolean charge(String userId, long amount); void refund(String userId, long amount); }
final class FakeGateway implements PaymentGateway {
private final Set<String> declineUsers;
final List<String> ledger = new ArrayList<>();
FakeGateway(Set<String> declineUsers) { this.declineUsers = declineUsers; }
public boolean charge(String user, long amount) {
boolean ok = !declineUsers.contains(user);
ledger.add((ok ? "charged " : "declined ") + user + " Rs " + amount);
return ok;
}
public void refund(String user, long amount) { ledger.add("refunded " + user + " Rs " + amount); }
}
interface RefundPolicy { int refundPercent(Duration beforeShow); }
/** 100% if more than 24 h before the show, 50% if more than 4 h, otherwise nothing. */
final class TieredRefundPolicy implements RefundPolicy {
public int refundPercent(Duration before) {
if (before.compareTo(Duration.ofHours(24)) > 0) return 100;
if (before.compareTo(Duration.ofHours(4)) > 0) return 50;
return 0;
}
}
// ---------- Booking service ----------
enum BookingStatus { CONFIRMED, CANCELLED }
final class Booking {
final String id, userId; final Show show; final List<String> seats; final long amount;
BookingStatus status = BookingStatus.CONFIRMED;
Booking(String id, String userId, Show show, List<String> seats, long amount) {
this.id = id; this.userId = userId; this.show = show; this.seats = List.copyOf(seats); this.amount = amount;
}
public String toString() { return id + " " + userId + " " + show.movie().title() + " " + seats + " Rs " + amount + " " + status; }
}
record Hold(String id, String userId, Show show, List<String> seats, Instant expiresAt) {}
final class BookingService {
static final Duration HOLD_TIME = Duration.ofMinutes(10);
private final SeatInventory inventory;
private final PaymentGateway gateway;
private final RefundPolicy refunds;
private final Clock clock;
private final Map<String, Show> shows = new ConcurrentHashMap<>();
private final Map<String, Hold> holds = new ConcurrentHashMap<>();
private final Map<String, Booking> bookings = new ConcurrentHashMap<>();
private final AtomicLong seq = new AtomicLong();
BookingService(SeatInventory inventory, PaymentGateway gateway, RefundPolicy refunds, Clock clock) {
this.inventory = inventory; this.gateway = gateway; this.refunds = refunds; this.clock = clock;
}
void addShow(Show s) { shows.put(s.id(), s); ((BaseInventory) inventory).addShow(s); }
List<Show> showsFor(String city, String movieTitle) {
return shows.values().stream()
.filter(s -> s.theatre().city().name().equals(city) && s.movie().title().equals(movieTitle))
.sorted(Comparator.comparing(Show::start)).toList();
}
List<String> availableSeats(String showId) {
Instant now = clock.instant();
return shows.get(showId).screen().seats().stream().map(Seat::id)
.filter(id -> inventory.state(showId, id).isFreeAt(now)).toList();
}
Optional<Hold> hold(String userId, String showId, List<String> seatIds) {
Show show = shows.get(showId);
if (!clock.instant().isBefore(show.start())) throw new IllegalStateException("show already started");
if (seatIds.isEmpty() || seatIds.size() > 10) throw new IllegalArgumentException("1 to 10 seats");
String holdId = "H" + seq.incrementAndGet();
Instant now = clock.instant(), expires = now.plus(HOLD_TIME);
if (!inventory.hold(showId, seatIds, holdId, now, expires)) return Optional.empty();
Hold h = new Hold(holdId, userId, show, List.copyOf(seatIds), expires);
holds.put(holdId, h);
return Optional.of(h);
}
long price(Show show, List<String> seatIds) {
Map<String, Seat> byId = new HashMap<>();
for (Seat s : show.screen().seats()) byId.put(s.id(), s);
return seatIds.stream().mapToLong(id -> show.price().get(byId.get(id).category())).sum();
}
/** Pay, then confirm. If the hold expired while paying, refund in full. */
Booking book(String holdId) {
Hold h = holds.remove(holdId);
if (h == null) throw new IllegalStateException("unknown hold " + holdId);
if (!clock.instant().isBefore(h.expiresAt())) {
throw new IllegalStateException("hold " + holdId + " expired, choose seats again");
}
long amount = price(h.show(), h.seats());
if (!gateway.charge(h.userId(), amount)) {
inventory.release(h.show().id(), h.seats(), holdId);
throw new IllegalStateException("payment declined, seats released");
}
String bookingId = "BK" + seq.incrementAndGet();
if (!inventory.confirm(h.show().id(), h.seats(), holdId, bookingId, clock.instant())) {
gateway.refund(h.userId(), amount); // compensating action
throw new IllegalStateException("hold lost during payment, refunded");
}
Booking b = new Booking(bookingId, h.userId(), h.show(), h.seats(), amount);
bookings.put(bookingId, b);
return b;
}
long cancel(String bookingId, String userId) {
Booking b = bookings.get(bookingId);
if (b == null || !b.userId.equals(userId)) throw new NoSuchElementException("no such booking");
synchronized (b) {
if (b.status == BookingStatus.CANCELLED) throw new IllegalStateException("already cancelled");
int pct = refunds.refundPercent(Duration.between(clock.instant(), b.show.start()));
long refund = b.amount * pct / 100;
b.status = BookingStatus.CANCELLED;
inventory.release(b.show.id(), b.seats, bookingId);
if (refund > 0) gateway.refund(userId, refund);
return refund;
}
}
}
final class ManualClock extends Clock {
private volatile Instant now;
ManualClock(Instant start) { now = start; }
void advance(Duration d) { now = now.plus(d); }
public Instant instant() { return now; }
public ZoneId getZone() { return ZoneOffset.UTC; }
public Clock withZone(ZoneId z) { return this; }
}
public class MovieBookingDemo {
static Show sampleShow(String id, Instant start) {
List<Seat> seats = new ArrayList<>();
for (char row : new char[] { 'A', 'B' })
for (int n = 1; n <= 6; n++)
seats.add(new Seat("" + row + n, row == 'A' ? SeatCategory.PREMIUM : SeatCategory.REGULAR));
Screen screen = new Screen("S1", seats);
Theatre t = new Theatre("T1", "Lakeview Cinemas", new City("Bengaluru"), List.of(screen));
return new Show(id, new Movie("M1", "The Long Monsoon", 150), t, screen, start,
Map.of(SeatCategory.PREMIUM, 350L, SeatCategory.REGULAR, 200L));
}
/** 200 threads each try to hold two adjacent seats in row A, half of them listing seats in reverse. */
static void race(BaseInventory inv) throws Exception {
Show show = sampleShow("SHOW-RACE", Instant.parse("2026-10-12T13:00:00Z"));
inv.addShow(show);
Instant now = Instant.parse("2026-10-12T10:00:00Z"), exp = now.plusSeconds(600);
ExecutorService pool = Executors.newFixedThreadPool(16);
CountDownLatch go = new CountDownLatch(1);
List<Future<List<String>>> fs = new ArrayList<>();
for (int i = 0; i < 200; i++) {
final int n = i;
fs.add(pool.submit(() -> {
int first = n % 5 + 1; // pairs A1A2, A2A3 ... A5A6
List<String> want = new ArrayList<>(List.of("A" + first, "A" + (first + 1)));
if (n % 2 == 1) Collections.reverse(want);
go.await();
return inv.hold(show.id(), want, "U" + n, now, exp) ? want : null;
}));
}
go.countDown();
Map<String, Integer> heldBy = new TreeMap<>();
int winners = 0;
for (Future<List<String>> f : fs) {
List<String> got = f.get();
if (got == null) continue;
winners++;
for (String s : got) heldBy.merge(s, 1, Integer::sum);
}
pool.shutdown();
boolean doubleBooked = heldBy.values().stream().anyMatch(c -> c > 1);
// How many threads win (2 or 3) depends on timing; the invariants below must always hold.
System.out.printf("%-11s every winner got 2 distinct seats: %s, any seat held twice: %s%n",
inv.name(), heldBy.size() == 2 * winners, doubleBooked);
}
public static void main(String[] args) throws Exception {
System.out.println("== 1. The double-booking race (naive check-then-act) ==");
NaiveInventory naive = new NaiveInventory(new CyclicBarrier(2));
Show s0 = sampleShow("SHOW-0", Instant.parse("2026-10-12T13:00:00Z"));
naive.addShow(s0);
Instant t0 = Instant.parse("2026-10-12T10:00:00Z");
ExecutorService two = Executors.newFixedThreadPool(2);
Future<Boolean> a = two.submit(() -> naive.hold("SHOW-0", List.of("A5"), "alice", t0, t0.plusSeconds(600)));
Future<Boolean> b = two.submit(() -> naive.hold("SHOW-0", List.of("A5"), "bob", t0, t0.plusSeconds(600)));
System.out.println("alice got A5: " + a.get() + ", bob got A5: " + b.get()
+ " <- both were told yes; one hold silently overwrote the other");
two.shutdown();
System.out.println("== 2. Same race, 200 threads, overlapping pairs ==");
race(new PessimisticInventory());
race(new OptimisticInventory());
System.out.println("== 3. Search, hold, pay, book ==");
ManualClock clock = new ManualClock(Instant.parse("2026-10-11T08:00:00Z"));
FakeGateway gw = new FakeGateway(Set.of("carol"));
BookingService svc = new BookingService(new PessimisticInventory(), gw, new TieredRefundPolicy(), clock);
Show evening = sampleShow("SHOW-1", Instant.parse("2026-10-12T13:00:00Z")); // 18:30 IST
svc.addShow(evening);
System.out.println("shows: " + svc.showsFor("Bengaluru", "The Long Monsoon").stream().map(Show::id).toList());
Hold dev = svc.hold("dev", "SHOW-1", List.of("A3", "A4")).orElseThrow();
System.out.println("dev holds " + dev.seats() + " until " + dev.expiresAt());
System.out.println("meena tries A4,A5 -> " + svc.hold("meena", "SHOW-1", List.of("A4", "A5")).isPresent());
Booking devBooking = svc.book(dev.id());
System.out.println("booked: " + devBooking);
System.out.println("== 4. Payment declined releases the hold ==");
Hold carol = svc.hold("carol", "SHOW-1", List.of("B1")).orElseThrow();
try { svc.book(carol.id()); } catch (IllegalStateException e) { System.out.println("carol: " + e.getMessage()); }
System.out.println("B1 available again: " + svc.availableSeats("SHOW-1").contains("B1"));
System.out.println("== 5. Hold expiry ==");
Hold farah = svc.hold("farah", "SHOW-1", List.of("B5", "B6")).orElseThrow();
clock.advance(Duration.ofMinutes(11));
Hold gopal = svc.hold("gopal", "SHOW-1", List.of("B6")).orElseThrow();
System.out.println("after 11 min gopal holds " + gopal.seats());
try { svc.book(farah.id()); } catch (IllegalStateException e) { System.out.println("farah: " + e.getMessage()); }
svc.book(gopal.id());
System.out.println("free seats: " + svc.availableSeats("SHOW-1"));
System.out.println("== 6. Cancellation and refunds ==");
Hold h1 = svc.hold("hari", "SHOW-1", List.of("B2", "B3")).orElseThrow();
Booking hb = svc.book(h1.id());
System.out.println("time to show: " + Duration.between(clock.instant(), evening.start()).toHours() + " h");
System.out.println("hari cancels, refund Rs " + svc.cancel(hb.id, "hari"));
clock.advance(Duration.ofHours(20));
System.out.println("time to show: " + Duration.between(clock.instant(), evening.start()).toHours() + " h");
System.out.println("dev cancels, refund Rs " + svc.cancel(devBooking.id, "dev"));
try { svc.cancel(devBooking.id, "dev"); } catch (IllegalStateException e) { System.out.println("dev again: " + e.getMessage()); }
System.out.println("ledger: " + gw.ledger);
}
}
Real output
This is the actual output from Temurin JDK 21:
== 1. The double-booking race (naive check-then-act) ==
alice got A5: true, bob got A5: true <- both were told yes; one hold silently overwrote the other
== 2. Same race, 200 threads, overlapping pairs ==
pessimistic every winner got 2 distinct seats: true, any seat held twice: false
optimistic every winner got 2 distinct seats: true, any seat held twice: false
== 3. Search, hold, pay, book ==
shows: [SHOW-1]
dev holds [A3, A4] until 2026-10-11T08:10:00Z
meena tries A4,A5 -> false
booked: BK3 dev The Long Monsoon [A3, A4] Rs 700 CONFIRMED
== 4. Payment declined releases the hold ==
carol: payment declined, seats released
B1 available again: true
== 5. Hold expiry ==
after 11 min gopal holds [B6]
farah: hold H5 expired, choose seats again
free seats: [A1, A2, A5, A6, B1, B2, B3, B4, B5]
== 6. Cancellation and refunds ==
time to show: 28 h
hari cancels, refund Rs 400
time to show: 8 h
dev cancels, refund Rs 350
dev again: already cancelled
ledger: [charged dev Rs 700, declined carol Rs 200, charged gopal Rs 200, charged hari Rs 400, refunded hari Rs 400, refunded dev Rs 350]
Read it section by section:
- The race. Both Alice and Bob were told they hold A5. The naive check-then-act is broken, reproducibly.
- Two hundred threads. Each wants two adjacent seats in row A (A1-A2, A2-A3, and so on), with half listing them in reverse. With both inventories, every winner holds exactly two seats and no seat is held twice. How many requests win varies from run to run (two or three non-overlapping pairs fit in six seats), which is why the program checks invariants rather than printing a count. The pessimistic run also proves the sorted lock order avoids deadlock: it finishes.
- Normal flow. Dev holds A3 and A4 until 08:10, Meena's request for A4 and A5 fails as a whole (all or nothing: A5 is not left half-held), and Dev's booking costs 2 × 350 = Rs 700.
- Decline. Carol's card is declined and B1 is immediately available again.
- Expiry. Farah held B5 and B6 at 08:00. After 11 minutes Gopal can hold B6 because Farah's hold expired lazily, and Farah's late booking attempt is refused. B5 shows as free even though nobody updated it.
- Refunds. Rs 400 and Rs 350, as computed above. A second cancellation is rejected. The ledger shows every charge and refund, and the totals reconcile: charged 700 + 200 + 400 = 1,300, refunded 750, and only Gopal's booking remains paid (Rs 200 net) plus Dev's 50% retained (Rs 350): 1,300 − 750 = 550 = 200 + 350.
Concurrency considerations
- Per-seat state lives in a
ConcurrentHashMapofAtomicReferences, so lookups never need a global lock. - Hold and confirm are atomic per request: pessimistic by sorted per-seat locks, optimistic by CAS with compensation.
- Lock granularity is one seat in one show. Two users picking different seats never block each other, even in the same show.
- Payment happens outside every lock. Only the short state transitions are protected.
- Cancellation locks only the booking object, so different bookings cancel in parallel.
- Clock reads are
volatilein the test clock; in production useClock.systemUTC(). - Across servers, in-memory locks protect nothing. Use the database as the source of truth with
FOR UPDATEor versioned updates, or a unique constraint on(show_id, seat_id)in a bookings table, so that the database rejects the second insert no matter what the application believes. A cache such as Redis can hold short-lived holds withSET key value NX PX 600000(set only if absent, expire in 10 minutes), but the final booking should still be protected by the database.
Extensions interviewers ask, and how the design absorbs them
1. Virtual waiting room for big releases. Put a queue in front of the seat-selection page that admits N users per second. The inventory is unchanged; the booking service gains an admission check.
2. Dynamic pricing. Replace the fixed price map with a PricingStrategy that considers occupancy and time to show. Lock the price into the Hold so the user pays what they saw.
3. Offers and coupons. A chain of Discount objects applied to the price (chain of responsibility or decorator). Validate and reserve coupon usage with the same atomic patterns as seats.
4. Food and beverages. Add OrderLine items to the booking. Inventory for food is a counter, not a seat, so use an atomic decrement.
5. Seat-map rules: no single gaps. Some cinemas forbid leaving one empty seat between booked ones. Add a SeatSelectionRule validated during hold, before touching state.
6. Waitlist for sold-out shows. On cancellation, publish a "seats released" event; an Observer notifies waitlisted users in order.
7. Notifications. Booking confirmed, show reminders, cancellation receipts: observers on booking events, sending through a notification service (see Design a notification system).
8. Multiple payment providers. Strategy for gateway selection, plus idempotency keys so a retried payment is not charged twice.
9. Show cancelled by the theatre. Iterate bookings for the show, cancel each with a 100% policy, notify users. A different RefundPolicy implementation, nothing else.
Common mistakes
isBookedonSeat, mixing a physical seat with its state in one show.- Check-then-act without atomicity: the race shown above.
- Locking per show with
synchronizedon the service, serialising every booking across all cities. - Locking seats in request order, which deadlocks when two users pick overlapping seats in different orders.
- Holding locks or transactions during payment.
- Relying only on a background job to expire holds, so seats stay blocked when the job lags.
- Charging before holding, which forces refunds whenever a seat is lost.
- Partial holds: granting A4 when A5 failed, leaving the user with half a group.
- No refund path when confirm fails after payment.
- Using
doublefor money or computing percentages with floating point.
Interview questions
Q1. Why separate Seat from the seat's state in a show?
A physical seat belongs to a screen and lasts for years; it appears in every show on that screen. Availability belongs to the pair (show, seat). Putting the status on Seat would make the 6 pm booking block the 9 pm show.
Q2. Walk me through how double booking happens.
Two requests read the same seat as available before either writes. Both then write "held" and both return success. The bug is that the check and the write are separate steps; the fix is to make them one atomic step for any requests that share a seat.
Q3. What is the difference between optimistic and pessimistic locking?
Pessimistic locking acquires a lock before reading, so others wait; it suits high contention and short critical sections. Optimistic locking reads a version, then writes only if the version has not changed, failing fast otherwise; it suits low contention. In SQL these are SELECT ... FOR UPDATE versus UPDATE ... WHERE version = ?.
Q4. How do you avoid deadlock when a user selects several seats?
Acquire seat locks in a single global order, for example sorted by seat id. Then two overlapping requests always try the shared seat first in the same order, so a circular wait cannot form. Release in a finally block.
Q5. How do held seats become available again?
Each hold has an expiry time, and reads treat expired holds as free (lazy expiry). A periodic sweeper can tidy up, but correctness does not depend on it. Seats are also released immediately when payment is declined.
Q6. What if payment succeeds but the hold expired meanwhile?
Confirm atomically checks that the hold is still valid and owned by this user. If not, the system refunds the payment and asks the user to choose again. To make this rare, extend the hold when payment starts or keep it longer than the payment timeout.
Q7. Why not charge the user first and then pick seats?
Then a user can be charged for a seat that someone else took during payment, requiring a refund and a bad experience. Holding first guarantees the seats exist for the user before money moves.
Q8. How would this work across many application servers?
The database becomes the arbiter: per-seat rows updated with conditional updates or row locks, and a unique constraint on (show_id, seat_id) for confirmed bookings. A distributed cache can keep short holds with set-if-absent and a time to live, but the database constraint is the final guard.
Q9. How do you make holds all-or-nothing?
Pessimistic: lock all requested seats in order, verify all are free, then update all, then unlock. Optimistic: CAS each seat and undo successful ones if any fails, or in a database, run all conditional updates in one transaction and roll back if any updates zero rows.
Q10. What is the right lock granularity?
Per seat in a show gives the most parallelism and is what the code uses. Per show is simpler and often enough, because bookings for one show are a small fraction of total traffic. A global lock is never acceptable.
Q11. How do you handle a flood of users when bookings open for a blockbuster?
A virtual waiting room limits how many users select seats at once. Seat maps are served from a cache that may be a second stale, because the hold step is the real check. Optimistic updates fail fast for losers, so they can pick other seats immediately.
Q12. How do you prevent a double refund on cancellation?
Make cancellation a state transition from CONFIRMED to CANCELLED that can happen only once: lock the booking or use a conditional update on status, and refund only if the transition succeeded. Use an idempotency key with the payment provider as well.
Q13. Where do design patterns appear in this design?
Strategy for refund policy, pricing and the inventory implementation; a facade-like BookingService coordinating components; Observer for waitlists and notifications; and immutable value objects for seat state. The patterns serve the concurrency design rather than decorating it.
Q14. How would you test the concurrency?
Force interleavings with barriers or latches, as the naive demo does, then run many threads with overlapping requests and assert invariants: no seat held twice, each winner got all its seats, no deadlock within a timeout. Repeat the test many times, because races are probabilistic.
Key takeaways
- The physical seat and its availability in a show are different entities.
- Holds with an expiry let users pay without blocking seats forever; lazy expiry makes them correct without a background job.
- Double booking is a check-then-act race; reproduce it on demand with a barrier in tests.
- Pessimistic locking locks before reading; take multi-seat locks in sorted order to avoid deadlock.
- Optimistic locking uses versions and compare-and-set; multi-seat changes need compensation or a database transaction.
- Never hold a lock or transaction across payment; compensate with refunds when confirm fails after payment.
- Across servers, the database (row locks, versioned updates, unique constraints) is the final guard.
Next lesson
Continue with Design Splitwise.

