Low-Level Design: Movie Ticket Booking System
The Movie Ticket Booking system (like BookMyShow or Fandango) is a popular LLD interview question that covers seat reservation races, payment flow, and time-bound holds. It combines OOP design with concurrency challenges.
Requirements
- Browse movies and showtimes at different theaters
- View seat map and select seats
- Hold selected seats temporarily (10-minute reservation window)
- Complete booking with payment
- Cancel booking (seat released back to inventory)
- Support different seat categories: Regular, Premium, Recliner
Core Classes
Movie, Theater, and Showtime
from enum import Enum
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional
import threading
import uuid
class SeatCategory(Enum):
REGULAR = "regular"
PREMIUM = "premium"
RECLINER = "recliner"
class SeatStatus(Enum):
AVAILABLE = "available"
HELD = "held" # Temporarily reserved; expires after timeout
BOOKED = "booked" # Payment confirmed
@dataclass
class Movie:
movie_id: str
title: str
duration_minutes: int
genre: str
rating: str # PG, PG-13, R, etc.
language: str = "English"
@dataclass
class Theater:
theater_id: str
name: str
location: str
@dataclass
class Seat:
seat_id: str
row: str # A, B, C...
number: int # 1, 2, 3...
category: SeatCategory
price: float
status: SeatStatus = SeatStatus.AVAILABLE
held_by: Optional[str] = None # user_id
held_until: Optional[datetime] = None
booked_by: Optional[str] = None
def __str__(self):
return f"{self.row}{self.number}"
def is_available(self) -> bool:
if self.status == SeatStatus.AVAILABLE:
return True
if self.status == SeatStatus.HELD:
# Check if hold has expired
if self.held_until and datetime.utcnow() > self.held_until:
self.status = SeatStatus.AVAILABLE
self.held_by = None
self.held_until = None
return True
return False
@dataclass
class Auditorium:
auditorium_id: str
theater_id: str
name: str # Hall 1, IMAX, etc.
total_seats: int
seats: dict = field(default_factory=dict) # seat_id -> Seat
def add_seat(self, seat: Seat):
self.seats[seat.seat_id] = seat
def get_available_seats(self) -> list[Seat]:
return [s for s in self.seats.values() if s.is_available()]
def get_seat_map(self) -> dict:
"""Return seat status grid for display."""
rows = {}
for seat in self.seats.values():
if seat.row not in rows:
rows[seat.row] = {}
rows[seat.row][seat.number] = {
'seat_id': seat.seat_id,
'category': seat.category.value,
'price': seat.price,
'status': 'available' if seat.is_available() else seat.status.value,
}
return rows
@dataclass
class Showtime:
showtime_id: str
movie: Movie
auditorium: Auditorium
theater: Theater
start_time: datetime
end_time: datetime
_lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
@property
def available_seats(self) -> list[Seat]:
return self.auditorium.get_available_seats()
Booking and Hold Management
HOLD_DURATION_MINUTES = 10
@dataclass
class Booking:
booking_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8].upper())
user_id: str = ""
showtime_id: str = ""
seat_ids: list[str] = field(default_factory=list)
total_amount: float = 0.0
status: str = "confirmed" # confirmed, cancelled
created_at: datetime = field(default_factory=datetime.utcnow)
payment_ref: str = ""
class BookingService:
def __init__(self):
self._showtimes: dict[str, Showtime] = {}
def add_showtime(self, showtime: Showtime):
self._showtimes[showtime.showtime_id] = showtime
def hold_seats(self, user_id: str, showtime_id: str,
seat_ids: list[str]) -> dict:
"""
Atomically hold multiple seats. All-or-nothing: if any seat
is unavailable, no seats are held.
"""
showtime = self._showtimes.get(showtime_id)
if not showtime:
raise ValueError("Showtime not found")
with showtime._lock:
# Validate all seats available before holding any
seats = []
for seat_id in seat_ids:
seat = showtime.auditorium.seats.get(seat_id)
if not seat:
raise ValueError(f"Seat {seat_id} not found")
if not seat.is_available():
raise ValueError(f"Seat {seat} is no longer available")
seats.append(seat)
# All available: hold them atomically
expiry = datetime.utcnow() + timedelta(minutes=HOLD_DURATION_MINUTES)
for seat in seats:
seat.status = SeatStatus.HELD
seat.held_by = user_id
seat.held_until = expiry
total = sum(s.price for s in seats)
return {
'hold_id': str(uuid.uuid4()),
'seat_ids': seat_ids,
'total': total,
'expires_at': expiry.isoformat(),
'payment_required_by': expiry.isoformat(),
}
def confirm_booking(self, user_id: str, showtime_id: str,
seat_ids: list[str], payment_ref: str) -> Booking:
showtime = self._showtimes[showtime_id]
with showtime._lock:
seats = []
for seat_id in seat_ids:
seat = showtime.auditorium.seats[seat_id]
if seat.status != SeatStatus.HELD or seat.held_by != user_id:
raise ValueError(f"Seat {seat} is not held by this user")
if seat.held_until and datetime.utcnow() > seat.held_until:
raise ValueError(f"Hold on seat {seat} has expired")
seats.append(seat)
# Process payment (real system: call payment gateway)
if not payment_service.charge(user_id, sum(s.price for s in seats), payment_ref):
raise ValueError("Payment failed")
# Confirm booking
for seat in seats:
seat.status = SeatStatus.BOOKED
seat.booked_by = user_id
seat.held_by = None
seat.held_until = None
booking = Booking(
user_id=user_id,
showtime_id=showtime_id,
seat_ids=seat_ids,
total_amount=sum(s.price for s in seats),
payment_ref=payment_ref,
)
bookings_db.save(booking)
return booking
def cancel_booking(self, booking_id: str, user_id: str) -> dict:
booking = bookings_db.get(booking_id)
if not booking or booking.user_id != user_id:
raise ValueError("Booking not found")
if booking.status == "cancelled":
raise ValueError("Already cancelled")
showtime = self._showtimes[booking.showtime_id]
with showtime._lock:
for seat_id in booking.seat_ids:
seat = showtime.auditorium.seats[seat_id]
seat.status = SeatStatus.AVAILABLE
seat.booked_by = None
booking.status = "cancelled"
bookings_db.save(booking)
# Refund (minus cancellation fee if applicable)
refund_amount = self._calculate_refund(booking, showtime.start_time)
payment_service.refund(booking.payment_ref, refund_amount)
return {'refund': refund_amount}
def _calculate_refund(self, booking: Booking, show_start: datetime) -> float:
hours_until_show = (show_start - datetime.utcnow()).total_seconds() / 3600
if hours_until_show > 24:
return booking.total_amount # Full refund
elif hours_until_show > 4:
return booking.total_amount * 0.5 # 50% refund
return 0.0 # No refund within 4 hours
Search and Discovery
class MovieSearchService:
def search_movies(self, city: str, date: str,
genre: str = None, language: str = None) -> list[dict]:
"""Find movies with available showtimes in a city on a date."""
results = []
for showtime in self._get_showtimes_for_city_date(city, date):
if genre and showtime.movie.genre != genre:
continue
if language and showtime.movie.language != language:
continue
available = len(showtime.available_seats)
if available > 0:
results.append({
'movie': showtime.movie.title,
'theater': showtime.theater.name,
'time': showtime.start_time.strftime("%H:%M"),
'available_seats': available,
'showtime_id': showtime.showtime_id,
'price_range': self._get_price_range(showtime),
})
return sorted(results, key=lambda x: x['time'])
def _get_price_range(self, showtime: Showtime) -> dict:
prices = [s.price for s in showtime.auditorium.seats.values()]
return {'min': min(prices), 'max': max(prices)}
Key Design Decisions
- All-or-nothing seat hold: Validate all requested seats before holding any. Under the showtime lock, prevents partial holds (user thinks they got seats 5A and 5B but only got 5A).
- Hold with expiry: Seats held for 10 minutes;
is_available()checks expiry lazily (no background cleanup needed for low-traffic cases). High-traffic: background job scans for expired holds. - Showtime-level locking: Lock per showtime (not per seat) ensures atomicity of multi-seat operations without deadlock risk. Coarser than per-seat locking but simpler and correct.
- Payment before confirmation: Seats remain HELD (not BOOKED) until payment succeeds. If payment fails, held seats auto-expire and return to inventory.
Scalability Considerations
- Database: Optimistic locking for seat status in PostgreSQL:
UPDATE seats SET status='held' WHERE seat_id=? AND status='available'; check rows_affected=1 - Distributed locks: For multi-server deployment, use Redis SETNX for showtime locks instead of in-process threading.Lock
- Popular shows: Queue users during high-demand ticket sales (Taylor Swift effect); virtual waiting room prevents thundering herd on database
- Seat map caching: Cache available seat count in Redis; invalidate on each booking/cancellation
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you handle concurrent seat selection in a movie booking system?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use a two-phase approach: (1) Seat hold — atomically mark requested seats as HELD under a showtime-level lock. Validate ALL requested seats are available before holding any (all-or-nothing). The hold has a 10-minute TTL; if payment is not completed, seats auto-expire back to AVAILABLE. (2) Booking confirmation — under the same lock, verify seats are still held by this user and the hold has not expired, then process payment and mark seats BOOKED.”}},{“@type”:”Question”,”name”:”How do you prevent two users from booking the same seat?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Two approaches: (1) In-process locking — use a per-showtime ReentrantLock/RLock; all seat operations for a showtime are serialized. Simple but single-server only. (2) Distributed locking — use Redis SETNX (“set if not exists”) for a showtime lock key; all servers participate. For database-backed systems, use optimistic locking: UPDATE seats SET status=’held’ WHERE seat_id=? AND status=’available’, then check rows_affected=1. If 0 rows updated, another user got the seat first.”}},{“@type”:”Question”,”name”:”How do you implement seat hold expiry in a movie booking system?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Two strategies: (1) Lazy expiry — check held_until timestamp inside is_available() on every access. No background job needed; expired holds are cleaned up on first access after expiry. Simple but can show seats as held to browsing users even after expiry. (2) Eager expiry — a background scheduler scans for holds past their expiry timestamp and resets them to AVAILABLE. More accurate seat availability display, but adds operational complexity. Use lazy for low-traffic, eager for high-traffic systems.”}},{“@type”:”Question”,”name”:”How would you scale a movie ticket booking system for high-demand releases?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”For blockbuster releases (Taylor Swift concerts, Star Wars premieres), implement a virtual waiting room: users are queued before the sale opens; the system releases them in batches to prevent database overwhelm. Use Redis counters for real-time seat counts (avoid DB reads for availability checks). Use Redis distributed locks instead of in-process locks. Apply database sharding by movie/showtime to distribute write load. Consider eventual consistency for the seat map display while maintaining strong consistency for the actual booking transaction.”}}]}
🏢 Asked at: Uber Interview Guide 2026: Dispatch Systems, Geospatial Algorithms, and Marketplace Engineering
🏢 Asked at: Airbnb Interview Guide 2026: Search Systems, Trust and Safety, and Full-Stack Engineering
🏢 Asked at: Shopify Interview Guide
🏢 Asked at: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems
🏢 Asked at: Lyft Interview Guide 2026: Rideshare Engineering, Real-Time Dispatch, and Safety Systems
🏢 Asked at: Atlassian Interview Guide