Design a Movie Ticket Booking System | LLD System Design
We are going to design the object model behind a movie-ticket booking system in a 60-minute low-level design round. The whole problem, stripped down, is one sentence: a seat for a show must be sold exactly once, even when a hundred people tap "book" on it in the same second. Get that right with clean, extensible classes and you have passed the round.
This is the LLD interview, not the HLD one. Nobody is asking you for load balancers or database sharding here. They want the classes, the relationships, the interfaces, and working code that compiles.
Start big, then narrow it down
The mistake is to start typing classes. Start by scoping, out loud, with the interviewer. "Movie ticketing" can mean anything from one cinema's box office to BookMyShow serving a whole country. The largest single cinema on earth, Kinepolis Madrid, has 25 screens and 9,200 seats; a platform like BookMyShow or Fandango aggregates thousands of such cinemas across cities. You cannot model all of that in an hour, so your first job is to agree on where the boundary is.
You: Are we building the booking engine for a single cinema chain, or the whole aggregator with payments, refunds, and food orders?
Interviewer: Focus on the booking engine for a chain. A user finds a show and books seats.
You: Do I need to model the browse/search side (cities, movies, showtimes), or start from a chosen show?
Interviewer: Model enough structure that a show makes sense, but the interesting part is what happens after the user picks a show. Spend your time there.
You: Two users pick the same seat at the same time. Who wins?
Interviewer: Good question. Exactly one of them. The other must be told it is gone.
You: And a booking that starts but never pays?
Interviewer: Hold the seats for a few minutes, then release them if payment does not come.
You: Pricing? I would assume seat category and show time affect it.
Interviewer: You decide the rules, but make it easy to change them.
That last exchange is the tell. "You decide, but make it easy to change" means pricing varies, which means it does not belong hard-coded inside your booking logic. Park that thought.
Requirements, pinned down
Functional:
- A chain has cinemas in cities. A cinema has screens. A screen has a fixed grid of physical seats in a few categories (regular, premium, recliner).
- A show is a movie playing on a screen at a start time.
- A user selects one or more seats for a show, holds them, pays, and gets a booking.
- Price depends on seat category and show time, and the rules must be swappable.
The one non-functional requirement that actually shapes the code:
- No double-booking. A seat for a show goes to exactly one booking. A partly-completed booking must not strand seats forever.
Everything hard lives in that last bullet. Keep it in view.
Nouns become classes, and one noun splits in two
Underline the nouns in the requirements: cinema, city, screen, seat, movie, show, user, booking. Each becomes a class. In this round every granular thing is its own class, down to the individual seat, because the interviewer wants to see the model, not a bag of arrays.
But there is one modeling decision that separates a passing design from a stuck one, and it is worth getting on the board early:
A physical seat and a bookable seat are not the same thing.
Seat A1 in Screen 1 is a permanent fixture. It exists whether or not any movie is playing. It has a row, a number, and a category, and that is all. It has no status, because the same seat A1 is free for the 6pm show and sold for the 9pm show at the very same moment. A single "is A1 booked?" flag cannot express that.
So we split it:
Seat- the physical seat. Permanent property of a screen. No status.ShowSeat- one per (show, seat). This is the thing that actually gets sold, so this is the thing that carries the status, the price for this show, and the lock.
Every hard part of the problem now lives on ShowSeat. If you only remember one thing from this post, remember this split; it is the question most candidates fumble.
What varies becomes an interface
Two things in the requirements change independently of the core flow, and each earns an abstraction:
- Pricing ("you decide the rules, make it easy to change"). Different chains price differently, and the same chain runs weekend surges and matinee discounts. That is the textbook cue for the Strategy pattern: put pricing behind a
PricingStrategyinterface and let the booking service depend on the interface, never on a concrete rule. That is the Dependency Inversion principle from SOLID doing its job. - The seat lifecycle. A
ShowSeatmoves Available -> Locked -> Booked, and Locked -> Available again if the hold lapses. That is a small state machine, and we enforce it with guarded transitions on the class rather than letting anyone flip a status field freely.
The class diagram
Here is the whole design on one board. Note the diamonds: a screen composes its seats (filled diamond - kill the screen and the seats go with it), while a booking merely aggregates the show-seats it groups (hollow diamond). BookingService depends on the PricingStrategy interface, which TieredPricing realizes (dashed triangle).
The crux: making a seat sell exactly once
ShowSeat carries the one method every concurrent booking fights over. tryLock is an atomic check-and-set: it succeeds only for the caller that flips the seat from free (or expired-hold) to held, and returns false to everyone else. This single method is your entire answer to "two users pick the same seat."
class ShowSeat {
private status = ShowSeatStatus.Available;
private lockedBy: string | null = null;
private lockExpiresAt = 0;
constructor(public readonly seat: Seat, public readonly price: number) {}
// Atomic hold. Succeeds only if the seat is free OR its previous hold expired.
tryLock(userId: string, now: number, ttlMs: number): boolean {
if (this.status === ShowSeatStatus.Booked) return false;
if (this.status === ShowSeatStatus.Locked && now < this.lockExpiresAt) return false;
this.status = ShowSeatStatus.Locked;
this.lockedBy = userId;
this.lockExpiresAt = now + ttlMs;
return true;
}
// Confirm only if the caller still holds an unexpired lock on this seat.
confirm(userId: string, now: number): boolean {
const mine = this.status === ShowSeatStatus.Locked
&& this.lockedBy === userId && now < this.lockExpiresAt;
if (!mine) return false;
this.status = ShowSeatStatus.Booked;
return true;
}
}
The seat's whole life is those transitions:
The hold with a TTL (time-to-live) is what stops an abandoned checkout from stranding a seat forever. No background job is needed to "clean up" expired holds: the next tryLock treats a lapsed lock as free and simply overwrites it. Expiry is lazy.
Pricing behind a Strategy
Pricing is one small interface and one default implementation. Swap in a DemandPricing or a MemberPricing later without touching a line of the booking flow.
interface PricingStrategy {
price(seat: Seat, startTime: number): number;
}
class TieredPricing implements PricingStrategy {
private static readonly BASE = {
[SeatCategory.Regular]: 150,
[SeatCategory.Premium]: 250,
[SeatCategory.Recliner]: 400,
};
price(seat: Seat, startTime: number): number {
let amount = TieredPricing.BASE[seat.category];
const d = new Date(startTime);
if (d.getHours() >= 18) amount += 50; // evening show
if (d.getDay() === 0 || d.getDay() === 6) amount += 50; // weekend
return amount;
}
}
The controller: all-or-nothing selection
BookingService is the only class that touches seats in bulk. openShow builds one priced ShowSeat per physical seat (this is where the strategy gets applied). select is the hot path: it locks every requested seat atomically, and if any one is already taken, it rolls back the holds it just grabbed and fails. That rollback is why a group booking never leaves you holding three of four seats.
select(show: Show, user: User, seatIds: string[], now = Date.now()): Booking {
const picked = show.showSeats.filter((ss) => seatIds.includes(ss.seat.id));
const locked: ShowSeat[] = [];
for (const ss of picked) {
if (ss.tryLock(user.id, now, this.lockTtlMs)) {
locked.push(ss);
} else {
locked.forEach((l) => l.release(user.id)); // roll back partial holds
throw new Error(`Seat ${ss.seat.id} is not available`);
}
}
const amount = locked.reduce((sum, ss) => sum + ss.price, 0);
return new Booking(`BK-${++this.bookingSeq}`, user, show, locked, amount);
}
Then confirm(booking) walks the held seats and flips each to Booked, but only if the caller still owns an unexpired lock; if the hold lapsed while the user fumbled their card, the booking goes to Expired and the seats are already free for the next person.
Running the demo (two users racing for recliner A1) prints exactly what the interviewer asked for:
Seats open: 4, A1 price = 450
Asha holds A1,A2 -> booking BK-1, amount 900
Ravi's select rejected: Seat A1 is not available
Asha pays: CONFIRMED, Ravi pays: CONFIRMED
Seats left: 0
Asha and Ravi both walk away with tickets, and A1 was never handed to both. The full source, with the thin structural classes (Cinema, Screen, Movie, Booking) and the runnable demo, is in the system-design-guide repo under lld/movie-ticketing.
Where the in-memory lock becomes a real one
tryLock is a plain if-then-assign, which is atomic inside one Node thread. Say so, then name the production version before the interviewer asks: on many servers that check-and-set has to be atomic across all of them. It becomes a Redis SET seat NX PX 300000 (set-if-absent with a 300-second expiry) or a database UPDATE show_seats SET status='LOCKED' WHERE id=? AND status='AVAILABLE' where the row count tells you if you won. Same shape, same guarantee, different substrate. Showing that you know the boundary between the object model and the distributed reality is what separates a mid from a senior answer.
How this maps to the interview
A machine-coding round wants clean, extensible, working code inside the hour, not a distributed system. The structure you can draw fast; spend your keystrokes on the three things that carry weight:
- The Seat vs ShowSeat split - it is the modeling insight the whole design hinges on.
- The atomic
tryLockwith a TTL - your correctness story for concurrency and abandoned carts. - The
PricingStrategyinterface - your extensibility story, and a clean bit of SOLID.
Get those three on the board with code that runs, and the cinema, screen, and movie classes are just holders you fill in if time allows. Next in this series we will take another everyday machine-coding favourite and put the same process - nouns to classes, what-varies to interfaces, one hard invariant to protect - to work on it.

