CMD Guide
HomeOO & Low-Level DesignOO Design Problems

Design a Movie Ticket Booking System

The entire design of a ticket booking system turns on one mechanism: the moment a customer commits to seats, the database must atomically check that those exact seats are still free and mark them taken, so that two concurrent requests for seat 55 cannot both observe it as free. Everything else — the class model, the search index, the notifications — is scaffolding around that single critical section.

This page keeps the standard Grokking class model but treats the seat-reservation race as the real subject: we trace the double-booking interleaving step by step, give a JDBC transaction that actually compiles and is actually correct, and then weigh the locking approach against the two alternatives a senior engineer would consider.

The domain model, in one pass

The objects only matter insofar as they let us name the row we are racing on. A Movie has many Shows; each Show runs in one CinemaHall at a startTime. A hall has fixed physical seats (CinemaHallSeat) — seat A12 exists whether or not anyone is watching. The seat that actually gets booked is the ShowSeat: the cross-product of one physical seat with one show. Seat A12 for the 7pm show and seat A12 for the 9:30pm show are two different ShowSeat rows with independent isReserved flags. A Booking ties a customer to a set of ShowSeats plus a Payment.

The key modelling decision: availability lives on ShowSeat, not on the physical seat. If you put isReserved on CinemaHallSeat you would reserve a seat across every show at once. The race we care about is therefore always over a small set of ShowSeat rows identified by (showId, showSeatId).

Sketched as fields (getters/setters elided; treat fields as private):

class Show       { int showId; Date startTime, endTime; CinemaHall hall; Movie movie; }
class CinemaHallSeat { int seatId; SeatType type; }          // physical, e.g. "A12"
class ShowSeat extends CinemaHallSeat {
    int    showSeatId;       // PK of the bookable row
    int    showId;           // which show
    boolean isReserved;      // THE contended field
    double price;
}
class Booking {
    String bookingNumber;
    BookingStatus status;    // PENDING -> CONFIRMED | CANCELED | ABANDONED
    Show show;
    List<ShowSeat> seats;
    Payment payment;
}

A booking moves PENDING → CONFIRMED only after both seats are locked and payment clears; an unpaid PENDING booking that times out becomes ABANDONED and its seats are released. That state machine is what makes the "hold the seat for 10 minutes while I pay" feature possible without leaking inventory forever.

The race, traced with real values

Two customers both want seat 55 of show 99 (a popular opening night). Both client requests arrive within the same millisecond and run the naive logic — read the seat, see it is free, then write it reserved — with no transaction or with a too-weak isolation level. The interleaving:

diagram
diagram

The defect is the gap between the read at t1/t2 and the write at t3/t4. Both transactions read isReserved = 0 before either writes, so both believe they won. This is a classic read–modify–write race (a "lost update"). Wrapping the two statements in a transaction at the default isolation level (READ COMMITTED in PostgreSQL/SQL Server, REPEATABLE READ in MySQL) does not save you on its own: a plain SELECT takes only a shared read lock (or, under MVCC, no lock at all — it just reads a snapshot), which does not block another transaction's later read. You need to make the read conflict with the other transaction's write.

Two correct fixes — and the claim to retire

The original page asserted: "within a transaction, if we read rows we get a write-lock on them." That is wrong and worth retiring deliberately, because it leads people to expect protection they do not have. A bare SELECT never takes a write/exclusive lock. Under SERIALIZABLE it takes shared and range (predicate) locks — enough to make the engine detect the conflict and abort one transaction, but the rows are not write-locked the instant you read them. There are two honest ways to close the race:

  1. Pessimistic locking — SELECT ... FOR UPDATE. This explicitly takes an exclusive row lock at read time. T_bob's SELECT ... FOR UPDATE blocks at t2 until T_alice commits, then sees isReserved = 1 and bails. This is the most direct expression of intent and works at the default isolation level.
  2. Serializable isolation. Set the level to SERIALIZABLE; the engine tracks range/predicate locks and, when it detects that the two transactions' read- and write-sets conflict, it aborts one with a serialization-failure error that the application must catch and retry.

Either works. The example below uses SELECT ... FOR UPDATE because it is the clearer mental model and does not require retry plumbing.

The SQL, with real ids (reserve seats 54, 55, 56 of show 99):

BEGIN;

-- Take an EXCLUSIVE lock on exactly the rows we want, but only if still free.
SELECT show_seat_id
FROM   show_seat
WHERE  show_id = 99
  AND  show_seat_id IN (54, 55, 56)
  AND  is_reserved = 0
FOR UPDATE;          -- <- this is what blocks the other booker

-- The application checks: did we get back exactly 3 rows?
-- If not, at least one seat is already taken -> ROLLBACK and tell the user.

UPDATE show_seat
SET    is_reserved = 1
WHERE  show_id = 99
  AND  show_seat_id IN (54, 55, 56)
  AND  is_reserved = 0;   -- guard repeated, so the UPDATE itself is safe

-- Application checks the affected-row count == 3 as a second belt.
INSERT INTO booking (...) VALUES (...);

COMMIT;

Why FOR UPDATE is load-bearing: without it the SELECT is a snapshot read and the two transactions never conflict on the read, so both reach the UPDATE and one silently overwrites the other (the lost update above). FOR UPDATE makes the second reader wait, turning a race into an ordered queue.

The same logic in JDBC — corrected and compilable

The original Java had several bugs: it created an unused Statement st; used SQL && (not valid SQL) instead of AND; passed an array to a single IN (?) placeholder (standard JDBC does not expand one parameter into an IN list); counted rows by scrolling a ResultSet with rs.next() then rs.last() (which skips the logic on the empty case and needs a scrollable result set); and called rollback() inside catch on a connection that might be null, risking a NullPointerException that masks the real error. Here is a version that compiles and is correct:

public boolean makeBooking(Booking booking) throws SQLException {
    List<ShowSeat> seats = booking.getSeats();
    int showId = booking.getShow().getShowId();

    // Build "?, ?, ?" with one placeholder per seat id.
    String placeholders = seats.stream()
        .map(s -> "?")
        .collect(java.util.stream.Collectors.joining(", "));

    String lockSql =
        "SELECT show_seat_id FROM show_seat " +
        "WHERE show_id = ? AND is_reserved = 0 " +
        "AND show_seat_id IN (" + placeholders + ") FOR UPDATE";

    String updateSql =
        "UPDATE show_seat SET is_reserved = 1 " +
        "WHERE show_id = ? AND is_reserved = 0 " +
        "AND show_seat_id IN (" + placeholders + ")";

    Connection conn = null;
    try {
        conn = getDBConnection();
        conn.setAutoCommit(false);   // start the transaction

        // 1. Lock the rows we still believe are free.
        try (PreparedStatement lock = conn.prepareStatement(lockSql)) {
            lock.setInt(1, showId);
            for (int i = 0; i < seats.size(); i++) {
                lock.setInt(i + 2, seats.get(i).getShowSeatId());
            }
            int locked = 0;
            try (ResultSet rs = lock.executeQuery()) {
                while (rs.next()) locked++;
            }
            // Fewer rows than requested => someone already took one. Abort.
            if (locked != seats.size()) {
                conn.rollback();
                return false;
            }
        }

        // 2. Flip them reserved. The WHERE guard makes this idempotent-safe.
        try (PreparedStatement upd = conn.prepareStatement(updateSql)) {
            upd.setInt(1, showId);
            for (int i = 0; i < seats.size(); i++) {
                upd.setInt(i + 2, seats.get(i).getShowSeatId());
            }
            int changed = upd.executeUpdate();
            if (changed != seats.size()) {   // belt-and-suspenders
                conn.rollback();
                return false;
            }
        }

        // 3. INSERT the Booking row here, then commit.
        conn.commit();
        return true;

    } catch (SQLException e) {
        if (conn != null) {
            try { conn.rollback(); } catch (SQLException ignore) { /* log */ }
        }
        throw e;   // don't swallow it as "return false"
    } finally {
        if (conn != null) {
            try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignore) {}
        }
    }
}

Why the naive version is wrong, in one line: a plain SELECT that reads isReserved = 0 does not stop a concurrent transaction from reading the same 0, so the read-then-write window lets both commit; only an exclusive lock (FOR UPDATE) or a conflict-detecting isolation level (SERIALIZABLE, with retry) makes one of them lose.

Pitfalls a working engineer hits

When to use DB locking vs. the alternatives

The decision is about how often two people fight over the same seat and how long you must hold a seat before confirming.

Concretely: for a single-region cinema chain doing a few thousand bookings a day, the corrected JDBC above (pessimistic FOR UPDATE inside a short transaction, with a separate PENDING/held_until hold for the payment window) is the right default — correct, no extra infrastructure, and the contention is bounded by the size of one hall. Reach for Redis holds only when you outgrow that.

Takeaways


Re-authored and deepened for this guide. Class model adapted from Grokking the Object Oriented Design Interview (Design Online Movie Ticket Booking System). Concurrency mechanics from the PostgreSQL documentation on Explicit Locking and Transaction Isolation (SELECT ... FOR UPDATE, serialization failures), the MySQL InnoDB Locking and Transaction Model reference, and Martin Kleppmann, "Designing Data-Intensive Applications," ch. 7 (lost-update and write-skew anomalies). The original page's claim that a read takes a write lock has been corrected.

🔨 Practice this hands-on — Design a Movie Ticket Booking System →
Attempt it from an empty file, break it to feel the failure, then defend it under pushback.
🤖 Don't fully get this? Learn it with Claude

Stuck on Design a Movie Ticket Booking System? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Design a Movie Ticket Booking System** (OO & Low-Level Design) and want to truly understand it. Explain Design a Movie Ticket Booking System from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Design a Movie Ticket Booking System** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Design a Movie Ticket Booking System** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Design a Movie Ticket Booking System** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes