Guide · System design
Movie Ticket Booking LLD: Design the Classes and Seat Locks
28 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- A movie ticket booking LLD (low-level design) is the class design inside one booking program. It names Movie, Theatre, Screen, Seat, Show, Booking and Payment, plus code that never sells a seat twice.
- Seat status belongs to the Show (one screening), not to the Seat (one chair). Seat F7 is one chair, but it can be sold at 6 pm and free at 9 pm.
- Double booking comes from check-then-save code: two threads (lines of work running at once) both see the seat free before either saves. A lock, a guard that lets one thread in at a time, fixes it.
- Use one short locked method per Show that checks every seat and then holds them all, or none. A hold stores its expiry time and counts as free once that time passes. Take payment outside the lock.
- Beginners can stop after the design patterns section. Engineers then get tests of lock scope, meaning how much one lock covers, including a per-seat deadlock: two threads each waiting forever for the other. They also see a claim-one-seat-then-undo method refuse both families. Then come a repeated payment message that refunds a confirmed ticket, and a seat hold inside PostgreSQL, a popular database.
What goes wrong when two people book the same movie seat?
Two families get a ticket for one chair, and one of them is turned away at the door. This is the core bug of any movie ticket booking LLD: the code checks the seat and saves the sale as two separate steps.
Picture a common setup. A small cinema chain runs four multiplexes with three screens each, and every screen has 120 seats. One Java app on one server sells all the tickets. The setup is made up, but the bug is real.
On the Friday a big film opens, the 9 pm show sells out in eleven minutes. By Sunday the team has 14 complaints. In each one, two families paid Rs 220 (220 rupees) a ticket for the same seat.
The first code looked fine. It read the seat, checked that it was free, and saved the booking. With one tester it never failed. On opening night, hundreds of requests ran at the same time.
The team wants two things. No seat is ever sold twice. A seat someone abandons on the payment page goes back on sale within minutes. This guide fixes both inside the Java classes, then tests the fix.
Spreading the same idea across many servers is a separate topic, ticket booking system design. One engineer section here shows its first step: moving the seat hold into a shared database.
What is a movie ticket booking LLD?
A movie ticket booking LLD is the low-level design of a booking app: its classes, their fields, how they connect, and the methods that book seats safely. Interviewers use it to test object modelling and concurrency, meaning many requests running at once.
Low-level design, or LLD, zooms into one program. High-level design draws servers, databases and queues as boxes. Low-level design asks what goes inside one box: which classes exist, what each one owns, and which method changes what.
In a movie booking LLD round, you usually cover five parts:
- Requirements: browse movies by city, pick a show, see the seat map, hold seats, pay, and get a ticket.
- Classes and relationships: what each class holds, and which classes point to which.
- The booking method: the exact steps that turn a free seat into a sold one.
- Concurrency: what happens when two customers tap the same seat at once.
- Extensions: different prices, cancellations, and what changes with many servers.
Leave real time for concurrency, even if the first two parts feel more familiar. That is where the opening-night bug lives, so this guide spends most of its time there.
Which words do you need before designing seat locking?
You need six words: thread, race condition, lock, critical section, atomic and seat hold. Each gets a plain meaning and an example from the cinema app.
Thread. A thread is one line of work running inside your program. In the common thread-per-request setup, each web request runs on its own thread. So two customers tapping F7 means two threads running the same code at once.
Race condition. A race condition is a bug where the result depends on which thread happens to run first. The opening-night double booking is a race condition. It only shows up when the timing is unlucky.
Lock. A lock is a guard that lets only one thread at a time run a chosen piece of code. Other threads that want the same lock wait until it is free.
Critical section. The critical section is the code a lock protects. Keep it short, because every other thread waits while one thread is inside it.
Atomic. A step is atomic when no other thread can see it half done. Either all of it has happened, or none of it has.
Seat hold. A seat hold is a temporary claim on a seat while the customer pays. It records who holds the seat and when the hold ends. Example: F7 is held for customer 5521 until 9:07 pm.
Which classes does a movie ticket booking system need?
You need classes for the things that exist (Movie, Theatre, Screen, Seat), one class for each screening (Show), and classes for the act of booking (Booking, Payment and BookingService). Each class owns one clear job.
- Movie: title, language, length and certificate. It knows nothing about seats.
- Theatre: one cinema building, with its city and its screens.
- Screen: one hall inside a theatre, with its fixed layout of seats.
- Seat: one physical chair, such as F7, with a type like Recliner or Classic.
- Show: one movie on one screen at one start time. It keeps the state of every seat for that screening.
- Booking: one customer's order, with the seat ids and a status such as PENDING or CONFIRMED.
- Payment: the result of one payment attempt, kept apart so a booking can have a failed try and then a good one.
- BookingService: the class the app calls. It runs the steps in order: hold, pay, confirm.
The most important choice is small. Seat status lives on Show, not on Seat. Seat F7 in Screen 2 is one chair, but it is sold for the 6 pm show and empty for the 9 pm show. If Seat had an isBooked field, the two shows would fight over it.
In the diagram below, start at BookingService and follow the arrows. Notice that only Show and Booking change during a sale. Movie, Theatre, Screen and Seat stay fixed.
Movie booking classes
Drag to exploreFixed things on one side, changing state on Show and Booking. Notice that seat status lives on Show, so the 6 pm and 9 pm shows never share it.
Diagram connections:
- BookingService to Show (Holds and confirms seats)
- BookingService to Booking (Creates)
- Show to Movie (Plays)
- Show to Screen (Runs on)
- Theatre to Screen (Has 3)
- Screen to Seat (Has 120)
- Booking to Payment (Paid by)
- BookingService: Runs hold, pay and confirm in order
- Show: One screening; owns seat holds and sold seats
- Movie: Title, language, length
- Screen: One hall with a fixed seat layout
- Theatre: One cinema building with 3 screens
- Seat: One physical chair, such as F7
- Booking: Customer, seat ids, status
- Payment: One payment attempt and its result
Why does check-then-save code sell the same seat twice?
Check-then-save code sells a seat twice because two threads can both check the seat before either one saves. Each sees F7 free, so each saves a booking for it.
This pattern is usually called check-then-act. Here is the naive first try, labelled so that nobody copies it. It lives inside a class with a map called sold, from seat id to customer.
// Naive first try: check, then save, with no lock. Do not ship this.
boolean book(String seatId, String customerId) {
if (sold.containsKey(seatId)) return false; // check: is F7 free?
sold.put(seatId, customerId); // save: F7 is mine
return true;
}Here is what happens on opening night, slowed down. The times are made up, in milliseconds after both customers tap F7.
0 ms: both requests arrive
Customer A and customer B tap F7 for the 9 pm show. The server runs each request on its own thread.
2 ms: thread A checks
Thread A looks in sold. F7 is not there, so it is free.
3 ms: thread B checks
Thread B looks in sold too. Thread A has not saved anything yet, so F7 still looks free.
5 ms: thread A saves
Thread A puts F7 in sold for customer A and moves on to payment.
6 ms: thread B saves
Thread B puts F7 in sold for customer B. Nothing told it that the seat changed after its check.
Pause and predict. If two threads race for F7 2,000 times, how many rounds sell it twice? The gap between check and save is one line of code, so zero sounds likely.
We ran exactly this method on Java 21, on a 2-core machine, releasing both threads at the same instant. Across six runs of 2,000 rounds, 16 to 32 rounds sold F7 twice. The exact count changes from run to run.
Then we added a 1 millisecond pause between check and save, standing in for a database call. Now 1,962 to 1,986 rounds out of 2,000 sold the seat twice. The slower the save, the wider the gap.
What just happened? Both threads read sold before either wrote to it. Nothing in the code tells the second thread that the answer it read is already out of date.
Think of a beauty parlour in wedding season, with one appointment book on the counter. Two receptionists take calls at once. Both see Sunday 5 am empty, and both promise it to a bride.
- The appointment book is the Show's seat data.
- Each receptionist is a thread.
- Reading the empty slot is the check, and writing the bride's name is the save.
- The fix, only whoever holds the book may look or write, is the lock.
Where the picture breaks: a receptionist would notice a name written a second ago. A thread acts on the value it already read, and without a lock it may not even see the other thread's write yet.
How do you lock seats for one show in Java?
Make checking and holding seats one atomic step, using a lock owned by the Show object. In Java, the shortest correct way is a synchronized method on Show.
The official Java tutorial gives synchronized methods two promises. Two calls to synchronized methods on the same object cannot interleave, meaning their steps never mix: one waits for the other. And when one call ends, its changes are visible to the next synchronized call on that object.
Why put the lock on Show? Each Java object has its own built-in lock, and a synchronized method uses its object's lock. So a customer booking the 9 pm show never waits for someone booking the 6 pm show in another hall. This is an object lock. A static synchronized method would instead take the lock of the Show class itself, one lock shared by every show.
A family of four wants F5 to F8 together, and three seats are no use to them. So hold checks every seat first and writes only if all of them are free.
SeatHold is a Java record, a short class that only holds data: the customer id and the expiry time. The Clock is passed in when the Show is created. Show also knows its own seat ids, so a typo like Z99 throws an error instead of being held.
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
record SeatHold(String customerId, Instant expiresAt) {}
final class Show {
static final Duration HOLD_TIME = Duration.ofMinutes(7);
private final Set<String> seats; // every seat on this screen
private final Map<String, SeatHold> holds = new HashMap<>();
private final Map<String, String> sold = new HashMap<>(); // seat id -> booking id
private final Clock clock;
Show(Set<String> seats, Clock clock) {
this.seats = Set.copyOf(seats);
this.clock = clock;
}
synchronized boolean hold(List<String> seatIds, String customerId) {
Instant now = clock.instant();
for (String id : seatIds) {
if (!isFree(id, now)) return false; // one seat taken: change nothing
}
Instant until = now.plus(HOLD_TIME);
for (String id : seatIds) holds.put(id, new SeatHold(customerId, until));
return true;
}
synchronized boolean isFree(String seatId) { // the seat map calls this
return isFree(seatId, clock.instant());
}
private boolean isFree(String id, Instant now) {
if (!seats.contains(id)) throw new IllegalArgumentException("no seat " + id);
SeatHold h = holds.get(id);
boolean held = h != null && h.expiresAt().isAfter(now);
return !sold.containsKey(id) && !held;
}
}Three parts matter. The word synchronized makes the whole method one turn with the lock. The first loop only reads and returns before writing anything, which gives you all or nothing. A hold whose expiry time has passed counts as free.
Notice that holds and sold are plain HashMaps. They are safe only because every method that touches them, including isFree for the seat map, is synchronized on the same Show. A method without that word could read the maps in the middle of a hold.
We ran the same race against Show.hold. Across 12,000 rounds, F7 was never held by both customers.
How long should a seat hold last, and how does it expire?
A hold should last long enough to pay and no longer. There is no standard number; this guide uses 7 minutes. The hold stores its expiry time, and any check at or after that time treats the seat as free.
Nothing runs at 9:07 pm to free F7. The next call to hold or isFree compares the expiry time with the clock and sees the seat as free. This is called lazy expiry: the cleanup happens when someone looks.
Lazy expiry has a big advantage. If a cleanup job is slow or crashes, seats still come back on time, because correctness never depends on the job. A small job can still delete old holds to save memory, but it must be synchronized on the Show too.
The seat map must use the same rule, which is why it calls isFree instead of reading holds directly. Otherwise the map would show F7 as taken while hold would happily give it away.
Why does Show ask a Clock for the time, instead of calling Instant.now()? The Clock docs call passing a Clock into code that needs the current time best practice. Tests can then use Clock.fixed, which always returns the same instant, or a clock they move forward.
Here are the states one seat moves through in one show. Follow the arrow out of Held. Notice that a lapsed hold goes back to Available without anyone touching it.
States of seat F7 in the 9 pm show
Drag to exploreTime alone moves a seat from Held to Lapsed, and a lapsed hold is treated as free. Only a confirmed payment reaches Sold.
Diagram connections:
- Available to Held (hold() succeeds)
- Held to Sold (confirm() before expiry)
- Held to Lapsed (7 minutes pass)
- Lapsed to Available (Treated as free)
- Sold to Cancelled (cancel())
- Cancelled to Available (Back on sale)
- Available: No live hold, not sold
- Held: Customer id and expiry time stored
- Sold: Confirmed after payment, with booking id
- Lapsed: Expiry time passed; nobody paid
- Cancelled: Customer cancels before the show
Seat state is not stored as a field. It is worked out from holds and sold each time, so it can never disagree with them.
How do you confirm a booking after payment without holding the lock?
Hold the seats, leave the lock, and take payment outside any lock. Then call a second short locked method that turns the holds into sales, but only if they still belong to this customer and have not expired.
Never keep the Show locked while the customer pays. Paying can take a few minutes on a phone. If the lock stayed taken that whole time, every other customer of the 9 pm show would freeze on the seat map.
Add these two methods inside Show, before its last closing brace. confirm runs after the payment gateway, the outside service that moves the money, reports success. cancel puts a sold seat back on sale. To keep the code short, the refund and a CANCELLED booking status are left out.
synchronized boolean confirm(List<String> seatIds, String customerId, String bookingId) {
Instant now = clock.instant();
for (String id : seatIds) {
SeatHold h = holds.get(id);
boolean mine = h != null && h.customerId().equals(customerId);
if (!mine || !h.expiresAt().isAfter(now)) {
return false; // hold missing, someone else's, or expired
}
}
for (String id : seatIds) {
holds.remove(id);
sold.put(id, bookingId);
}
return true;
}
synchronized boolean cancel(List<String> seatIds, String bookingId) {
for (String id : seatIds) {
if (!bookingId.equals(sold.get(id))) return false; // not this booking's seat
}
for (String id : seatIds) sold.remove(id); // back on sale
return true;
}The key line is the check on mine. It catches the case where this customer's hold lapsed and another customer took the seat. It also refuses a lapsed hold that nobody took, which keeps one simple rule: a sale needs a live hold.
BookingService runs the two steps. start holds the seats before the customer goes to pay. onPaymentSuccess confirms them, or marks the booking FAILED and asks the gateway for a refund.
import java.util.List;
enum BookingStatus { PENDING, CONFIRMED, FAILED }
interface PaymentGateway {
void refund(String bookingId);
}
final class Booking {
final String id;
final String customerId;
final List<String> seatIds;
volatile BookingStatus status = BookingStatus.PENDING;
Booking(String id, String customerId, List<String> seatIds) {
this.id = id;
this.customerId = customerId;
this.seatIds = List.copyOf(seatIds);
}
}
final class BookingService {
private final PaymentGateway gateway;
BookingService(PaymentGateway gateway) { this.gateway = gateway; }
boolean start(Show show, Booking b) { // step 1: hold, then send to payment
if (show.hold(b.seatIds, b.customerId)) return true;
b.status = BookingStatus.FAILED;
return false;
}
void onPaymentSuccess(Show show, Booking b) { // step 2: the gateway says paid
boolean refund;
synchronized (b) { // one message per booking at a time
if (b.status != BookingStatus.PENDING) return; // a repeat message: already handled
boolean ok = show.confirm(b.seatIds, b.customerId, b.id);
b.status = ok ? BookingStatus.CONFIRMED : BookingStatus.FAILED;
refund = !ok;
}
if (refund) gateway.refund(b.id); // slow network call, outside every lock
}
}Three details matter here. synchronized (b) locks on that one Booking object, not on a whole method. The word volatile makes a status written by one thread visible to the next thread that reads it. The refund, a slow network call, happens after the lock is released.
The status check and the lock on the booking handle a repeated success message; the engineer section shows why that matters. The diagram shows the whole path, and each question box is a call to a synchronized method on Show.
Booking flow in BookingService
Drag to exploreTwo short locked steps, with the slow payment in between and outside any lock. Notice that a lapsed hold ends in a refund, not a sale.
Diagram connections:
- Customer picks F5 to F8 to All seats held? (Start)
- All seats held? to Show seat taken message (No)
- All seats held? to Take payment (Yes)
- Take payment to Still held by you? (Payment succeeded)
- Still held by you? to Booking confirmed (Yes)
- Still held by you? to Refund the payment (No, hold lapsed)
- Customer picks F5 to F8: Seat map for the 9 pm show
- All seats held?: show.hold() under the Show lock
- Show seat taken message: Nothing was held, so nothing to undo
- Take payment: Outside any lock; can take minutes
- Still held by you?: show.confirm() under the Show lock
- Booking confirmed: Ticket sent to the customer
- Refund the payment: Booking marked FAILED
If the payment fails or the customer closes the app, nothing needs to run at all. The hold lapses after 7 minutes and the seats go back on sale by themselves.
How can you run the whole booking flow yourself?
Save Show.java with confirm and cancel added inside it, then Booking.java, then the demo below as MovieBookingDemo.java. SeatHold is a record, and records need Java 16 or later. We ran it on OpenJDK 17.0.20 and 21.0.12.
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
public class MovieBookingDemo {
static final class TestClock extends Clock { // a clock the test can move forward
private Instant now = Instant.parse("2026-10-16T15:30:00Z");
void advance(Duration d) { now = now.plus(d); }
@Override public Instant instant() { return now; }
@Override public ZoneId getZone() { return ZoneId.of("UTC"); }
@Override public Clock withZone(ZoneId zone) { return this; }
}
public static void main(String[] args) {
TestClock clock = new TestClock();
Show show = new Show(Set.of("F5", "F6", "F7", "F8", "F9"), clock);
List<String> refunds = new ArrayList<>();
BookingService service = new BookingService(refunds::add);
Booking a = new Booking("B1", "family-A", List.of("F5", "F6", "F7", "F8"));
Booking b = new Booking("B2", "family-B", List.of("F7", "F8"));
System.out.println("A holds F5-F8: " + service.start(show, a));
System.out.println("B wants F7-F8: " + show.hold(b.seatIds, b.customerId));
clock.advance(Duration.ofMinutes(7)); // A is still on the payment page
System.out.println("7 min later, F7 free: " + show.isFree("F7"));
System.out.println("B holds F7-F8: " + service.start(show, b));
service.onPaymentSuccess(show, a); // A's money arrives too late
System.out.println("A paid late: " + a.status + ", refunds " + refunds);
service.onPaymentSuccess(show, b);
System.out.println("B paid: " + b.status + ", refunds " + refunds);
service.onPaymentSuccess(show, a); // gateway repeats both messages
service.onPaymentSuccess(show, b);
System.out.println("Repeats: A " + a.status + ", B " + b.status + ", refunds " + refunds);
System.out.println("B cancels: " + show.cancel(b.seatIds, b.id) + ", F7 free: " + show.isFree("F7"));
}
}Compile with javac *.java, then run java MovieBookingDemo. You should see exactly these eight lines:
- A holds F5-F8: true
- B wants F7-F8: false
- 7 min later, F7 free: true
- B holds F7-F8: true
- A paid late: FAILED, refunds [B1]
- B paid: CONFIRMED, refunds [B1]
- Repeats: A FAILED, B CONFIRMED, refunds [B1]
- B cancels: true, F7 free: true
What just happened? Family A's hold lapsed at exactly 7 minutes, so family B got F7 and F8. A's late money was refunded once, B's booking was confirmed, and the repeated messages changed nothing.
Which design patterns fit a movie ticket booking LLD?
A few patterns fit, used lightly: Strategy for pricing, an interface in front of the seat store, and an enum for booking status. An interface lists methods a class promises to provide, and an enum is a type with a fixed set of named values. Add a pattern only when it solves a problem you can name.
- Strategy for pricing. Strategy means one rule sits behind an interface, so you can swap it. A PricingStrategy interface lets the chain charge Rs 220 on weekends and Rs 160 on Tuesdays without touching Booking.
- An interface for the seat store. A SeatStore interface with hold, confirm and cancel lets a database version replace the in-memory Show later. The runnable code skips it to stay short.
- An enum for status. BookingStatus has PENDING, CONFIRMED and FAILED, so no code passes loose strings around.
- Plain constructors over a Singleton, a class with one global instance. BookingService gets its gateway passed in, which is how the demo swaps in a fake that records refunds. A global Singleton with hidden state is harder to test.
The seat-store interface matters most. It is the seam where a one-server design grows into a many-server one, and BookingService does not change when the store moves.
Beginners can stop here. You have the classes, the race, the lock, the hold and the payment flow, which is a complete interview answer. The rest is for engineers: lock scope, repeated payment messages, and the hold inside PostgreSQL.
Should you lock the whole app, one show or one seat?
Lock one show. One lock for the whole app is correct, but every show waits in one line. A lock per seat lets more work run at once, but group bookings can then freeze.
The Java tutorial defines deadlock as two or more threads blocked forever, each waiting for the other. Picture two families who both want F7 and F8. Thread A locks F7 and waits for F8, while thread B locked F8 first and waits for F7.
We forced exactly that order on Java 21 with one lock object per seat. The JVM (Java Virtual Machine, the program that runs Java code) has a deadlock check, ThreadMXBean.findDeadlockedThreads, and it reported both threads stuck. With the seat ids sorted before locking, the same test finished.
ConcurrentHashMap is a map that many threads can use at once. Its putIfAbsent is atomic, as the ConcurrentMap docs say, so it can claim one seat safely. For a group, you claim seats one by one and undo the ones you got if a later one is taken.
That undo hides a quiet failure. Family A claimed F7 then F8, while family B claimed F8 then F7. In 14 of 30,000 free-running rounds (13, 1 and 0 in three runs of 10,000), both families were refused, though either could have had both seats.
Sorting the seat ids first gave 0 such rounds, because both families then fight over F7 first. Even sorted, other customers can see a half-claimed group on the seat map for a moment.
| Option | How it works | Group of seats, all or nothing | Main risk |
|---|---|---|---|
| One global lock | Every booking in every show waits for one lock | Easy | All shows queue behind one busy show |
| synchronized method on Show | Each Show object's built-in lock | Easy: check all, then write all | Forgetting the word on one method; F7 never held twice in 12,000 rounds |
| ReentrantLock per Show | An explicit lock field, with tryLock and a time limit | Easy, same as above | Forgetting to release it, so release it in finally |
| One lock per seat | Lock each chosen seat, then write | Possible if seats are locked in sorted order | Deadlock with opposite orders (seen in our test) |
| putIfAbsent per seat | Claim seats one by one, undo on failure | Hard: undo what you claimed | Both families refused in 14 of 30,000 rounds |
ReentrantLock is worth knowing for its tryLock method, which can wait for a set time and then give up. In our test, tryLock with 200 milliseconds returned false after about 200 milliseconds, so the app can say try again instead of hanging.
The ReentrantLock docs ask you to follow every lock call with a try block and release the lock in finally. A fair lock, set in the constructor, favours the longest-waiting thread, but the untimed tryLock ignores that setting.
There is one more reason teams pick ReentrantLock. A virtual thread is a cheap thread the JVM schedules itself. On Java 21 to 23, it pins the real thread under it while it runs inside or waits to enter synchronized code. That real thread cannot run other virtual threads meanwhile.
Our pin trace on Java 21.0.12 reported reason MONITOR for synchronized and nothing for ReentrantLock. Monitor is the Java API's name for an object's built-in lock. JEP 491, delivered in JDK 24 on 18 March 2025, lets a blocked virtual thread release that real thread in synchronized code. Our hold takes about a microsecond, so pinning costs little here.
What happens when the payment success message arrives twice?
Without a guard, the second message can refund a ticket that was already confirmed. Gateways retry a message when your server does not confirm it in time, so the same message can arrive twice.
A webhook is an HTTP call the gateway makes to your server to report an event, such as a successful payment. Your onPaymentSuccess method is what that call ends up running. One large gateway's webhook docs say endpoints, the URLs that receive these calls, "might occasionally receive the same event more than once".
We removed the status guard and sent one success message twice. The first call confirmed the booking. The second found no live hold, so it marked the booking FAILED and refunded it, while F7 and F8 stayed sold to it.
For a booking that had already failed, the same repeat sent two refunds. Both bugs come from treating every confirm that returns false as a late payment.
The guard in onPaymentSuccess fixes both. It acts only while the booking is PENDING, under a lock on that one booking. With the same message sent on two threads at once, 5,000 rounds each, we saw 0 wrong results.
onPaymentSuccess takes the booking lock, then the Show lock inside confirm. No code takes them the other way round, so these two locks cannot deadlock.
The in-memory guard is lost on restart. Real systems also save the gateway's event id and skip ids they have already handled, which is what the same webhook docs advise.
What changes when the seat hold moves into a database?
Each server's Show has its own lock, so the lock must move into the database that every server shares. Store one row per seat per show, keep the hold in columns, and apply the same check-all-then-write-all rule inside a transaction, a group of statements that succeed or fail together.
We tested this on PostgreSQL 16.15, a popular open-source database, with its default isolation level, Read Committed. An isolation level decides what one transaction sees of others running at the same time.
CREATE TABLE show_seat (
show_id bigint NOT NULL,
seat_id text NOT NULL,
held_by text, -- customer id while a hold is live
hold_until timestamptz, -- when that hold lapses
booking_id text, -- set once the seat is sold
PRIMARY KEY (show_id, seat_id)
);
INSERT INTO show_seat (show_id, seat_id)
SELECT 9, 'F' || n FROM generate_series(1, 12) AS n;The hold runs as one transaction. The lines from SELECT :ROW_COUNT down are for psql, the PostgreSQL command-line tool. ROW_COUNT is the number of rows the UPDATE changed, so the file commits only if all four changed.
BEGIN;
SELECT seat_id FROM show_seat
WHERE show_id = 9 AND seat_id IN ('F5', 'F6', 'F7', 'F8')
ORDER BY seat_id
FOR UPDATE; -- lock the four rows in one fixed order
UPDATE show_seat
SET held_by = 'family-A', hold_until = now() + interval '7 minutes'
WHERE show_id = 9 AND seat_id IN ('F5', 'F6', 'F7', 'F8')
AND booking_id IS NULL
AND (hold_until IS NULL OR hold_until <= now());
SELECT :ROW_COUNT = 4 AS all_held \gset
\if :all_held
COMMIT;
\else
ROLLBACK;
\endifIn an app, read the row count your database driver returns instead. SELECT with FOR UPDATE locks the four rows, and ORDER BY seat_id makes every transaction lock them in the same order. That is the per-seat lesson again. The UPDATE then touches only seats that are unsold and have no live hold.
These are the results we saw, running the file above and a second family's version for F8 and F7:
- Free seats: UPDATE 4, then COMMIT.
- Family B wants F8 and F7 while A's hold is live: UPDATE 0, then ROLLBACK.
- Only F7 held by A: UPDATE 1, then ROLLBACK, so F8 stays free. All or nothing still holds.
- A's hold already lapsed: family B gets UPDATE 2 and commits.
- Both at once: B waited until A committed (about 1.5 seconds in our test, because A paused on purpose), then got UPDATE 0 and rolled back.
- Rows updated one at a time in opposite orders: PostgreSQL reported deadlock detected and cancelled one transaction.
Why did the waiting family get UPDATE 0? B's SELECT ... FOR UPDATE waited until A committed. Then it re-checked its WHERE clause against A's new row, which still matched, because it names only the show and the seats.
So B locked the rows. B's UPDATE then ran as a new statement and did not wait. Under Read Committed, each statement sees rows committed before it started, so it saw A's live hold and changed nothing.
Under Repeatable Read, the same race failed instead with could not serialize access due to concurrent update. Then the app must retry the whole transaction.
Confirming the sale is one more conditional update, run after the payment message arrives:
UPDATE show_seat
SET booking_id = 'B1', held_by = NULL, hold_until = NULL
WHERE show_id = 9 AND seat_id IN ('F5', 'F6', 'F7', 'F8')
AND held_by = 'family-A' AND hold_until > now()
AND booking_id IS NULL;In an app, run it in a transaction and commit only if it reports UPDATE 4, as the hold does. Run it twice and the second run says UPDATE 0. That is the repeated-message trap again, so the app must check the booking's status before it treats UPDATE 0 as a late payment.
How do you explain movie ticket booking LLD in a system design interview?
Explain it in the order the bug teaches you: scope, classes, the race, the lock, the hold, and the payment gap. Say each choice out loud with its reason.
Fix the scope
Ask whether it is one cinema app on one server, and whether group bookings and cancellations are in. Write the answers down.
Name the classes
Movie, Theatre, Screen, Seat, Show, Booking, Payment and BookingService. Say that seat status lives on Show.
Show the race
Walk through two threads that both check F7 before either saves. This proves you know why a lock is needed.
Pick the lock scope
Lock one show with a synchronized method or a ReentrantLock. Explain why a global lock is slow and per-seat locks need a fixed order.
Add holds with expiry
Each hold stores a customer id and an expiry time. Expired holds count as free, so no cleanup job is needed for correctness.
Close the payment gap
Pay outside the lock, confirm under it, refund if the hold lapsed, and ignore repeated success messages.
Move to many servers
Name the seat-store interface, then the database version: lock the rows in seat order and update only free seats.
Questions people ask
- What are the main classes in a movie ticket booking system?
- The main classes are Movie, Theatre, Screen, Seat, Show, Booking, Payment and a BookingService that runs the steps. Show is the centre of the design, because it ties a movie to a screen at a time and owns the state of every seat for that screening.
- Why should seat status be stored on Show and not on Seat?
- Because one physical seat is used by many shows. Seat F7 can be sold for the 6 pm show and free for the 9 pm show. If Seat held a booked flag, every show on that screen would share it, and booking one would block all the others.
- Is synchronized enough to stop double booking?
- Yes, inside one running Java program, if every method that reads or changes seat state is synchronized on the same Show object. In our test, F7 was never held by both customers in 12,000 rounds. It does not help across two servers, because each server has its own objects and locks.
- What is the difference between synchronized and ReentrantLock?
- Both let only one thread run the protected code at a time. synchronized is shorter and releases the lock by itself, even when an exception escapes. ReentrantLock is an object you take and release yourself, releasing it in a finally block. It adds tryLock with a time limit and an optional fair order.
- Why not use ConcurrentHashMap putIfAbsent for seat locking?
- putIfAbsent claims one seat atomically, but a group needs several seats at once. You claim them one by one and undo on failure. In our test, two families claiming F7 and F8 in opposite orders were both refused in 14 of 30,000 rounds. Sorting the seat ids removed that.
- How long should a seat be held during payment?
- Long enough for most people to pay, and no longer. There is no standard number, and this guide uses 7 minutes. Measure how long real payments take on your app, then show a countdown so customers know the time limit.
- Do I need a background job to release expired seat holds?
- Not for correctness. If every check treats a hold past its expiry time as free, seats come back on sale on time even if no job runs. A small cleanup job is still useful to delete old holds, as long as it takes the same Show lock.
- How is movie ticket booking LLD different from ticket booking system design?
- The LLD covers classes, methods and locks inside one program. Ticket booking system design covers many servers, a shared database, caching the seat map, and heavy traffic when a big film opens. Interviewers often start with the LLD and then ask how it scales.
Sources and further reading
- The Java Tutorials: Synchronized Methods (two promises of synchronized)
- The Java Tutorials: Intrinsic Locks and Synchronization (monitor lock, static synchronized locks the Class object, release on exception)
- The Java Tutorials: Deadlock (definition)
- Java SE 21 API: ReentrantLock (try/finally, timed tryLock, fairness)
- Java SE 21 API: ConcurrentMap (putIfAbsent is performed atomically)
- Java SE 21 API: Clock (pass a Clock in; Clock.fixed for tests)
- JEP 395: Records (finalized in JDK 16)
- JEP 491: Synchronize Virtual Threads without Pinning
- OpenJDK: JDK 24 (JEP 491 listed; GA 18 March 2025)
- PostgreSQL 16 docs: Transaction Isolation (Read Committed per-statement view, FOR UPDATE re-check, Repeatable Read error)
- PostgreSQL 16 docs: Explicit Locking (deadlocks and consistent lock order)
- Payment gateway docs: Receive events in your webhook endpoint (Handle duplicate events)