Code a cinema seat reservation system
Open in the interactive app: practice, quiz, mock interview →In one minute
- The heart of the system is a per-seat state machine for one show: available, held (with an expiry time), booked. Every other class exists to drive that machine safely.
- A hold is all-or-nothing across the seats a user picked, and the check and the claim happen in one critical section. Check-then-act across two steps is how double bookings happen.
- Expiry is computed from an injected clock, not a background timer. A held seat whose expiry has passed already reads as available, so correctness never depends on a sweeper running on time; the sweeper only tidies up.
- Booking requires the same user, the same hold, and a hold that is still live at the moment of booking. A late payment against an expired hold must fail and be refunded, never squeeze into a seat someone else now holds.
- The shape is contention: many buyers, one seat. The same machine guards hotel rooms, clinic appointment slots, cloud GPU reservations and the last unit of stock in a flash sale.
Requirements
Functional
- Browse cinemas, screens, movies and shows (a show is one movie on one screen at one start time).
- See the seat map for a show with each seat's state.
- Select one or more seats and place a temporary hold on them for a fixed time (for example 5 minutes) while paying.
- Confirm the booking after payment; the held seats become booked and a ticket is issued.
- Release a hold explicitly (user backs out) or implicitly (it expires).
- Seat categories with different prices (standard, premium, recliner).
Non-functional
- Never book the same seat twice for the same show, under any interleaving of requests.
- A hold is all-or-nothing: either every requested seat is held or none is.
- Holds expire on time as observed by the next request, independent of background jobs.
- Seat map reads are fast and may be slightly stale; writes (hold, book) are strongly consistent.
Out of scope
- Payment provider internals, refunds workflow, food and beverage add-ons, dynamic pricing and the distributed waiting room. The system design version of this chapter covers the multi-node story.
Entities and relationships
Cinema has many Screens. A Screen has a fixed SeatLayout: rows of Seat definitions, each with an id like C7 and a SeatCategory. Movie holds title, duration and rating. Show links one Movie to one Screen at a start time and owns a ShowSeats object: the live state of every seat for that showing. Splitting the static Seat (part of the room) from the per-show seat state is the most important modelling decision; the same physical seat is booked for the 7 pm show and free for the 10 pm one.
A Hold has an id, the user, a list of seat ids, an expiry time and a state (active, booked, released, expired). A Booking is created from a booked hold and references the Show, the seats, the price breakdown and a Payment. BookingService coordinates: it asks ShowSeats for a hold, asks the PaymentGateway to charge, then asks ShowSeats to book. A Clock interface supplies the current time, so tests can move time forward without sleeping.
Class diagram
classDiagram
class Cinema {
+id
+screens
}
class Screen {
+id
+layout SeatLayout
}
class Seat {
+id
+category SeatCategory
}
class Movie {
+title
+durationMin int
}
class Show {
+id
+movie Movie
+screen Screen
+startsAt
+seats ShowSeats
}
class ShowSeats {
-holderOf map
-booked set
-holds map
+hold(user, seatIds) holdId
+book(holdId, user) seatIds
+release(holdId) bool
+status(seatId) SeatStatus
+releaseExpired() int
}
class Hold {
+id
+user
+seatIds
+expiresAt
+state HoldState
}
class Clock {
<<interface>>
+now() int
}
class BookingService {
+reserve(showId, user, seatIds)
+confirm(holdId, user, payment)
}
class PaymentGateway {
<<interface>>
+charge(idempotencyKey, amount)
}
Cinema "1" *-- "*" Screen
Screen "1" *-- "*" Seat
Show --> Movie
Show --> Screen
Show "1" *-- "1" ShowSeats
ShowSeats "1" o-- "*" Hold
ShowSeats --> Clock
BookingService --> ShowSeats
BookingService --> PaymentGatewayKey flows
The critical flow is hold, pay, book. The hold id doubles as the payment idempotency key, so a retried confirm cannot charge twice.
sequenceDiagram
participant U as User
participant B as BookingService
participant S as ShowSeats
participant C as Clock
participant P as PaymentGateway
U->>B: reserve(show, [C7, C8])
B->>S: hold(user, [C7, C8])
S->>C: now()
S->>S: all seats available? claim all, expiresAt = now + ttl
S-->>B: holdId 42
B-->>U: holdId 42, pay within 5 min
U->>B: confirm(42, card)
B->>P: charge(key=42, amount)
P-->>B: charged
B->>S: book(42, user)
S->>C: now()
alt hold still live
S-->>B: [C7, C8] booked
B-->>U: ticket
else hold expired or taken over
S-->>B: error hold invalid
B->>P: refund(key=42)
B-->>U: sorry, seats released
endDesign patterns used and why
State for seats and holds. A seat moves available to held to booked, and back from held to available on release or expiry. Booked is terminal for the show (cancellation is a separate, audited flow). Writing the legal transitions down as a small table, and rejecting everything else, is what makes the double-booking guarantee reviewable. In the practice code the state is derived rather than stored on the seat: a seat is booked if it is in the booked set, held if it points to a live hold, otherwise available. That removes a whole class of bugs where a seat's stored state and its hold disagree.
Facade in BookingService. The API layer calls reserve and confirm; the service hides the order of operations across seats, payments and tickets.
Strategy for pricing. A PricingStrategy maps (seat category, show time, day) to a price. Weekday matinee discounts or a premium surcharge are new strategies, not edits to booking code.
Dependency injection of Clock and PaymentGateway. Expiry is the most bug-prone part of the system, and an injected clock lets a test say "now it is 299 seconds later" and "now it is 300" without sleeping. A fake gateway lets tests force a payment that succeeds after the hold expired.
Observer for side effects. When a hold becomes a booking, publish an event; ticket emails, loyalty points and analytics subscribe. The booking path stays short and does not fail because the email service is slow.
Concurrency and edge cases
Check and claim must be one step
The classic bug: request A checks C7 is available, request B checks C7 is available, both write "held". In one process, guard each show's seat state with a mutex and do the check and the claim inside it. Lock per show, not globally, so a blockbuster's opening does not slow every other screen. In a database, the equivalent is a conditional update (UPDATE show_seat SET hold_id = $1, expires_at = $2 WHERE show_id = $3 AND seat_id = ANY($4) AND booked = false AND (hold_id IS NULL OR expires_at <= now())) and checking that the affected row count equals the number of seats requested, otherwise roll back.
All-or-nothing holds
A user who asks for C7 and C8 together does not want only C7. Validate every seat before claiming any. If you claim seat by seat in a database, sort the seat ids first so two overlapping requests lock rows in the same order and cannot deadlock.
Lazy expiry
A hold expires when now >= expiresAt. Every read and write evaluates that, so a seat whose hold expired one millisecond ago is available to the next caller even if the cleanup job has not run. When a new hold takes over a seat from an expired hold, mark the old hold expired and free all of its seats, so the old holder cannot later book a partial set. releaseExpired is housekeeping that frees memory and reports metrics; it is not on the correctness path.
The late payment
The user pays at 4:59, the card network answers at 5:03. The hold has expired and maybe someone else holds the seat. book must refuse, and the service must refund (or void the authorisation). Two common mitigations reduce how often this happens: extend the hold once when payment starts, and authorise first and capture only after book succeeds.
Other edge cases
Duplicate seat ids in one request, unknown seats, an empty selection, a different user trying to book someone else's hold, a double click on "confirm" (book is idempotent for the same user and hold), and the boundary instant where now == expiresAt (treat as expired, and test it).
Down the stack
Layer 1The contract
For each show, at most one booking per seat, ever. hold either claims every requested seat or changes nothing. A hold created at time t is live while now < t + ttl and dead after, as seen by every subsequent operation. book succeeds only for the hold's own user while it is live, and repeating a successful book returns the same seats.
Layer 2The mechanism
ShowSeats keeps three maps: seat to current hold, the set of booked seats, and hold id to Hold. status is O(1). hold is O(k) for k seats under one lock. releaseExpired in the practice scans holds; a production version keeps a min-heap ordered by expiresAt and pops until the top is in the future, so cleanup costs O(log n) per expired hold. In Postgres, the same contract comes from row locks taken by UPDATE or SELECT ... FOR UPDATE, plus a unique index on (show_id, seat_id) in the bookings table as the last line of defence. A Redis-only design uses SET seat:show:C7 holdId NX PX 300000, which gives the expiry for free but loses the hold if Redis fails over before replicating.
Layer 3The machine
An uncontended sync.Mutex lock and unlock in Go is a single atomic compare-and-swap, roughly 10 to 20 ns. Under contention, goroutines spin briefly then park on a semaphore, and the runtime switches to starvation mode after 1 ms of waiting so no waiter is stuck forever. A hold touches a few map entries, so the critical section is well under a microsecond and one show can serve hundreds of thousands of hold attempts per second on one core. In a database, the same hold costs a network round trip (about 0.5 ms in one data centre) plus a WAL write and fsync at commit (tens of microseconds on NVMe, milliseconds on networked disks), which is why the hot show, not the database in general, is what you size for.
Why it's built this way
Two buyers can click within the same millisecond, so the check and the claim must be atomic. Atomicity is cheapest at the smallest scope that owns the data, so lock per show. Payment takes seconds to minutes, far too long to hold a lock, so the lock protects a short state transition and the long wait is represented as data (a hold with an expiry). Background timers can be late, paused or dead, so expiry is a comparison against the clock at use time. Tests must cover the exact boundary, so the clock is injected.
Extensibility
- Seat categories and pricing: add a
PricingStrategy;ShowSeatsdoes not change because it never knows prices. - Partial cancellation of a confirmed booking: add a booked-to-available transition guarded by a refund policy, logged as its own event so auditors can see that a seat was sold twice legitimately.
- Group seating ("4 adjacent seats in row F"): a seat-finder that proposes seat ids and then calls the same all-or-nothing
hold. Selection logic stays outside the state machine. - Multiple servers: keep
ShowSeatssemantics but move the state into a database row per show seat with conditional updates, or route all requests for a show to one owner process. The interface does not change, which is why it is worth designing around one show. - Waitlists: when a hold expires or is released, notify the first waiting user; this is an observer on the release transition.
- Other domains: the same
ShowSeatswith "seat" renamed to "slot" is a doctor's appointment book, a meeting-room scheduler, or a GPU reservation system.
Leadership lens
- Insist on the state transition table in the design doc before any code. Most double-booking incidents trace back to a transition nobody wrote down, such as "expired hold gets booked".
- The contract between booking and payments (hold id as idempotency key, authorise then capture, refund on failed book) crosses teams. Name an owner for it, and test the late-payment path end to end.
- Ask how expiry is tested. If the answer involves
sleep, the tests will be slow and flaky; push for an injected clock. - Watch the operational signals that predict trouble: hold-to-book conversion, holds expiring during payment, and book failures after a successful charge. The last one is a direct refund cost and a support ticket.
- Keep the in-process lock scoped to one show. A global lock is simpler on day one and becomes the outage on the first opening night.
Check yourself
Why does the design treat a hold whose expiry has passed as available immediately, instead of waiting for a cleanup job?
Show answer
Correctness then depends only on comparing the clock with
expiresAtat the moment of each request. A cleanup job can be late or down; if correctness depended on it, seats would stay blocked or, worse, two views of state would disagree.User A's hold on C7 and C8 expired, and user B has just held C7. What should happen to A's hold, and why?
Show answer
A's hold should be marked expired and all its seats freed, including C8. Otherwise A could later book C8 alone, a partial set A never asked for, or the stale hold could block C8 for others.
Why is the hold id a good payment idempotency key?
Show answer
It is unique per attempt to buy a specific set of seats, and a retried confirm carries the same id, so the gateway charges once. A new attempt after expiry gets a new hold and therefore a new key.
Where does a per-show mutex stop being enough, and what replaces it?
Show answer
As soon as two processes can serve the same show, an in-memory lock protects nothing. Replace it with a conditional update in the database (row count check, unique index on booked seats) or route each show to a single owner process.
Watch
- LLD of BookMyShow: Design Movie Ticket Booking, Low Level Design and Concurrency (Concept && Coding, in Hindi): a class-by-class design with code and a section on handling concurrent seat selection.
- System Design Interview: Design Ticketmaster w/ a Ex-Meta Staff Engineer (Hello Interview): the distributed version of the same problem, including reservation expiry and surges.
- "Transactions: myths, surprises and opportunities" by Martin Kleppmann (Strange Loop Conference): what isolation levels actually guarantee when the seat state moves into a database.
Free resources
- State pattern (Refactoring.Guru): structure and trade-offs of modelling behaviour as explicit states and transitions.
- Explicit locking (PostgreSQL docs): row-level locks and deadlock detection, the database counterpart of the per-show mutex.
- SET command (Redis docs): the
NXandPXoptions used for expiring holds, and their limits. - How to do distributed locking (Martin Kleppmann): why leases with expiry need fencing tokens to be safe, directly relevant to holds.
- The Go Memory Model (go.dev): what a mutex guarantees about visibility between goroutines.
- Test Double (Martin Fowler): the vocabulary for fakes such as the injected clock and payment gateway.