Home › Low-Level Design › Design a Movie Ticket Booking System
D

Design a Movie Ticket Booking System

The whole problem lives in one moment: two people tap the same seat at the same second. Everything else is scaffolding around that race. I have asked this question in loops at a large consumer company, and the candidates who pass are the ones who find that moment in the first two minutes and spend their energy there. The ones who fail draw a pretty seven-class diagram and then wave their hands when I ask "what happens when both users click G7?"

Low-Level DesignHard13 min read

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.

  1. 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.
  2. One component holds locks. SeatLockProvider owns the dangerous concurrency and nothing else does, so correctness is auditable in a single file.
  3. 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.
  4. All-or-nothing locks. Grab every seat the user picked or give them all back. Nobody wants 3 of their 4 seats.
  5. 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.
From the interviewer's chair

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 Seat is physical and permanent. A ShowSeat is 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.
Seat vs ShowSeat
The single most important call in this problemGet it and the rest falls out. Miss it and you put a `status` field on `Seat`, then the first question ("can G7 be booked for 6pm and free for 9pm?") collapses your model.
Pause before you read on: where does the `status` field live, on `Seat` or on `ShowSeat`? And why does the answer decide the whole design?
Reveal the answer
On `ShowSeat`. A single physical seat has a different status for every show playing on that screen: free at 9pm, booked at 6pm, held at noon. Status and price are properties of the seat-for-one-show, not the furniture. Put status on `Seat` and you can only represent one show's state at a time, which is wrong the moment a screen plays two movies in a day.

3Class design#

The shape I aim for, in one breath:

  • A BookingService orchestrates everything.
  • A SeatLockProvider owns the dangerous concurrency logic and nothing else does.
  • Booking is 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.

classDiagram class Movie { -String id -String title -int durationMins } class City { -String id -String name } class Theater { -String id -String name } class Screen { -String id -List~Seat~ seats } class Seat { -String id -String row -int number -SeatType type } class Show { -String id -Movie movie -Screen screen -LocalDateTime startTime -Map seatMap } class ShowSeat { -String id -Seat seat -SeatStatus status -double price } class Booking { -String id -User user -List~ShowSeat~ seats -BookingStatus status +confirm() void +cancel() void } class Payment { -String id -double amount -PaymentStatus status } class User { -String id -String name } class BookingService { +holdSeats(showId, seatIds, user) Booking +confirmBooking(bookingId, payment) Booking +releaseExpired() void } class SeatLockProvider { +lock(show, seats, user) boolean +unlock(show, seats) void +isLocked(showSeat) boolean } class PricingStrategy { +price(show, seat) double } Theater o-- Screen Screen *-- Seat City o-- Theater Show --> Movie Show --> Screen Show *-- ShowSeat ShowSeat --> Seat Booking *-- ShowSeat Booking --> User Booking --> Payment BookingService --> SeatLockProvider BookingService --> PricingStrategy
The class map. Notice the diamonds: 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 ConcurrentHashMap keyed by show seat id.
  • The atomic compute call 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:

  • lock is 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 unlock is 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;
    }
}
Order matters in confirmBooking

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."

sequenceDiagram participant U as User participant BS as BookingService participant LP as SeatLockProvider participant P as PaymentGateway U->>BS: holdSeats(show, seatIds) BS->>LP: lock(user, seats) LP-->>BS: true (all acquired) BS-->>U: Booking CREATED (5 min timer) U->>BS: confirmBooking(bookingId, payment) BS->>P: charge(amount) P-->>BS: SUCCESS BS->>LP: unlock(user, seats) BS-->>U: Booking CONFIRMED
The happy path. The lock is acquired before any seat flips to HELD, and released only after every seat flips to BOOKED. The 5-minute timer starts the instant the booking is CREATED, which is the window the expiry sweep polices.

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 to BookingService.
  • State machine for Booking and ShowSeat. 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 one ShowSeat per physical Seat on that screen. A ShowFactory.create(movie, screen, time) keeps that fan-out in one place, so a 200-seat screen reliably produces exactly 200 ShowSeats.
  • 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 BookingConfirmed event 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.compute is atomic per key, so exactly one installs their SeatLock.
  • 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 unlock checks ownership and release() only acts on HELD seats, a user who paid one millisecond before expiry is safe: their seats are already BOOKED, so release() 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 bookingId and guard on BookingStatus.
  • 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 ConcurrentHashMap lock 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.
  • NX gives the same compare-and-set; PX gives 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.
Three lines of defense, not one

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.

Red flag that fails you here

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.

Follow-ups the interviewer will ask (and your answers)
"Payment succeeds but the hold already expired. Now what?"
At confirm time, before charging, I re-check that the user still owns the locks. If a cleanup job released them and someone else grabbed them, I fail the confirmation and refund. If they are still free, I re-acquire and proceed. Never charge for a seat you do not still hold.
"Your map lock works in one JVM. How does it survive many app servers?"
Push the lock into Redis: `SET showseat:{id} {userId} NX PX 300000`. `NX` is the same compare-and-set, `PX` is the 300-second TTL so expired holds self-clean even if the sweeper dies. The DB unique constraint stays underneath as the source of truth.
"The show is full. Can I waitlist?"
Yes. Users join a queue, and when a hold expires or a booking cancels, the Observer fires and offers the freed seats to the next person for a short window, maybe 2 minutes, before passing on. The event plumbing from section 5 already exists.
"Give me 4 seats together, center-ish. How?"
A scan over the seat map for a contiguous block in the best rows, scored by distance from center. It is a clean ten-minute coding tangent if they want algorithm chops, and it reads straight off the per-show seat map.
"Book across two screens for a private event, and one screen fails?"
Now a single lock is not enough. I reach for a saga: if screen B fails, compensate by releasing screen A. This is where I admit the in-process model stops and a coordinator takes over.

Other roads I keep ready:

  • Dynamic pricing. Swap PricingStrategy for 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 CANCELLED path that releases ShowSeats 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 Seat and ShowSeat are 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.

Thanks. Check your inbox, your Design a Movie Ticket Booking System prep plan is on the way.