LLD: Movie Ticket Booking
Cinemas, shows, seat maps, and the hard part — holding seats for a few minutes so two people can never buy the same one.
Big idea
A ticket-booking system is mostly catalogue (cinemas, screens, movies, shows) plus one genuinely hard problem: two people must never book the same seat. The standard answer is a temporary hold: selecting seats atomically locks them for a few minutes while the user pays; payment confirms the hold into a booking; timeout or failure releases it. Seats move through a small state machine, and every transition is checked.
Requirements
Functional
- Browse movies and shows by city/cinema/time; see a show's seat map.
- Select seats → they're held for the user for N minutes.
- Pay → booking confirmed, tickets issued. Payment failure or timeout → seats released.
- Seat categories with different prices (regular, premium).
Non-functional: no double booking under heavy concurrency (a popular show opening); holds expire reliably; seat map reads are fast.
Out of scope: payment provider details, refunds/cancellations (extensions).
Entities
Movie, Cinema, Screen (seat layout), Show (movie + screen + start time; owns the seat states), Seat (row, number, category), SeatState (AVAILABLE, HELD, BOOKED), Hold (user, seats, expires_at), Booking, PricingPolicy, BookingService (the use cases), Clock.
Think of it like
A restaurant's waiting list with a pager. When a table frees up, the host holds it for you and buzzes your pager; you have five minutes to walk over. If you don't show, the table goes to the next party. Nobody else can be seated there while the hold is on — but the hold doesn't last forever either.
Class diagram
Key flow: two users race for the same seat
Code skeleton
import threading
class Show:
def __init__(self, sid, seats):
self.id = sid
self.state = {s: "available" for s in seats} # seat -> available / hold id / booked
self.lock = threading.Lock() # one lock per show
def try_hold(self, seats, hold_id):
with self.lock: # check-and-set must be atomic
if any(self.state.get(s) != "available" for s in seats):
return False
for s in seats: # all-or-nothing
self.state[s] = hold_id
return True
def release(self, hold_id):
with self.lock:
for s, v in self.state.items():
if v == hold_id:
self.state[s] = "available"
def book(self, hold_id):
with self.lock:
for s, v in self.state.items():
if v == hold_id:
self.state[s] = "booked"
class BookingService:
HOLD_MINUTES = 5
PRICES = {"F": 12, "A": 20} # row letter -> price (a PricingPolicy in full)
def __init__(self, shows, clock):
self.shows, self.clock, self.holds, self.n = shows, clock, {}, 0
def hold(self, show_id, user, seats):
self.n += 1
hold_id = f"H{self.n}"
if not self.shows[show_id].try_hold(seats, hold_id):
return f"{user}: seats {seats} not available"
self.holds[hold_id] = (show_id, user, seats, self.clock() + self.HOLD_MINUTES)
return f"{user}: holding {seats} as {hold_id} until t={self.clock() + self.HOLD_MINUTES}"
def confirm(self, hold_id, paid):
if hold_id not in self.holds:
return f"{hold_id}: expired or unknown"
show_id, user, seats, expires = self.holds.pop(hold_id)
if not paid:
self.shows[show_id].release(hold_id)
return f"{user}: payment failed, seats released"
self.shows[show_id].book(hold_id)
return f"{user}: booked {seats} for {sum(self.PRICES[s[0]] for s in seats)}"
def expire_holds(self): # run every minute by a scheduler
for hold_id, (show_id, user, seats, expires) in list(self.holds.items()):
if self.clock() >= expires:
del self.holds[hold_id]
self.shows[show_id].release(hold_id)
print(f" t={self.clock()}: {hold_id} for {user} expired")
now = [0]
svc = BookingService({"S1": Show("S1", ["F7", "F8", "F9", "A1"])}, clock=lambda: now[0])
print(svc.hold("S1", "ana", ["F7", "F8"]))
print(svc.hold("S1", "ben", ["F8", "F9"])) # loses the race for F8
print(svc.hold("S1", "ben", ["F9", "A1"]))
now[0] = 6
svc.expire_holds() # ana didn't pay in time
print(svc.confirm("H1", paid=True))
print(svc.hold("S1", "cy", ["F7", "F8"]))
print(svc.confirm("H4", paid=True))
print("seat map:", svc.shows["S1"].state)output
ana: holding ['F7', 'F8'] as H1 until t=5
ben: seats ['F8', 'F9'] not available
ben: holding ['F9', 'A1'] as H3 until t=5
t=6: H1 for ana expired
t=6: H3 for ben expired
H1: expired or unknown
cy: holding ['F7', 'F8'] as H4 until t=11
cy: booked ['F7', 'F8'] for 24
seat map: {'F7': 'booked', 'F8': 'booked', 'F9': 'available', 'A1': 'available'}Run and edit it in the app.
Ben's second hold (H3) was never confirmed, so it also expired at t=6 — holds must always end, one way or another.
Watch it
Why must "check the seats are free" and "mark them held" happen in one atomic step?
A user selects seats F7 and F8, but F8 was just taken. What should hold() do?
Extensions and trade-offs
- Database-level locking — in production the seat state lives in a database: conditional updates or
SELECT … FOR UPDATEper show; a unique constraint on (show, seat) for bookings is the final safety net. - Expiry — a scheduled job, or a key with a TTL in a cache; also check expiry on confirm (a late payment must not book a released seat).
- Hot shows — queue users before the seat map opens; shard by show.
- Pricing — weekend, premium, dynamic → a
PricingPolicyStrategy. - Cancellations and refunds — a booking state machine of its own (State).
- See also Idempotency and retries for safe payment confirmation.
Key terms
- Seat hold — A temporary, expiring reservation that blocks others while a user pays.
- Double booking — Two confirmed bookings for the same seat; the failure the design must make impossible.
- Check-then-act race — A bug where state changes between checking a condition and acting on it.
- All-or-nothing — A multi-seat request either reserves every seat or none.