This is a classic LLD interview problem. I will walk through it the way I would at a whiteboard, out loud, in the order the answer actually unfolds.
If you remember five things
The whole answer in one glance. Everything below defends these five calls.
- Seat vs ShowSeat. A Seat is furniture that lives on a screen forever. A ShowSeat is that seat for one show, with its own status and price. Miss this split and your whole model collapses on the first question.
- One component holds locks. SeatLockProvider owns the dangerous concurrency and nothing else does, so correctness is auditable in a single file.
- Hold then pay, with a timer. Seats go AVAILABLE then HELD (5 min) then BOOKED. A hold that times out or a failed payment releases cleanly.
- All-or-nothing locks. Grab every seat the user picked or give them all back. Nobody wants 3 of their 4 seats.
- Three lines of defense. In-memory lock for latency, Redis for the distributed case, a DB unique constraint for truth. The lock can fail; the money cannot.
1Clarify the requirements#
Pin down what we are actually building before drawing a single class. "Movie booking" means five different things to five interviewers, and half of them are secretly testing whether you will build the wrong thing confidently.
What I ask, and the default I pick so I keep moving:
- Seats or ticket counts? Assigned seating (a specific seat in row G) is a different design from general admission (just decrement a counter). I assume assigned seating. That is where the hard part is.
- One theater or a chain? I assume a chain: cities, each with theaters, each with screens, each playing shows. That hierarchy is four nouns I get for free.
- Do we handle payment? I model payment as a pluggable step behind an interface, but I do not build a real gateway. Nobody wants to watch me integrate Stripe at a whiteboard.
- Can a user hold seats before paying? Yes. This is the crux. A hold has a timeout, and I fight for a concrete number: 5 minutes.
Functional scope I commit to:
- Browse movies by city, find shows for a movie.
- See a seat map for a show with live availability.
- Hold selected seats for a short window, 5 minutes.
- Pay, and on success convert the hold into a confirmed booking.
- If payment fails or the timer expires, release the seats.
Non-functional scope:
- Correctness under concurrency is the headline. A seat is sold to exactly one person. Never two, never zero when someone paid. Double-sell and lost-sale are the only two failure modes that get you fired.
- Low latency on the seat map read. People refresh it constantly, reads dwarf writes, so the read path must not touch the lock table.
- Extensible pricing. Weekend seats, recliner seats, matinee discounts all exist, and marketing will invent three more rules next quarter.
I say out loud, "the money question here is seat locking, I will design everything else to serve that." Interviewers like hearing you name the hard part early, because it tells them you have built one of these before and are not going to spend 20 minutes on a User class.
2Identify the core entities#
Name the nouns and justify each one. If I cannot justify a class, it does not go on the board. Noise reads as a candidate who cannot prioritize.
- Movie: title, language, duration, rating. Pure data about the film.
- City and Theater: a theater belongs to a city and has screens.
- Screen (the physical auditorium): owns a fixed layout of seats.
- Seat: a physical seat on a screen. Row, number, and a
SeatType(regular, premium, recliner). It exists once per screen, not per show. Seat G7 is furniture; it does not change when the movie changes. - Show: a movie playing on a screen at a time. The thing people actually book.
- ShowSeat: the class juniors forget, and the one I watch for. A
Seatis physical and permanent. AShowSeatis that seat for one specific show, with a status (available, held, booked) and a price. G7 is available for the 9pm show and booked for the 6pm show at the same time. Status and price belong to the showing, not the furniture. - Booking: a user's attempt to reserve a set of
ShowSeats. It has a status lifecycle. - Payment: the transaction attached to a booking.
- User: the person booking.
Reveal the answer
3Class design#
The shape I aim for, in one breath:
- A
BookingServiceorchestrates everything. - A
SeatLockProviderowns the dangerous concurrency logic and nothing else does. Bookingis a small state machine.- Pricing is a strategy, so I can swap rules without touching booking logic.
I deliberately keep locking out of Booking and out of Show. One component holds locks. That makes the scary part auditable in one file, so when correctness is questioned I point at a single class instead of grep-ing four of them.
Screen *-- Seat and Show *-- ShowSeat are composition (seats cannot outlive their screen, show seats cannot outlive their show), while City o-- Theater is aggregation (a reference, not ownership). The arrows carry a claim, and interviewers notice when yours mean something.4Key implementation#
I show the parts that carry the design: the enums, the lock provider, and the booking flow. I skip getters and constructors; assume they exist.
Start with the status enums, because the whole system is a state machine wearing a trench coat.
enum SeatStatus { AVAILABLE, HELD, BOOKED }
enum BookingStatus { CREATED, CONFIRMED, EXPIRED, CANCELLED }
enum PaymentStatus { PENDING, SUCCESS, FAILED }
enum SeatType { REGULAR, PREMIUM, RECLINER }
The ShowSeat. Small on purpose. Its transitions are guarded so nobody jumps from BOOKED back to AVAILABLE by accident. Every method is synchronized on the instance, so two threads cannot interleave a read-check-write on the same seat.
class ShowSeat {
private final String id;
private final Seat seat;
private volatile SeatStatus status = SeatStatus.AVAILABLE;
private double price;
synchronized boolean hold() {
if (status != SeatStatus.AVAILABLE) return false;
status = SeatStatus.HELD;
return true;
}
synchronized void book() {
if (status != SeatStatus.HELD)
throw new IllegalStateException("can only book a held seat");
status = SeatStatus.BOOKED;
}
synchronized void release() {
if (status == SeatStatus.HELD) status = SeatStatus.AVAILABLE;
}
}
Why release() only touches a HELD seat. That one if is load-bearing: it is what makes the expiry sweep safe to run against a seat that was booked a millisecond earlier. I come back to it in the edge cases.
Now the piece that matters most: the lock provider. It holds a per-seat lock with an owner and an expiry.
- One
ConcurrentHashMapkeyed by show seat id. - The atomic
computecall is my compare-and-set. - No separate mutex; the map itself is the synchronization primitive.
class SeatLock {
final String ownerUserId;
final Instant expiresAt;
SeatLock(String owner, Instant exp) {
this.ownerUserId = owner; this.expiresAt = exp;
}
boolean isExpired() { return Instant.now().isAfter(expiresAt); }
}
class SeatLockProvider {
private final ConcurrentHashMap<String, SeatLock> locks = new ConcurrentHashMap<>();
private final Duration ttl;
SeatLockProvider(Duration ttl) { this.ttl = ttl; }
// Lock ALL seats or none. All-or-nothing is the contract.
boolean lock(String userId, List<ShowSeat> seats) {
List<ShowSeat> acquired = new ArrayList<>();
Instant exp = Instant.now().plus(ttl);
for (ShowSeat s : seats) {
SeatLock mine = new SeatLock(userId, exp);
SeatLock prev = locks.compute(s.id(), (k, existing) -> {
if (existing == null || existing.isExpired()) return mine;
return existing; // someone else holds it
});
if (prev != mine) { // we did not win this seat
unlock(userId, acquired); // roll back what we grabbed
return false;
}
acquired.add(s);
}
return true;
}
void unlock(String userId, List<ShowSeat> seats) {
for (ShowSeat s : seats) {
locks.computeIfPresent(s.id(), (k, l) ->
l.ownerUserId.equals(userId) ? null : l);
}
}
}
Two things I want the interviewer to notice:
lockis all-or-nothing: if I cannot get every seat, I give back the ones I grabbed and fail clean. Nobody wants 3 of their 4 seats.- I only release a lock if the caller owns it, so a late cleanup job cannot yank a seat from the person who legitimately holds it. The ownership check in
unlockis the other load-bearing line in this file.
Then the service ties it together.
class BookingService {
private final SeatLockProvider lockProvider;
private final PricingStrategy pricing;
private final Map<String, Booking> bookings = new ConcurrentHashMap<>();
Booking holdSeats(Show show, List<String> seatIds, User user) {
List<ShowSeat> seats = show.resolve(seatIds);
if (!lockProvider.lock(user.id(), seats))
throw new SeatUnavailableException("seats just got taken");
for (ShowSeat s : seats) {
s.hold();
s.setPrice(pricing.price(show, s));
}
Booking b = new Booking(user, seats, BookingStatus.CREATED);
bookings.put(b.id(), b);
return b;
}
Booking confirmBooking(String bookingId, Payment payment) {
Booking b = bookings.get(bookingId);
if (b.status() != BookingStatus.CREATED)
throw new IllegalStateException("booking not in a confirmable state");
if (payment.status() != PaymentStatus.SUCCESS) {
cancel(b);
throw new PaymentFailedException();
}
b.seats().forEach(ShowSeat::book); // HELD -> BOOKED
lockProvider.unlock(b.user().id(), b.seats());
b.setStatus(BookingStatus.CONFIRMED);
return b;
}
}
I flip the seats to BOOKED before I release the lock. If I released first, there would be a window where the seat is neither locked nor booked, and a racing thread could grab it. Book, then unlock. Never the other way.
The runtime flow for a happy booking, which is what I sketch if the interviewer asks "walk me through a booking."
5Design decisions and patterns used#
Each pattern earns its place by removing a specific future edit.
- Strategy for pricing.
PricingStrategy.price(show, seat)plugs in weekend surge, recliner premiums, or a happy-hour discount without reopening booking code. I can chain them decorator-style, so a matinee discount wraps a seat-type base price. Rule number six is a new class, not an edit toBookingService. - State machine for
BookingandShowSeat. Transitions are enforced in code (book()throws if the seat is not HELD). Interviewers love when invalid states are literally unrepresentable instead of guarded by scattered if-checks. - Factory for building a
Show. When a show is created I generate oneShowSeatper physicalSeaton that screen. AShowFactory.create(movie, screen, time)keeps that fan-out in one place, so a 200-seat screen reliably produces exactly 200ShowSeats. - Provider / Singleton for
SeatLockProvider. There is exactly one authority on who holds what. If locking were sprinkled across classes, I could never reason about correctness. Concentrating it is the whole point. - Observer as a follow-up. A
BookingConfirmedevent notifies a mailer, a seat-map cache invalidator, and an analytics sink without the service knowing any of them exist. This keeps the hot booking path free of side-effect code.
Payment stays behind an interface. I keep the gateway behind a PaymentGateway interface so booking logic never imports Stripe or Razorpay directly. Pure dependency inversion, and it means the whole flow is testable with a fake gateway that returns SUCCESS or FAILED on command.
6Handling edge cases and concurrency#
This is the section the whole problem exists for. "I would add locking" is not an answer, it is a promise to answer. Here is me paying it off.
Two users grab seat G7 at once.
- Both hit
lockProvider.lock. locks.computeis atomic per key, so exactly one installs theirSeatLock.- The other sees a non-expired existing lock and loses; its partial acquisitions roll back.
- Clean. No double sell. This is the scenario I trace line by line, because it is the entire question.
The hold timer expires.
- A user holds 4 seats and walks away.
releaseExpired()runs on a scheduler, say every 30 seconds.- It scans bookings in CREATED state older than the 5-minute TTL, flips them to EXPIRED, calls
ShowSeat.release(), and unlocks. - Because
unlockchecks ownership andrelease()only acts on HELD seats, a user who paid one millisecond before expiry is safe: their seats are already BOOKED, sorelease()is a no-op. The guards from section 4 pay off exactly here.
Payment succeeds but the lock already expired (the nasty one).
- User pays at second 301 of a 300-second hold.
- I handle it at confirm time: before charging, re-check that this user still owns the locks.
- If the cleanup job already released them and someone else grabbed them, fail the confirmation and refund.
- If they are still free, re-acquire and proceed.
- The rule I state out loud: never charge money for a seat you do not still hold.
Never charge money for a seat you do not still hold.
Idempotency on a double-clicked Pay.
- Key confirmation on
bookingIdand guard onBookingStatus. - The second call sees CONFIRMED (not CREATED) and returns the existing booking instead of charging twice.
- Same logic protects a client that retries on a flaky network.
Single process vs distributed.
- My
ConcurrentHashMaplock is correct for one JVM. - In the real system there are many app servers, so I push the lock into Redis:
SET showseat:{id} {userId} NX PX 300000. NXgives the same compare-and-set;PXgives the 300-second TTL for free, so expired holds self-clean even if my cleanup job dies.- Saying this shows I know where the in-memory version stops working, which is the question behind the question for a senior round.
The database is the final backstop. The ShowSeat row gets a unique constraint or an optimistic version column, so even if two requests both think they won the lock, UPDATE ... WHERE status = 'HELD' AND version = ? commits for exactly one. Locks are for latency and fairness; the DB constraint is for truth. The lock can fail and the money cannot, so I want three layers.
Scattering lock logic across Booking, Show, and BookingService, then trying to argue correctness. If I cannot find the one file that decides who holds G7, I do not believe your concurrency story no matter how clean the diagram is. Worse: releasing the lock before flipping to BOOKED, which opens the exact window you were hired to close.
7Extensions and follow-ups#
When the interviewer pushes, these are the roads I am ready to walk.
"Payment succeeds but the hold already expired. Now what?"
"Your map lock works in one JVM. How does it survive many app servers?"
"The show is full. Can I waitlist?"
"Give me 4 seats together, center-ish. How?"
"Book across two screens for a private event, and one screen fails?"
Other roads I keep ready:
- Dynamic pricing. Swap
PricingStrategyfor one that reads current occupancy. 90% full? Nudge the last rows up. The Strategy pattern makes this a one-class change. - Cancellation and refunds. Add a
CANCELLEDpath that releasesShowSeats back to AVAILABLE and triggers a refund through the gateway, with a cutoff before showtime, say no refunds inside 2 hours of start.
8What the interviewer is really checking#
They are not grading your UML. They are watching for two things.
- Did you spot that
SeatandShowSeatare different things? That split is the modeling tell. - Can you make "two people, one seat" provably safe? Put locking in five classes and you fail the concurrency story no matter how pretty the diagram. Name the hard case (pay-after-expiry) before they do and you pass the senior bar.
The rest, cities and movies and screens, is data modeling they expect you to get right quickly so you have time for the part that is actually hard.
What you can now explain in the room
- Why `Seat` is furniture and `ShowSeat` carries status and price, and what breaks if you merge them.
- How `ConcurrentHashMap.compute` gives you an atomic compare-and-set with no separate mutex.
- Why `lock` is all-or-nothing and why `unlock` checks ownership.
- Why you flip seats to BOOKED before releasing the lock, never the reverse.
- How the pay-after-expiry race is caught at confirm time with a re-check and refund.
- Why you want three lines of defense: in-memory lock, Redis, and a DB unique constraint.
If you liked the locking story here, look at the same pattern in the parking lot and the elevator problems, and read up on the State and Strategy patterns before your next LLD round.
Get a prep plan built around your Design a Movie Ticket Booking System interview
Tell us the role and your interview date. We will send back a dated, day by day plan. Free.