Skip to content
LLD Learn/Case Studies: Marketplace & Booking Systems
Browsing as a guest. Sign in to save your progress and earn XP as you complete chapters.

Design a Movie Ticket Booking System

10 min read

You'll learn to

  • -Model theaters, shows, seats, and bookings, and prevent two users from ever booking the same seat concurrently
  • -Use State to represent seat lifecycle (available, locked, booked) with a time-boxed hold before payment

This prompt combines the concurrency-sensitive resource assignment from the Parking Lot case study with a genuinely new wrinkle: a seat needs to be temporarily held during the payment flow, not just instantly claimed - if it were instant-claim like a parking spot, a user who abandons checkout would permanently lock a seat no one else could ever book.

Step 1: Core Entities

Theater, Show, and Seat
class Seat:
    def __init__(self, seat_id: str):
        self.seat_id = seat_id
        self.state: "SeatState" = AvailableState()

class Show:
    def __init__(self, show_id: str, movie: str, seats: list[Seat]):
        self.show_id = show_id
        self.movie = movie
        self.seats = {s.seat_id: s for s in seats}

Step 2: Seat Lifecycle as State

A seat moves through Available -> Locked (held during checkout, with an expiry) -> Booked (payment confirmed), or back to Available if the hold expires or checkout is cancelled - exactly the state-machine shape from the State pattern chapter, with the added wrinkle of a time-based automatic transition.

Seat states, including the time-boxed lock
import time

class SeatState(ABC):
    @abstractmethod
    def lock(self, seat: Seat, user_id: str) -> bool: ...
    @abstractmethod
    def confirm(self, seat: Seat) -> bool: ...
    @abstractmethod
    def release(self, seat: Seat) -> None: ...

class AvailableState(SeatState):
    def lock(self, seat: Seat, user_id: str) -> bool:
        seat.state = LockedState(user_id, expires_at=time.time() + 300)  # 5-minute hold
        return True
    def confirm(self, seat: Seat) -> bool: return False
    def release(self, seat: Seat) -> None: pass

class LockedState(SeatState):
    def __init__(self, user_id: str, expires_at: float):
        self.user_id = user_id
        self.expires_at = expires_at

    def _check_expiry(self, seat: Seat) -> None:
        if time.time() > self.expires_at:
            seat.state = AvailableState()

    def lock(self, seat: Seat, user_id: str) -> bool:
        self._check_expiry(seat)
        return False                        # already locked (by this or another user)
    def confirm(self, seat: Seat) -> bool:
        self._check_expiry(seat)
        if isinstance(seat.state, LockedState):
            seat.state = BookedState()
            return True
        return False                        # lock expired before payment completed
    def release(self, seat: Seat) -> None:
        seat.state = AvailableState()

class BookedState(SeatState):
    def lock(self, seat: Seat, user_id: str) -> bool: return False
    def confirm(self, seat: Seat) -> bool: return False
    def release(self, seat: Seat) -> None: pass   # a real system: only via a cancellation flow

Step 3: Concurrency-Safe Locking

Just like the Parking Lot's atomic search-and-assign, locking a seat must be a single atomic step guarded by a lock - two users clicking the same seat simultaneously must never both succeed. The BookingService wraps the lock() call in a per-seat (or per-show) lock, exactly the pattern established in the Concurrency module.

Atomic seat locking, same shape as ParkingLevel.park()
import threading

class BookingService:
    def __init__(self):
        self._lock = threading.Lock()

    def hold_seat(self, show: Show, seat_id: str, user_id: str) -> bool:
        with self._lock:
            seat = show.seats[seat_id]
            return seat.state.lock(seat, user_id)

    def confirm_booking(self, show: Show, seat_id: str) -> bool:
        with self._lock:
            seat = show.seats[seat_id]
            return seat.state.confirm(seat)

Step 4: Expiring Abandoned Holds

The design above checks expiry lazily (only when lock() or confirm() is next called on that seat) rather than running a background timer per seat, which is a deliberate simplicity trade-off worth naming: it means a seat whose hold has technically expired might still show as "locked" to a status-check call until someone actually tries to interact with it, which is an acceptable trade-off for a first-pass design, with a background sweep as the natural follow-up if the interviewer pushes on it.

A subtle bug to watch for: LockedState.confirm() must re-check expiry before confirming, not just before locking - otherwise a payment that completes microseconds after the hold expired would incorrectly succeed on an already-released seat.

Interview Signal is part of Pro

See a real weak answer next to a real strong one for this exact topic.

Quiz is part of Pro

Test what you just read with a short quiz, and bank the XP.

ScaleDojo Logo
Initializing ScaleDojo