Movie ticket booking: two people, one seat
Part of the free Low-Level Design course on LogicWiz, in the chapter: Case studies, step by step. The class-diagram exercises run in the app.
Movie ticket booking: two people, one seat
"Two people paid for seat F8 and the app sold it twice. Show me the line where the second one should have been stopped."
Priya, reviewing Seat.book()
The prompt, the first questions and the first classes
The prompt: design the booking part of a movie ticketing app. Show the seat map for a show, let a user select seats, hold them while the user pays, and confirm the booking. No seat may ever be sold twice.
Arjun asks his questions and writes down the answers.
- Do we build search and listings? No. Focus on seats, holds and bookings for one show.
- How long is a hold? Ten minutes. After that the seats go back to the pool if payment has not finished.
- Can a user book many seats? Up to 10, and the hold is all or nothing: a user never ends up with half of a group booking.
- How many servers? One process with many threads, for the round. A database comes up in the last section.
- Do prices differ? Yes, by seat type, fixed per show.
- What if payment succeeds after the hold ran out? The booking fails and the money is refunded at once.
The nouns: cinema, screen, seat, movie, show, booking, hold, payment. Arjun draws Cinema, Screen, Seat, Movie, Show and Booking. A Seat is a physical chair: a row, a number and a type. Verbs that need an owner are "hold seats", "confirm" and "release". Those become SeatHoldService, and a BookingService that runs the whole flow and calls a PaymentGateway. He skips search, food orders and offers.
The first draft, and where it breaks
Arjun puts a status on the Seat and writes book(). The pause() stands for a database call or a slow network, which any real system has.
class Seat {
final String id;
String status = "AVAILABLE"; // one status for all shows
Seat(String id) { this.id = id; }
boolean book() {
if (status.equals("AVAILABLE")) {
pause(); // the real world: a database call, a slow network
status = "BOOKED";
return true;
}
return false;
}
}
"I check first, so it is safe," says Arjun. Priya asks him to start eight threads, all calling book() on seat F8 at the same moment. Two shows, 7 pm and 10 pm, share the same Seat object.
Users who got seat F8 for the 7 pm show: 8
The 10 pm show now says F8 is BOOKED
Eight users got one seat. This is a race condition: the result depends on which thread runs when. The bug has a name too, check-then-act: the check ("is it free?") and the act ("mark it booked") are two steps, and another thread can run between them.
| Step | Thread A | Thread B |
|---|---|---|
| 1 | checks: AVAILABLE | |
| 2 | checks: AVAILABLE | |
| 3 | sets BOOKED, returns true | |
| 4 | sets BOOKED, returns true |
Both threads passed the check before either one wrote. Adding the check was not a fix, because the check is stale by the time the act runs.
The second bug is quieter. The 10 pm show says F8 is booked, but nobody has booked it for 10 pm. A physical seat exists in every show on its screen, so a status on Seat blocks it for all of them.
The decision: status per show, and a hold that expires
The first decision is where the status lives. It belongs to one seat in one show. Arjun adds a class for that: ShowSeat. The Show owns its ShowSeat objects, and each one points to its physical Seat. Seat keeps only what never changes: row, number and type.
The second decision is the hold. A user needs time to pay, so a seat has three statuses: AVAILABLE, HELD and BOOKED. A hold carries who holds it and when it ends.
stateDiagram-v2
[*] --> AVAILABLE
AVAILABLE --> HELD : hold by a user
HELD --> BOOKED : confirm after payment
HELD --> AVAILABLE : hold expires or payment fails
BOOKED --> [*]
Who removes a hold when time runs out? A cleanup job could, but a job can run late, and a late job means a free seat looks taken. So Arjun makes ShowSeat answer the question itself, using the current time:
final class ShowSeat {
private SeatStatus status = SeatStatus.AVAILABLE;
private String heldBy;
private Instant holdExpiresAt;
/** An expired hold counts as free, so we do not depend on a cleanup job running on time. */
boolean isFree(Instant now) {
return status == SeatStatus.AVAILABLE
|| (status == SeatStatus.HELD && !now.isBefore(holdExpiresAt));
}
boolean isHeldBy(String userId, Instant now) {
return status == SeatStatus.HELD && userId.equals(heldBy) && now.isBefore(holdExpiresAt);
}
}
The methods hold, book and release are small and sit in the same class. They set or clear these three fields, and book refuses to run unless the seat is HELD. Correctness no longer depends on any job. The current time comes from an injected Clock, so a test can move time forward 11 minutes without waiting. The seat map must use the same isFree rule. If the screen drew every HELD seat as taken, users would see grey seats that are really free.
Priya adds one more point. Booking stays a separate class from ShowSeat. A booking is the user's record, with an amount and a payment. A seat status is stock. Cancelling a booking later releases seats through the same service, so the same lock rules apply.
The key method: hold, pay, confirm, release
Now the check and the act must become one step. A lock is an object that only one thread can hold at a time. Other threads that want it wait. Code that runs between taking the lock and releasing it is a critical section: while one thread is inside, no other thread enters. Arjun asks how many locks to use.
- One lock for the whole city: correct, but every show waits for every other show.
- One lock per seat: a user who holds F7 and F8 needs two locks. Another user who asks for F8 then F7 can end up waiting for the first user forever. That is a deadlock.
- One lock per show: shows stay independent, and a group of seats is one critical section. This is the one Priya wants.
ConcurrentHashMap.computeIfAbsent creates a show's lock the first time anyone asks for it. Two threads asking at once still get the same lock. A ReentrantLock is an ordinary lock object, and a map of them lets us keep one per show.
final class SeatHoldService {
static final Duration HOLD_TIME = Duration.ofMinutes(10);
private final ConcurrentMap<String, ReentrantLock> showLocks = new ConcurrentHashMap<>();
private final Clock clock; // injected, so tests can move time forward
SeatHoldService(Clock clock) { this.clock = clock; }
private ReentrantLock lockFor(Show show) {
return showLocks.computeIfAbsent(show.id(), id -> new ReentrantLock());
}
/** All or nothing: every seat is held for this user, or none is. */
boolean hold(Show show, List<String> seatIds, String userId) {
ReentrantLock lock = lockFor(show);
lock.lock();
try {
Instant now = clock.instant();
List<ShowSeat> seats = seatIds.stream().map(show::seat).toList();
if (!seats.stream().allMatch(s -> s.isFree(now))) return false;
Instant until = now.plus(HOLD_TIME);
seats.forEach(s -> s.hold(userId, until));
return true;
} finally {
lock.unlock();
}
}
}
The try and finally guarantee the lock is released even if the code inside throws. confirm and release use the same lock in the same way: confirm checks that this user still holds every seat and marks them BOOKED, and release frees only the seats this user still holds.
Now the flow around it. startBooking calls hold and, when it works, builds a Hold: a small record with the show, the seats, the user and the amount. Booking is another record that carries the hold and a BookingStatus: CONFIRMED, PAYMENT_FAILED or HOLD_EXPIRED. Payment is slow, so it must happen outside the lock. If a card payment sat inside the critical section, one slow bank would freeze the whole show.
Booking completeBooking(String holdId) {
Hold hold;
synchronized (open) { hold = open.remove(holdId); }
Show show = shows.get(hold.showId());
PaymentResult paid = gateway.charge(hold.userId(), hold.amountPaise()); // slow: outside every lock
if (!paid.ok()) {
holds.release(show, hold.seatIds(), hold.userId()); // free the seats now
return new Booking(UUID.randomUUID().toString(), hold, BookingStatus.PAYMENT_FAILED);
}
if (!holds.confirm(show, hold.seatIds(), hold.userId())) {
gateway.refund(paid.paymentId()); // the hold had expired
return new Booking(UUID.randomUUID().toString(), hold, BookingStatus.HOLD_EXPIRED);
}
return new Booking(UUID.randomUUID().toString(), hold, BookingStatus.CONFIRMED);
}
Two failure paths matter. If the card is declined, the hold is released at once, so the seats are not locked away for 10 minutes. If payment works but the hold expired, confirm fails and the money goes back. Arjun runs a script with a fake gateway and a test clock.
Arjun holds F7 and F8: true (amount 70000 paise)
Meena asks for F8: false
charge arjun 70000 paise: declined
Booking status: PAYMENT_FAILED
F8 is AVAILABLE again, Meena asks again: true
11 minutes later, Meena asks for F1: true
charge arjun 35000 paise: ok
refund pay-arjun
Arjun pays at last: HOLD_EXPIRED
Then the threaded proof. A CountDownLatch is a gate: threads wait at it until the main thread opens it, so all eight start together. Each one tries to hold F5.
CountDownLatch go = new CountDownLatch(1);
AtomicInteger winners = new AtomicInteger();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 8; i++) {
String user = "user" + i;
Thread t = new Thread(() -> {
try { go.await(); } catch (InterruptedException e) { return; }
if (holds.hold(show, List.of("F5"), user)) winners.incrementAndGet();
});
threads.add(t);
t.start();
}
Users who got F5: 1
Eight threads, one winner. The earlier draft gave all eight a seat. The difference is the lock, and nothing else.
Extend it, then try to break it
Extension: prices that rise as the show fills up. Fixed prices live in ShowSeat today. A price rule that changes is a strategy: a PricingStrategy interface with a fixed rule and a demand rule. BookingService asks it when the hold is created and stores the amount on the Hold. The user then pays what the screen showed, even if the price moves a minute later.
Break it: three servers. Your lock lives inside one process. If the app runs on three servers, each has its own ReentrantLock, and two users on different servers can both pass. The database must be the judge. Use one conditional update: set the status to HELD where the show and seat match and the status is AVAILABLE. Then count the rows changed. If the count is lower than the number of seats asked for, roll back. This is optimistic locking: try the change and check it worked, instead of waiting for a lock first. A version number on each row works the same way: the update succeeds only if the version is still the one you read. The other choice is a pessimistic row lock, where the second user waits for the first to finish. It is simpler to reason about, but busy shows queue up.
💡 Tip: Say the trade-off aloud. In-memory locks are right for a 90-minute round. A database conditional update is right once there is more than one process.
Your turn
Draw the core in the class designer. The starter is the draft: a Seat with a status, and a Show that points straight to seats. Move the status to a per-show class, give it an enum, and add the class that holds seats.
Then open the Movie ticket booking problem in the problem library. Design it again without the model answer, compare, and follow its "Build it on your machine" kit to write the working version, with the threaded test, in your IDE.
📌 Summary: Status belongs to a seat in one show, so use a ShowSeat. Check and claim in one step under a lock per show. Pay outside the lock, and release or refund when payment fails.
What Arjun learns next
Booking has a lot of state. Next, Priya hands him a game: sixty-four squares, six kinds of piece, and a rule that no move may leave your own king under attack.
Check your understanding
- Seat F8 exists in both the 7 pm and the 10 pm show. Where should its status (available, held, booked) live?
- Two threads both see F8 as AVAILABLE and both mark it HELD. What is the cause, and what is the fix?
- The card payment takes 8 seconds and then fails. Where does the charge run, and what happens to the held seats?
- The app now runs on three servers behind a load balancer. Why does one ReentrantLock per show stop being enough?