Engineering Atlas › Low-Level Design

Code a cinema seat reservation system

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope

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 --> PaymentGateway

Key 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
    end

Design 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

Leadership lens

Check yourself

  1. 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 expiresAt at 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.

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

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

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

Free resources

Open in the interactive app: practice, quiz, mock interview →