Skip to main content
Contents10 / 15 ▾
10
Medium · 5 of 5

BookMyShow: Seat Locking Under Concurrency

A City contains Theatres, each Theatre its Screens, each Screen its Shows, each Show its Seat map — a five-level containment tree that is almost incidental to the real test: one atomic, timed lock that lets a user hold a set of seats just long enough to pay, while guaranteeing that two users can never walk away with the same seat.

10.1Problem Statement

Design the booking subsystem for a movie-ticketing platform: a user browses shows by city and movie, selects seats, and books them with payment. It reads like a straightforward CRUD-over-a-hierarchy exercise until the second sentence of the constraints: seat locking must be atomic, and a lock expires. That single requirement is the whole interview.

Two users must never book the same seat. Everything else — the City→Theatre→Screen→Show hierarchy, the payment outcome, the notifications — is scaffolding around one atomic operation: claiming a set of seats, for a user, with a deadline.

Functional requirements:

  • A City has Theatres; each Theatre has Screens; each Screen runs Shows of Movies at given times.
  • A Show has a seat map; each seat has a status: AVAILABLE / LOCKED / BOOKED.
  • A user can search shows by city + movie, select seats, and book them.
  • Booking flow: lock selected seats → take payment → confirm (mark BOOKED) or release on timeout/failure.

Non-functional constraints, which are the actual point of the exercise:

  • Concurrency: seat locking must be atomic; a lock has a timeout after which seats auto-release.
  • Observer for notifications (booking confirmed, payment failed, seats released).
  • Payment as a small State machine: PENDING → SUCCESS / FAILED, with the booking reacting accordingly.

The expected public interface, given up front so the design effort goes into the locking semantics, not the signatures:

enum class SeatStatus { AVAILABLE, LOCKED, BOOKED };

class SeatLockProvider {              // the concurrency core
public:
    bool lockSeats(const Show& show, const std::vector<Seat>& seats,
                   const std::string& userId);          // atomic; false if any taken
    void unlockSeats(const Show& show, const std::vector<Seat>& seats,
                     const std::string& userId);
    bool validateLock(const Show&, const Seat&, const std::string& userId) const;
};

class BookingService {
public:
    std::vector<Show> searchShows(const std::string& city, const std::string& movie);
    Booking createBooking(const std::string& userId, const Show& show,
                          const std::vector<Seat>& seats);   // locks seats
    Booking confirmBooking(const Booking& booking, const Payment& payment);
};

What the interviewer is really testing:

  • The atomic seat-lock with timeout — this is the make-or-break detail. A solution where two threads can both succeed on the last seat is a fail, full stop.
  • A clean entity hierarchy (City → Theatre → Screen → Show → Seat) kept separate from the two services that hold the actual behavior.
  • Observer wired for notifications, and the payment state handled explicitly rather than folded into a boolean.

Follow-up questions to expect once the base design stands:

  • Distributed locking if the service is horizontally scaled — discuss it, even if you implement in-memory.
  • Dynamic / tiered pricing (recliner vs. regular, weekend surge) via a pricing strategy.
  • Waitlist and auto-allocation when seats release.
  • Idempotent booking under client retries.

10.2How to Approach

Finding the Nouns: Data vs. Behaviour

Mine the hard constraints before drawing a single class. "Two users must never book the same seat" names the one thing being tested — everything else is scaffolding around one atomic operation. "A lock has a timeout after which seats auto-release" tells you locking isn't a boolean flag; it's an all-or-nothing claim over multiple seats, owned by a user, that expires. That phrasing alone screams one mutex-guarded provider plus a per-lock expiry timestamp.

Circle the remaining nouns — city, theatre, screen, show, movie, seat, lock, booking, payment, user, notification — and they split cleanly in two: a containment hierarchy (City → Theatre → Screen → Show → Seat, almost pure data) and three services that hold all the behavior (SeatLockProvider, BookingService, and the BookingObserver family).

Find the one sentence the whole problem pivots on — here, "never book the same seat." Design that core airtight first; the rest is plumbing around it.

Pinning the Interface Before Internals

The statement hands us the shape; lock it before internals, because it forces the hard decision — what "atomic lock" actually returns — to the front. lockSeats is all-or-nothing: it returns bool, and on failure it must leave the world untouched, no partial locks. That single guarantee is what prevents double-booking. createBooking locks but does not confirm — locking and paying are separate phases, and a lock is a reservation with a deadline, not a sale. validateLock is the gate before confirming, because payment success means nothing if the lock already expired and someone else grabbed the seat.

Modeling the Leaves: Seat, Show, and the Timed Lock

Build bottom-up: dependency-free types first. Movie, Seat, and Show are almost pure data — a seat map is just a vector of seats hanging off a show. The interesting leaf is SeatLock, which carries the timeout the problem demanded: "these seats, held by this user, valid until this instant."

The moment a spec says "timeout / TTL / expires," store an absolute expiry instant, not a duration or a countdown. now() > expiry is the simplest, race-free expiry check there is — no clock arithmetic, no drift between "how much time is left" and "is it still valid right now."

The Key Insight: Atomic Check-Then-Set Under One Mutex

This is the heart of the problem, and it deserves real thought. A naive design marks each seat LOCKED one at a time. Two threads booking seats {A1, A2} and {A2, A3} can interleave: thread 1 locks A1, thread 2 locks A2 and A3, thread 1 tries A2 and fails — but it already locked A1, so it must roll back. Worse, without a lock around the whole check-then-set, both threads can read "A2 available" before either writes.

A naive per-seat locking loop lets two threads both pass the "is it free?" check for the same seat before either one writes its claim — the classic double-booking race. The fix is not a smarter data structure; it's making "check every requested seat, then claim every requested seat" one indivisible operation. Verify-all-then-claim-all inside a single critical section. Mutate before you've verified the whole set, and you've created a partial-failure rollback problem by design — avoid it instead of handling it.

The fix is three rules: (1) one owner of all locks — SeatLockProvider holds the single source of truth, a map from showId to seatId → SeatLock; nobody else mutates seat-claim state. (2) guard check-then-set with a mutex — no window between checking and claiming. (3) all-or-nothing — inside the lock, first verify every requested seat is free (treating expired locks as free), then claim them all; if any check fails, claim nothing. A locked seat is free again the instant its lock expires, so the timeout is enforced lazily, right inside the availability check — no background sweeper is required for correctness, only to reclaim memory eventually.

Payment and Booking: Match Modeling Weight to the Requirement

The problem says payment is a small state machine, PENDING → SUCCESS / FAILED. That doesn't need a full polymorphic State pattern — three enum values plus the booking's reaction to them is the right altitude. A SUCCESS confirms (seats become permanently booked); a FAILED, or a lock that turned out to have expired, cancels and releases. Reserve heavyweight State classes for genuinely behaviour-rich modes, like a vending machine's multi-step session — a three-outcome payment is honestly served by an enum and a switch.

Observer for Notifications, and Orchestrating the Whole Flow

Booking events — confirmed, payment failed, seats released — need to fan out to subscribers (email, SMS, a waitlist trigger) without the service knowing who's listening. Observer earns its keep exactly here: the list of reactions grows independently of the action, so the service publishes one event and stays oblivious to who reacts.

BookingService is the conductor, and it follows a validate → mutate → transition rhythm with the dangerous part delegated entirely to the lock provider. createBooking locks the seats and records a CREATED booking, or throws if the lock fails. confirmBooking re-validates the lock is still ours for every seat — because payment success is necessary but not sufficient if the hold already expired — then branches on the payment outcome to confirm or release, publishing the matching event either way. The lock provider is the only class that needs to be thread-safe; the service stays a thin, boring coordinator, which is exactly where you want the complexity to not be.

A correct driver for this problem doesn't just show the happy path — it attempts the race. Spin up two threads booking the same seats and assert exactly one wins. That assertion, not a green checkmark on a single-threaded run, is the proof an interviewer is looking for.

Follow-Ups: Naming the Seams

A strong finish names the extension points instead of coding all of them. Distributed locking: the in-memory mutex only protects one process; swap the provider's internals for a shared atomic store — Redis SET seat:<show>:<id> <user> NX EX <ttl> gives all-or-nothing via NX and the timeout for free via EX. The interface doesn't change, only the backend behind it. Tiered pricing: extract a PricingStrategy interface and inject it — recliner vs. regular, weekend surge, each a strategy implementation, orthogonal to the lock logic. Waitlist: already an Observer payoff — a WaitlistObserver reacts to SEATS_RELEASED by offering the freed seats to the next queued user. Idempotent retries: have the client pass an idempotency key, and have the service keep a key → Booking map so a retried request returns the existing booking instead of creating a second one.

10.3Whole Solution

The reference solution below is a single, self-contained program with its own main(). It merges every incremental fragment from the walkthrough into one internally consistent design: the entity hierarchy, the mutex-guarded SeatLockProvider with lazy timeout expiry, the enum-driven payment/booking lifecycle, the BookingObserver fan-out, and a BookingService that orchestrates all of it. The driver proves the design with the same race an interviewer would ask for — two threads booking the same seats, exactly one winner — followed by a successful-payment path and a failed-payment path.

#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
#include <mutex>
#include <chrono>
#include <thread>
#include <atomic>
#include <stdexcept>

// ---------------------------------------------------------------------
// Seat status as seen from outside. In this single-process demo a seat's
// AVAILABLE / LOCKED state is derived lazily from SeatLockProvider's lock
// table rather than stored per seat -- a seat becomes AVAILABLE again the
// instant its lock expires, with no background sweeper required for
// correctness (see SeatLockProvider below). BOOKED is recorded on the
// Booking, not mutated back onto the Seat, to keep this demo's data model
// small; a production system would flip a stored SeatStatus too.
// ---------------------------------------------------------------------
enum class SeatStatus { AVAILABLE, LOCKED, BOOKED };

// ---------------------------------------------------------------------
// Data hierarchy: dumb structs, no behaviour. City -> Theatre -> Screen
// -> Show -> Seat is a containment tree used for browsing; a Show is also
// addressable directly by id, which is all BookingService needs.
// ---------------------------------------------------------------------
struct Movie {
    std::string id;
    std::string title;
};

enum class SeatType { REGULAR, PREMIUM, RECLINER };

struct Seat {
    std::string id;        // e.g. "A12" -- unique within a show's seat map
    int row = 0, col = 0;
    SeatType type = SeatType::REGULAR;
    int price = 0;         // base price; tiering is a follow-up (Strategy)
};

struct Show {
    std::string id;
    Movie movie;
    std::string screenId;
    std::string city;
    long long startTime = 0;   // epoch seconds
    std::vector<Seat> seatMap;
};

struct Screen {
    std::string id;
    std::string name;
    std::vector<std::string> showIds;      // shows scheduled on this screen
};

struct Theatre {
    std::string id;
    std::string name;
    std::vector<Screen> screens;
};

struct City {
    std::string name;
    std::vector<Theatre> theatres;
};

// ---------------------------------------------------------------------
// SeatLock: one timed, user-owned claim over a set of seats. The moment a
// spec says "timeout / TTL / expires", store an absolute expiry instant --
// now() > expiry is the simplest, race-free expiry check.
// ---------------------------------------------------------------------
class SeatLock {
public:
    SeatLock(std::vector<std::string> seatIds, std::string userId,
             std::chrono::seconds ttl)
        : seatIds_(std::move(seatIds)), userId_(std::move(userId)),
          expiry_(std::chrono::steady_clock::now() + ttl) {}

    bool isExpired() const { return std::chrono::steady_clock::now() > expiry_; }
    const std::string& userId() const { return userId_; }
    const std::vector<std::string>& seatIds() const { return seatIds_; }

private:
    std::vector<std::string> seatIds_;
    std::string userId_;
    std::chrono::steady_clock::time_point expiry_;
};

// ---------------------------------------------------------------------
// SeatLockProvider: the concurrency core, and the only class in this whole
// program that needs to be thread-safe. One mutex-guarded map is the
// single source of truth for every seat claim.
// ---------------------------------------------------------------------
class SeatLockProvider {
public:
    explicit SeatLockProvider(std::chrono::seconds ttl) : ttl_(ttl) {}

    // Atomic: locks ALL requested seats or NONE. Returns false if any seat
    // is already held by a non-expired lock -- and on false, nothing is
    // mutated (no partial locks, no rollback needed).
    bool lockSeats(const Show& show, const std::vector<Seat>& seats,
                   const std::string& userId) {
        std::lock_guard<std::mutex> guard(mutex_);      // ONE critical section
        auto& showLocks = locks_[show.id];

        // Phase 1: verify EVERY seat is free (or held by an expired lock).
        for (const Seat& s : seats) {
            auto it = showLocks.find(s.id);
            if (it != showLocks.end() && !it->second.isExpired())
                return false;                            // taken -> claim nothing
        }

        // Phase 2: claim them all. Still holding the mutex, so no other
        // thread could have slipped in between phase 1 and phase 2.
        for (const Seat& s : seats)
            showLocks.insert_or_assign(s.id, SeatLock({s.id}, userId, ttl_));
        return true;
    }

    void unlockSeats(const Show& show, const std::vector<Seat>& seats,
                      const std::string& userId) {
        std::lock_guard<std::mutex> guard(mutex_);
        auto& showLocks = locks_[show.id];
        for (const Seat& s : seats) {
            auto it = showLocks.find(s.id);
            if (it != showLocks.end() && it->second.userId() == userId)
                showLocks.erase(it);                      // only the owner may unlock
        }
    }

    bool validateLock(const Show& show, const Seat& seat,
                       const std::string& userId) const {
        std::lock_guard<std::mutex> guard(mutex_);
        auto showIt = locks_.find(show.id);
        if (showIt == locks_.end()) return false;
        auto it = showIt->second.find(seat.id);
        return it != showIt->second.end()
            && !it->second.isExpired()
            && it->second.userId() == userId;              // still mine, still valid
    }

private:
    mutable std::mutex mutex_;                              // mutable: const validateLock locks too
    std::chrono::seconds ttl_;
    std::unordered_map<std::string,
        std::unordered_map<std::string, SeatLock>> locks_;  // showId -> seatId -> lock
};

// ---------------------------------------------------------------------
// Payment: an enum plus a switch in BookingService, not a polymorphic
// State hierarchy -- a three-outcome state machine doesn't earn one.
// ---------------------------------------------------------------------
enum class PaymentStatus { PENDING, SUCCESS, FAILED };

struct Payment {
    std::string id;
    int amount = 0;
    PaymentStatus status = PaymentStatus::PENDING;
};

enum class BookingStatus { CREATED, CONFIRMED, CANCELLED };

struct Booking {
    std::string id;
    std::string userId;
    Show show;
    std::vector<Seat> seats;
    BookingStatus status = BookingStatus::CREATED;
};

// ---------------------------------------------------------------------
// Observer: booking events fan out to subscribers without BookingService
// knowing who is listening. Email today; SMS and a waitlist trigger
// tomorrow, with zero changes to the publisher.
// ---------------------------------------------------------------------
enum class BookingEvent { CONFIRMED, PAYMENT_FAILED, SEATS_RELEASED };

class BookingObserver {
public:
    virtual ~BookingObserver() = default;
    virtual void onEvent(BookingEvent event, const Booking& booking) = 0;
};

class EmailNotifier : public BookingObserver {
public:
    void onEvent(BookingEvent event, const Booking& booking) override {
        std::cout << "[email] " << booking.userId
                  << " event=" << static_cast<int>(event)
                  << " booking=" << booking.id << "\n";
    }
};

// ---------------------------------------------------------------------
// BookingService: the conductor. It orchestrates lock -> pay -> confirm
// or release, and stays deliberately boring -- the one tricky invariant
// (no double-booking) is entirely inside SeatLockProvider.
// ---------------------------------------------------------------------
class BookingService {
public:
    BookingService(SeatLockProvider& lockProvider,
                   std::vector<BookingObserver*> observers)
        : lockProvider_(lockProvider), observers_(std::move(observers)) {}

    void addShow(const Show& s) { shows_.push_back(s); }

    std::vector<Show> searchShows(const std::string& city,
                                   const std::string& movie) {
        std::vector<Show> out;
        for (const Show& s : shows_)
            if (s.city == city && s.movie.title == movie)
                out.push_back(s);
        return out;
    }

    // Locks the seats (atomic); does NOT charge or confirm. A lock is a
    // reservation with a deadline, not a sale. Throws if someone else
    // already holds at least one of the requested seats.
    Booking createBooking(const std::string& userId, const Show& show,
                           const std::vector<Seat>& seats) {
        if (!lockProvider_.lockSeats(show, seats, userId))
            throw std::runtime_error("seats unavailable");

        Booking b;
        b.id     = nextBookingId();
        b.userId = userId;
        b.show   = show;
        b.seats  = seats;
        b.status = BookingStatus::CREATED;      // locked, awaiting payment
        return b;
    }

    // Re-validates the lock (payment success means nothing if the hold
    // already expired and someone else grabbed the seat), then branches
    // on the payment outcome.
    Booking confirmBooking(const Booking& booking, const Payment& payment) {
        Booking b = booking;

        for (const Seat& s : b.seats) {
            if (!lockProvider_.validateLock(b.show, s, b.userId)) {
                b.status = BookingStatus::CANCELLED;
                notify(BookingEvent::SEATS_RELEASED, b);    // lock expired under us
                return b;
            }
        }

        if (payment.status == PaymentStatus::SUCCESS) {
            b.status = BookingStatus::CONFIRMED;            // seats become BOOKED
            notify(BookingEvent::CONFIRMED, b);
        } else {                                             // FAILED or still PENDING
            lockProvider_.unlockSeats(b.show, b.seats, b.userId);
            b.status = BookingStatus::CANCELLED;
            notify(BookingEvent::PAYMENT_FAILED, b);
        }
        return b;
    }

private:
    void notify(BookingEvent event, const Booking& booking) {
        for (BookingObserver* obs : observers_)
            obs->onEvent(event, booking);
    }
    std::string nextBookingId() { return "BK" + std::to_string(++counter_); }

    SeatLockProvider& lockProvider_;
    std::vector<BookingObserver*> observers_;
    std::vector<Show> shows_;
    int counter_ = 0;
};

// ---------------------------------------------------------------------
// Driver: happy path, failed-payment path, and -- the point of this
// chapter -- two threads racing for the same seats, asserting exactly
// one winner. That assertion is the proof the design actually works.
// ---------------------------------------------------------------------
int main() {
    SeatLockProvider lockProvider(std::chrono::seconds(5));   // 5s hold
    EmailNotifier email;
    BookingService service(lockProvider, {&email});

    Show show;
    show.id = "S1";
    show.city = "NYC";
    show.movie = {"M1", "Inception"};
    show.seatMap = { {"A1"}, {"A2"}, {"A3"}, {"B1"} };
    service.addShow(show);

    std::vector<Seat> wanted = { {"A1"}, {"A2"} };

    // Two users race for the SAME seats. The mutex inside SeatLockProvider
    // serializes their check-then-claim, so exactly one can win.
    std::atomic<int> wins{0};
    auto attempt = [&](const std::string& user) {
        try { service.createBooking(user, show, wanted); ++wins; }
        catch (const std::exception&) { /* lost the race, expected */ }
    };
    std::thread t1(attempt, "alice");
    std::thread t2(attempt, "bob");
    t1.join(); t2.join();
    std::cout << "winners: " << wins.load() << "\n";           // exactly 1

    // Successful payment path: lock -> pay -> confirm.
    Booking b = service.createBooking("carol", show, { {"A3"} });
    Payment ok{"P1", 250, PaymentStatus::SUCCESS};
    Booking done = service.confirmBooking(b, ok);
    std::cout << "carol booking confirmed="
              << (done.status == BookingStatus::CONFIRMED) << "\n";

    // Failed payment path: lock -> pay fails -> seats released, event fired.
    Booking b2 = service.createBooking("dave", show, { {"B1"} });
    Payment bad{"P2", 250, PaymentStatus::FAILED};
    Booking done2 = service.confirmBooking(b2, bad);
    std::cout << "dave booking cancelled="
              << (done2.status == BookingStatus::CANCELLED) << "\n";

    return 0;
}
How would you make seat locking work across multiple horizontally-scaled service instances?

The in-memory std::mutex only protects one process, so it can't be the answer once there's more than one instance of the booking service. Swap SeatLockProvider's internals for a shared atomic store — Redis's SET seat:<show>:<id> <user> NX EX <ttl> is the textbook move: NX ("set if not exists") gives the all-or-nothing claim, and EX gives the timeout for free, enforced by Redis itself instead of a lazy check. The important part of the answer is that the interface — lockSeats / unlockSeats / validateLock — doesn't change at all; only the implementation behind it moves from a hashmap to a shared store. That's the general shape every distributed-locking follow-up wants to hear: same contract, new backend.

The interviewer asks for tiered pricing — recliner vs. regular, weekend surge. How do you extend the design without touching the lock?

Extract a PricingStrategy interface with one method, something like int priceFor(const Show&, const Seat&), and inject it into BookingService. Recliner-vs-regular, weekend surge, demand-based pricing — each becomes a strategy implementation, swapped in without touching SeatLockProvider or the booking lifecycle at all. This is the Strategy pattern doing exactly what it's for: pricing is orthogonal to concurrency, so the two should never share a class.

A client's network drops right after they click "pay," and their app retries the booking call. How do you avoid a duplicate booking?

Have the client attach an idempotency key to the request, and have BookingService keep a key → Booking map. On a retry with the same key, return the existing booking instead of creating a second one and locking the seats twice. Notice the lock provider is already close to idempotent for the same user re-locking their own seats — insert_or_assign with the same owner is a no-op in effect — but the idempotency key is still needed at the service layer so a retried createBooking doesn't mint a second Booking record with a new id.