bigonotes
CS ConceptsLLD

Movie Ticket Booking System

Design movie seat booking with per-show seat state, all-or-nothing seat locking, payment, and per-show thread safety.

5 min readLast updated LLD

Movie ticket booking is a favorite concurrency problem: two people must never end up with the same seat. The key ideas are that seat availability belongs to a show, not to the auditorium, and that booking is two-phase: lock the seats, then pay, then confirm or release.

Requirements#

Scope it to one theater and the booking flow:

  1. A theater has auditoriums with a grid of seats (N rows × M seats); seats are REGULAR or VIP.
  2. A show is a movie in an auditorium at a start time, with prices per seat type.
  3. A user picks a show and one or more seats.
  4. Seats are locked before payment; on success they become booked, on failure they're released.
  5. Users can see available seats for a show.

Actor: the user.

Clarifications

  • One theater with a limited number of auditoriums.
  • Seat types affect price.
  • Failure cases: seat already locked or booked, invalid seat, payment failure.

Out of scope: UI, real payment (behind an interface), lock timeouts (see extensions).

Core entities#

The important split is between physical seats and their per-show status:

Entity Holds
Movie id, name, description
Seat id, seat type
Auditorium id, seats
Show id, movie, auditorium, time, prices, seat_status: seat_id → BookStatus, lock
BookStatus (enum) AVAILABLE, LOCKED, BOOKED
Ticket id, show, seats, status
Payment (interface) pay(amount) -> bool
BookingSystem shows; orchestrates lock → pay → confirm

Relationships#

Most links are plain associations; per-seat status is owned by the show:

From To Kind
Auditorium Seat Composition
Show Movie, Auditorium Association
Show per-seat status Composition: status exists only for that show
BookingSystem Show Holds the shows
Ticket Show, Seat Association

The same physical seat is AVAILABLE for the 7pm show and BOOKED for the 9pm show, which is why status lives on Show.

Key flows#

Booking happens in two phases, and both run under the show's lock.

lock_seats(show, seats): under the show's lock, check that every seat is AVAILABLE; if any isn't, fail without changing anything (all-or-nothing); otherwise mark them all LOCKED.

confirm_booking(show, seats, payment):

  1. Compute the total from the seat types.
  2. Charge the payment.
  3. On failure, release the seats.
  4. On success, mark them BOOKED (under the show's lock, verifying they're still LOCKED) and issue a ticket.

Concurrency: every read-then-write of seat_status happens inside Show's methods under a per-show threading.Lock. BookingSystem never touches seat_status directly, so no check-then-act race leaks out. Locking per show (not globally) lets different shows book in parallel.

Code#

The Show class owns all seat-status changes; BookingSystem only orchestrates:

import threading
import uuid
from abc import ABC, abstractmethod
from datetime import datetime
from enum import Enum
from typing import Dict, List, Optional


class BookStatus(Enum):
    AVAILABLE = "AVAILABLE"
    LOCKED = "LOCKED"
    BOOKED = "BOOKED"


class SeatType(Enum):
    REGULAR = "REGULAR"
    VIP = "VIP"


class Payment(ABC):
    @abstractmethod
    def pay(self, amount: float) -> bool:
        pass


class AlwaysSucceedsPayment(Payment):
    def pay(self, amount: float) -> bool:
        return True


class AlwaysFailsPayment(Payment):
    def pay(self, amount: float) -> bool:
        return False


class Movie:
    def __init__(self, movie_id: str, name: str, description: str):
        self.id = movie_id
        self.name = name
        self.description = description


class Seat:
    def __init__(self, seat_id: str, seat_type: SeatType):
        self.id = seat_id
        self.seat_type = seat_type


class Auditorium:
    def __init__(self, auditorium_id: str):
        self.id = auditorium_id
        self.seats: Dict[str, Seat] = {}

    def add_seat(self, seat: Seat) -> None:
        self.seats[seat.id] = seat


class Show:
    def __init__(self, show_id: str, movie: Movie, auditorium: Auditorium,
                 time: datetime, price: Dict[SeatType, float]):
        self.id = show_id
        self.movie = movie
        self.auditorium = auditorium
        self.time = time
        self.price = price
        self.seat_status: Dict[str, BookStatus] = {
            seat_id: BookStatus.AVAILABLE for seat_id in auditorium.seats
        }
        self._lock = threading.Lock()

    def lock_seats(self, seats: List[Seat]) -> bool:
        with self._lock:
            if any(self.seat_status.get(s.id) != BookStatus.AVAILABLE
                   for s in seats):
                return False  # all-or-nothing: change nothing
            for s in seats:
                self.seat_status[s.id] = BookStatus.LOCKED
            return True

    def mark_booked(self, seats: List[Seat]) -> bool:
        with self._lock:
            if any(self.seat_status.get(s.id) != BookStatus.LOCKED
                   for s in seats):
                return False
            for s in seats:
                self.seat_status[s.id] = BookStatus.BOOKED
            return True

    def release(self, seats: List[Seat]) -> None:
        with self._lock:
            for s in seats:
                # Only release locks: never un-book a sold seat
                if self.seat_status.get(s.id) == BookStatus.LOCKED:
                    self.seat_status[s.id] = BookStatus.AVAILABLE

    def available_seats(self) -> List[Seat]:
        with self._lock:
            return [self.auditorium.seats[sid]
                    for sid, status in self.seat_status.items()
                    if status == BookStatus.AVAILABLE]


class Ticket:
    def __init__(self, show: Show, seats: List[Seat]):
        self.id = str(uuid.uuid4())
        self.show = show
        self.seats = seats
        self.status = BookStatus.BOOKED


class BookingSystem:
    def __init__(self, shows: Dict[str, Show]):
        self.shows = shows

    def lock_seats(self, show_id: str, seats: List[Seat]) -> bool:
        show = self.shows.get(show_id)
        return show is not None and show.lock_seats(seats)

    def confirm_booking(self, show_id: str, seats: List[Seat],
                        payment: Payment) -> Optional[Ticket]:
        show = self.shows.get(show_id)
        if show is None:
            return None

        amount = sum(show.price[s.seat_type] for s in seats)
        if not payment.pay(amount):
            show.release(seats)  # payment failed: free the locks
            return None

        # The LOCKED check happens under the show's lock
        if not show.mark_booked(seats):
            return None  # see trade-offs: would need a refund here
        return Ticket(show, seats)

    def available_seats(self, show_id: str) -> List[Seat]:
        show = self.shows.get(show_id)
        return show.available_seats() if show else []


if __name__ == "__main__":
    aud = Auditorium("A1")
    for sid in ["r1", "r2", "r3"]:
        aud.add_seat(Seat(sid, SeatType.REGULAR))

    movie = Movie("m1", "Dune", "sci-fi")
    prices = {SeatType.REGULAR: 12.0, SeatType.VIP: 20.0}
    show = Show("s1", movie, aud, datetime.now(), prices)
    system = BookingSystem({"s1": show})
    seats = [aud.seats["r1"], aud.seats["r2"]]

    def free():
        return [s.id for s in system.available_seats("s1")]

    print("available:", free())                           # r1 r2 r3
    print("lock:", system.lock_seats("s1", seats))        # True
    print("lock again:", system.lock_seats("s1", seats))  # False
    failed = system.confirm_booking("s1", seats, AlwaysFailsPayment())
    print("confirm (fail):", failed)                      # None
    print("after fail:", free())                          # r1 r2 r3

    system.lock_seats("s1", seats)
    ticket = system.confirm_booking("s1", seats, AlwaysSucceedsPayment())
    print("ticket issued:", ticket is not None)           # True
    print("after book:", free())                          # r3

Extensions and trade-offs#

This version is deliberately simple. The follow-ups expose its gaps:

  • Lock ownership: right now anyone can confirm or release any locked seat. Return a hold_id from lock_seats, store seat → hold_id, and require it on confirm and release.
  • Lock expiry: a user who abandons checkout holds seats forever. Store locked_until and treat expired locks as AVAILABLE (lazily on read, or with a sweeper).
  • Pay-then-confirm gap: if mark_booked fails after payment succeeds (e.g. the lock expired), you've charged for nothing. Either refund in that branch, or verify the hold is still valid under the lock before charging and extend it during payment.
  • Pricing: move prices into a PricingStrategy (weekend, matinee, dynamic).
  • Seat layout: store seats as rows × cols to support "pick adjacent seats" queries.
  • Scaling out: with many servers, an in-process lock isn't enough. Use the database: a row per (show_id, seat_id) with a conditional update (UPDATE ... SET status='LOCKED' WHERE status='AVAILABLE') or SELECT ... FOR UPDATE, or a distributed lock with TTL.

Key takeaways#

  • Seat availability lives on the show, not the auditorium.
  • Two-phase booking: lock (all-or-nothing) → pay → book or release.
  • Keep every read-then-write of seat status inside one object, under one lock.
  • Lock per show so unrelated shows don't contend.
  • The follow-ups are lock ownership, expiry, the pay/confirm gap, and database-level locking.

Found a mistake? Tell me on LinkedIn ↗

Further reading#