Design Any Booking System (Movie Ticket Booking) | Low Level Design & Multithreading
A comprehensive architectural blueprint for designing concurrent, scalable, and high-throughput booking systems (Movie Tickets, Concerts, Airlines, Trains, Hotels, Cabs). This chapter bridges the gap between high-level architectural patterns and real-world multithreaded machine coding, focusing on lock isolation, atomic seat reservation, fine-grained concurrency control, and strategy design patterns.
1. The Universal Booking Paradigm
Every commercial reservation system—whether for movie theaters, high-demand music concerts, airline seats, hotel rooms, or ride-sharing cabs—shares an identical architectural core: reserving a finite, non-fungible set of inventory under extreme concurrent load without double-booking or losing revenue.
Key Operating Invariants across Domains
- Inventory Isolation: An asset (seat, room, cab) can be permanently assigned to at most one user for a given time window.
- Two-Phase Reservation Lifecycle:
- Soft Allocation (Temporary Lock): Held for a time-bound window (e.g., 2–10 minutes) while the user completes payment.
- Hard Allocation (Permanent Booking): Triggered immediately upon payment confirmation.
- Business Risk Mitigation:
- Overbooking (Double Booking): Destroys customer trust and incurs legal/financial penalties.
- Underbooking (Abandoned Holds): Caused by deadlocked or unreleased temporary locks, resulting in lost revenue.
2. Real-World Mental Model: Admin vs. User Workflows
To identify clean domain entities without architectural bloat, enact the system workflow from two distinct perspectives: the Admin who structures the system inventory, and the User who consumes it.
Entity Relationship Mapping
- Admin Actions:
- Registers physical
Theaterlocations. - Configures
Screenlayouts and assigns layout-specificSeatrecords. - Maps a
Movieand aScreento a specific time window, producing aShow.
- Registers physical
- User Actions:
- Browses available
Showinstances by location or movie. - Retrieves current real-time seat availability.
- Selects candidate seats and requests a temporary lock.
- Passes payment credentials; on success, the system converts the temporary lock into a confirmed
Bookingand generates aTicket.
- Browses available
3. Interview Strategy: Machine Coding vs. 1-Hour LLD Pragmatism
In a standard 45-to-60-minute system design interview, attempting to implement a complete production-grade multi-tiered architecture will guarantee incomplete code and failure. You must tailor your depth based on the interview format.
Interview Execution Strategy: Focus 80% of your coding time on
SeatLockProvider,BookingService, and thread synchronization logic. Keep domain models lightweight, avoid boilerplate getters/setters, and state design patterns (e.g., Repository, Strategy) verbally before jumping into core lock implementations.
4. Core Domain Models
Below are the minimal, clean domain representations for the core entities required to build a thread-safe seat booking system.
public enum BookingStatus {
CREATED,
CONFIRMED,
EXPIRED
}
public enum SeatCategory {
SILVER,
GOLD,
PLATINUM
}
public class Seat {
private final String id;
private final String row;
private final SeatCategory category;
public Seat(String id, String row, SeatCategory category) {
this.id = id;
this.row = row;
this.category = category;
}
public String getId() { return id; }
public SeatCategory getCategory() { return category; }
}
public class Movie {
private final String id;
private final String title;
private final int durationInMinutes;
public Movie(String id, String title, int durationInMinutes) {
this.id = id;
this.title = title;
this.durationInMinutes = durationInMinutes;
}
public String getId() { return id; }
}
public class Screen {
private final String id;
private final String name;
private final List<Seat> seats;
public Screen(String id, String name, List<Seat> seats) {
this.id = id;
this.name = name;
this.seats = seats;
}
public String getId() { return id; }
public List<Seat> getSeats() { return seats; }
}
public class Show {
private final String id;
private final Movie movie;
private final Screen screen;
private final long startTimeMillis;
public Show(String id, Movie movie, Screen screen, long startTimeMillis) {
this.id = id;
this.movie = movie;
this.screen = screen;
this.startTimeMillis = startTimeMillis;
}
public String getId() { return id; }
public Screen getScreen() { return screen; }
}
5. The Concurrency Problem: Race Conditions & Overbooking
Consider a scenario where two concurrent requests (User A and User B) attempt to reserve overlapping seats for the same Show at exact millisecond .
User A Thread: Requests seats [Seat 5, Seat 6, Seat 7]
User B Thread: Requests seats [Seat 7, Seat 8, Seat 9]
^
Collision
Naive vs. Fine-Grained Locking Strategies
Naive Approach 1: Global Lock (System Bottleneck)
Locking the entire SeatLockProvider or database table globally ensures thread safety, but degrades performance:
- Time Complexity: sequential request processing.
- Impact: Users attempting to book seats for a movie show in City X are blocked by users booking a completely different show in City Y.
Naive Approach 2: Independent Unordered Seat Locks (Deadlock Trap)
Locking each Seat object individually in a loop without sorting:
// User A thread locks Seat 5 -> Seat 6 -> Seat 7
// User B thread locks Seat 7 -> Seat 6 -> Seat 5
// Result: Deadlock! Both threads block forever waiting for each other's locks.
The Production-Grade Solution: Show-Level Isolation Window
Instead of locking individual seats sequentially or locking the global application state, scope the critical section to the Show instance map.
6. Fine-Grained Locking Strategy: Show-Level Isolation
Because seat availability is entirely isolated per individual Show, the ideal synchronization boundary is the Show object scope.
Temporal Lock Invariants
A seat lock is considered active if and only if:
If , the system treats the lock as expired and automatically permits other threads to overwrite it without requiring explicit cleanup threads.
7. Step-by-Step Code Evolution: Seat Locking & Concurrency Control
1. The SeatLock Model
Encapsulates temporary ownership of a seat for a show.
public class SeatLock {
private final Seat seat;
private final Show show;
private final String lockedBy;
private final long lockTimeMillis;
private final long timeoutInSeconds;
public SeatLock(Seat seat, Show show, String lockedBy, long lockTimeMillis, long timeoutInSeconds) {
this.seat = seat;
this.show = show;
this.lockedBy = lockedBy;
this.lockTimeMillis = lockTimeMillis;
this.timeoutInSeconds = timeoutInSeconds;
}
public boolean isLockExpired() {
long expirationTime = this.lockTimeMillis + (this.timeoutInSeconds * 1000);
return System.currentTimeMillis() > expirationTime;
}
public Seat getSeat() { return seat; }
public String getLockedBy() { return lockedBy; }
}
2. Strategy Pattern: SeatLockProvider Interface
public interface SeatLockProvider {
void lockSeats(Show show, List<Seat> seats, String user) throws SeatUnavailableException;
void unlockSeats(Show show, List<Seat> seats, String user);
boolean validateLock(Show show, Seat seat, String user);
List<Seat> getLockedSeats(Show show);
}
3. Concrete In-Memory Implementation with Show-Level Locking
This implementation uses nested ConcurrentHashMaps along with synchronized monitor locks per Show to eliminate cross-show contention while preventing deadlocks and race conditions during multi-seat requests.
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class InMemorySeatLockProvider implements SeatLockProvider {
private final long lockTimeoutInSeconds;
// Outer Map Key: Show ID | Inner Map Key: Seat ID -> SeatLock object
private final Map<String, Map<String, SeatLock>> locks = new ConcurrentHashMap<>();
// Synchronization monitors mapped per Show ID to isolate execution locks
private final Map<String, Object> showMonitors = new ConcurrentHashMap<>();
public InMemorySeatLockProvider(long lockTimeoutInSeconds) {
this.lockTimeoutInSeconds = lockTimeoutInSeconds;
}
private Object getShowMonitor(String showId) {
return showMonitors.computeIfAbsent(showId, k -> new Object());
}
@Override
public void lockSeats(Show show, List<Seat> seats, String user) throws SeatUnavailableException {
Object showMonitor = getShowMonitor(show.getId());
// Lock execution strictly scoped to the specific Show ID
synchronized (showMonitor) {
Map<String, SeatLock> showLocks = locks.computeIfAbsent(show.getId(), k -> new HashMap<>());
// Step 1: Check for collisions across all requested seats atomically
for (Seat seat : seats) {
if (isSeatLocked(showLocks, seat)) {
throw new SeatUnavailableException("Seat ID " + seat.getId() + " is currently locked by another user.");
}
}
// Step 2: Acquire locks for all requested seats
long now = System.currentTimeMillis();
for (Seat seat : seats) {
SeatLock lock = new SeatLock(seat, show, user, now, lockTimeoutInSeconds);
showLocks.put(seat.getId(), lock);
}
}
}
private boolean isSeatLocked(Map<String, SeatLock> showLocks, Seat seat) {
if (!showLocks.containsKey(seat.getId())) {
return false;
}
SeatLock lock = showLocks.get(seat.getId());
if (lock.isLockExpired()) {
showLocks.remove(seat.getId()); // Passive expiration cleanup
return false;
}
return true;
}
@Override
public void unlockSeats(Show show, List<Seat> seats, String user) {
Object showMonitor = getShowMonitor(show.getId());
synchronized (showMonitor) {
Map<String, SeatLock> showLocks = locks.get(show.getId());
if (showLocks == null) return;
for (Seat seat : seats) {
SeatLock lock = showLocks.get(seat.getId());
// Defensive check: Only lock owner can unlock
if (lock != null && lock.getLockedBy().equals(user)) {
showLocks.remove(seat.getId());
}
}
}
}
@Override
public boolean validateLock(Show show, Seat seat, String user) {
Map<String, SeatLock> showLocks = locks.get(show.getId());
if (showLocks == null) return false;
SeatLock lock = showLocks.get(seat.getId());
return lock != null && !lock.isLockExpired() && lock.getLockedBy().equals(user);
}
@Override
public List<Seat> getLockedSeats(Show show) {
Object showMonitor = getShowMonitor(show.getId());
synchronized (showMonitor) {
Map<String, SeatLock> showLocks = locks.get(show.getId());
if (showLocks == null) return Collections.emptyList();
List<Seat> lockedSeats = new ArrayList<>();
Iterator<Map.Entry<String, SeatLock>> iterator = showLocks.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, SeatLock> entry = iterator.next();
if (entry.getValue().isLockExpired()) {
iterator.remove(); // Evict stale lock
} else {
lockedSeats.add(entry.getValue().getSeat());
}
}
return lockedSeats;
}
}
}
8. Complete System Integration & Workflow
The orchestrating layer integrates BookingService, SeatAvailabilityService, and the strategy-based PaymentStrategy.
public class Booking {
private final String id;
private final Show show;
private final String user;
private final List<Seat> seatsBooked;
private BookingStatus status;
public Booking(String id, Show show, String user, List<Seat> seatsBooked) {
this.id = id;
this.show = show;
this.user = user;
this.seatsBooked = seatsBooked;
this.status = BookingStatus.CREATED;
}
public void confirmBooking() { this.status = BookingStatus.CONFIRMED; }
public void expireBooking() { this.status = BookingStatus.EXPIRED; }
public String getId() { return id; }
public Show getShow() { return show; }
public List<Seat> getSeatsBooked() { return seatsBooked; }
public String getUser() { return user; }
public BookingStatus getStatus() { return status; }
}
Seat Availability Computation Logic
Available seats are derived dynamically using set subtraction:
public class SeatAvailabilityService {
private final SeatLockProvider seatLockProvider;
private final BookingService bookingService;
public SeatAvailabilityService(SeatLockProvider seatLockProvider, BookingService bookingService) {
this.seatLockProvider = seatLockProvider;
this.bookingService = bookingService;
}
public List<Seat> getAvailableSeats(Show show) {
List<Seat> allSeats = show.getScreen().getSeats();
List<Seat> unavailableSeats = new ArrayList<>();
unavailableSeats.addAll(bookingService.getConfirmedBookedSeats(show));
unavailableSeats.addAll(seatLockProvider.getLockedSeats(show));
Set<String> unavailableSeatIds = new HashSet<>();
for (Seat s : unavailableSeats) {
unavailableSeatIds.add(s.getId());
}
List<Seat> availableSeats = new ArrayList<>();
for (Seat seat : allSeats) {
if (!unavailableSeatIds.contains(seat.getId())) {
availableSeats.add(seat);
}
}
return availableSeats;
}
}
Booking Orchestration Engine
public class BookingService {
private final Map<String, Booking> bookingStore = new ConcurrentHashMap<>();
private final SeatLockProvider seatLockProvider;
public BookingService(SeatLockProvider seatLockProvider) {
this.seatLockProvider = seatLockProvider;
}
public Booking createBooking(Show show, String user, List<Seat> seats) throws SeatUnavailableException {
// Step 1: Acquire temporary fine-grained locks across requested seats
seatLockProvider.lockSeats(show, seats, user);
// Step 2: Generate an unconfirmed booking instance tied to the user
String bookingId = UUID.randomUUID().toString();
Booking booking = new Booking(bookingId, show, user, seats);
bookingStore.put(bookingId, booking);
return booking;
}
public void confirmBooking(String bookingId, String user, PaymentStrategy paymentStrategy) {
Booking booking = bookingStore.get(bookingId);
if (booking == null) {
throw new IllegalArgumentException("Booking ID not found.");
}
// Step 3: Validate that the lock is still validly held by the user
for (Seat seat : booking.getSeatsBooked()) {
if (!seatLockProvider.validateLock(booking.getShow(), seat, user)) {
booking.expireBooking();
throw new IllegalStateException("Lock expired. Cannot confirm booking.");
}
}
// Step 4: Process payment via Strategy Pattern
boolean success = paymentStrategy.processPayment(user, 500.00);
if (success) {
booking.confirmBooking();
// Permanent confirmation complete: Release temporary memory locks
seatLockProvider.unlockSeats(booking.getShow(), booking.getSeatsBooked(), user);
} else {
booking.expireBooking();
seatLockProvider.unlockSeats(booking.getShow(), booking.getSeatsBooked(), user);
throw new RuntimeException("Payment processing failed.");
}
}
public List<Seat> getConfirmedBookedSeats(Show show) {
List<Seat> bookedSeats = new ArrayList<>();
for (Booking booking : bookingStore.values()) {
if (booking.getShow().getId().equals(show.getId()) && booking.getStatus() == BookingStatus.CONFIRMED) {
bookedSeats.addAll(booking.getSeatsBooked());
}
}
return bookedSeats;
}
}
Strategy Design Pattern: Payment Strategy Integration
public interface PaymentStrategy {
boolean processPayment(String userId, double amount);
}
public class UpiPaymentStrategy implements PaymentStrategy {
@Override
public boolean processPayment(String userId, double amount) {
// Simulated payment gateway integration
System.out.println("Processing UPI payment of $" + amount + " for user: " + userId);
return true;
}
}
9. Production Pitfalls, Multithreading Traps & Edge Cases
1. Global Synchronized Monitor Bottleneck
- The Pitfall: Adding
synchronizedto the entirelockSeats()method signature inInMemorySeatLockProvider. - The Consequence: All concurrent users, regardless of theater, city, screen, or show, block each other during seat selection.
- The Fix: Maintain a concurrent map of lock monitors scoped strictly per
Show.getId().
2. Lock Expiration & Memory Leak Traps
- The Pitfall: Relying purely on an active background thread with a
while(true)loop sleeping every second to clean up expired locks. - The Consequence: Context-switching overhead and CPU thread starvation under heavy load.
- The Fix: Use Passive Lazy Expiration directly inside validation and lookup methods (
isLockExpired()), backed optionally by a lightweight, scheduledScheduledExecutorServicerunning periodically.
3. Partial Lock Acquisition Collisions
- The Pitfall: Acquiring locks on requested seats one by one without atomicity.
// User A requests [Seat 1, Seat 2]
// User B requests [Seat 2, Seat 3]
// User A locks Seat 1. User B locks Seat 2.
// User A attempts to lock Seat 2 -> Fails!
// User B attempts to lock Seat 3 -> Succeeds!
// Result: User A fails, but left Seat 1 locked permanently!
- The Fix: Validate all requested seats inside a show-scoped synchronized critical section before writing locks. If even one seat fails, write zero locks and fail immediately (All-or-Nothing transactional approach).
10. Summary of Applied Design Patterns
Summary and Key Takeaways
- Universal Domain Paradigm: Movie, airline, concert, and hotel booking systems share identical core constraints: locking finite inventory under high concurrency.
- Scope Locks to Show ID: Avoid global provider locks. Isolate locks to specific
Showinstances using fine-grained synchronization maps (showMonitors) to maximize throughput. - All-or-Nothing Atomic Locks: When a user selects multiple seats, validate that all requested seats are available within the critical section before acquiring any locks to prevent partial locks and deadlocks.
- Passive Lock Expiration: Evaluate lock expiration lazily () during validation checks to avoid relying solely on active polling loops.
- Separation of Reservation States: Distinguish clearly between temporary holds (
SeatLock, short-lived in memory) and permanent allocations (Booking, persistent state). - Interview Time Optimization: In a 1-hour Low Level Design interview, skip boilerplate CRUD controllers, DAOs, and generation factories. Focus core coding efforts on
BookingService,SeatLockProvider, and atomic synchronization logic.