LLD for Movie Booking System: Designing BookMyShow
Designing a movie booking system involves handling concurrency (no double bookings!), managing complex hierarchies, and ensuring a smooth user experie
Abstract AlgorithmsAI-assisted content. This post may have been written or enhanced with AI tools. Please verify critical information independently.
TLDR
TLDR: A Movie Booking System (like BookMyShow) is an inventory management problem with an expiry: seats expire when the show starts. The core engineering challenge is preventing double-booking under concurrent user load with a 3-state seat model (AVAILABLE โ LOCKED โ BOOKED).
๐ The Cinema Inventory Problem: What Makes It Hard
You run a cinema. A Friday night show has 200 seats. Two scenarios break a naive implementation:
- Double-booking: User A and User B click "Book Seat A1" at the same millisecond. Only one should succeed.
- Abandoned locks: User A locks Seat A1 but never completes payment. The seat should become available again after 10 minutes.
This needs a 3-state model + distributed lock strategy.
๐ Key Entities in a Movie Booking System
Before writing a single line of concurrency code, you need a clear domain model. A movie booking platform has six core entities:
| Entity | What It Represents | Key Attributes |
| Movie | The film being screened | title, duration, genre |
| Cinema | The physical venue | name, city, list of screens |
| Screen | A single auditorium | id, total seats, seat layout |
| Show | One screening of a movie on a screen | movie, screen, startTime |
| Seat | An individual seat in a screen | row, column, SeatType, SeatStatus |
| Booking | A confirmed reservation | user, show, seats, paymentStatus |
| User | The person making the reservation | name, email, booking history |
SeatType classifies seats by price tier:
PLATINUMโ premium recliner seats (front/center)GOLDโ standard premium tierSILVERโ economy tier
SeatStatus is the concurrency-critical state that drives the entire booking engine:
AVAILABLEโ free to book by any userLOCKEDโ temporarily held during checkout (10-minute TTL by default)BOOKEDโ payment confirmed, seat belongs to a specific user
The Show entity is the critical aggregation point: it joins a Movie, a Screen, and a specific start time. Every Booking references a Show, not the Movie directly โ this is why "the 7 PM showing of a film" is completely separate inventory from "the 9 PM showing" of the same film. This hierarchy shapes every query, index, and cache partition in the real system.
๐ข The Domain Hierarchy: City โ Cinema โ Screen โ Show โ Seat
classDiagram
class City {+String name; +List~Cinema~ cinemas}
class Cinema {+String name; +List~Screen~ screens}
class Screen {+int id; +List~Seat~ seats}
class Movie {+String title; +int durationMinutes}
class Show {
+Movie movie
+Screen screen
+LocalDateTime startTime
+synchronized boolean bookSeats(List seats)
}
class Seat {
-SeatStatus status
+String row
+int col
+SeatType type
+reserve(bookingId)
+release()
+confirm()
}
class PremiumSeat {
+boolean hasRecline
+boolean hasFootrest
}
class GroupSeat {
+String groupReservationId
}
class Booking {
+int id
+Show show
+List~Seat~ seats
+PaymentStatus payment
}
class BookingService {
+reserveSeats(User, Show, List~Seat~)
+confirmBooking(Booking)
}
class PricingStrategy {
<<interface>>
+calculatePrice(Seat, Show, Customer) Money
}
class PaymentGateway {
<<interface>>
+charge(Customer, Money, String) PaymentResult
}
City *-- Cinema
Cinema *-- Screen
Screen *-- Seat
Show "1" *-- "many" Seat : contains
Show --> Screen
Booking --> Show
Booking --> Seat
PremiumSeat --|> Seat : extends
GroupSeat --|> Seat : extends
BookingService o-- PricingStrategy : uses (injected)
BookingService o-- PaymentGateway : uses (injected)
The composition arrows (*--) running down the left spine โ City โ Cinema โ Screen โ Seat โ model physical containment: a Screen cannot exist without its owning Cinema, and Seat instances are part of the Screen. Show sits at the centre as the critical join entity, linking a Movie and a Screen at a specific startTime; every Booking references a Show, not a Movie directly, which is why two screenings of the same film are completely independent inventory units. The aggregation arrows (o--) from BookingService to PricingStrategy and PaymentGateway express collaboration, not ownership โ both dependencies are injected interfaces, making the service fully decoupled from any concrete fee rule or payment provider.
- SeatType:
GOLD,SILVER,PLATINUMโ affects pricing. - SeatStatus:
AVAILABLE,LOCKED,BOOKEDโ the concurrency core. - Show is the intersection of Movie + Screen + StartTime.
โ๏ธ The 3-State Seat Model: Preventing Double Booking
The key insight: don't go directly from AVAILABLE to BOOKED. Insert a temporary LOCKED state.
State machine:
stateDiagram-v2
[*] --> AVAILABLE
AVAILABLE --> LOCKED : User selects seat (10 min TTL)
LOCKED --> BOOKED : Payment success
LOCKED --> AVAILABLE : Payment timeout / failure
BOOKED --> [*] : Show complete
The booking flow:
| Time | User A | User B | Seat A1 Status |
| 10:00:00 | Selects A1 | โ | AVAILABLE |
| 10:00:01 | System locks A1 | โ | LOCKED (expires 10:10) |
| 10:00:02 | Directed to payment | Selects A1 | โ |
| 10:00:02 | โ | System: seat unavailable | LOCKED (no change) |
| 10:05:00 | Payment success | โ | BOOKED |
User B gets a graceful "This seat is currently being held by another user" message โ not a race condition with undefined behavior.
๐ Booking Flow: From Seat Selection to Confirmation
Here is how a booking request moves through the system end-to-end โ including both failure paths that keep inventory clean:
flowchart TD
A([User Selects Seats]) --> B{All seats AVAILABLE?}
B -- No --> C([Error: Seat Already Held by Another User])
B -- Yes --> D[Lock Seats 10 min TTL applied]
D --> E([User Redirected to Payment Page])
E --> F{Payment Successful?}
F -- Timeout or Failure --> G[Release Lock Seats set back to AVAILABLE]
F -- Yes --> H[Set Seats to BOOKED]
H --> I([Confirmation Email Sent to User])
G --> J([Error: Payment Failed Seats Released])
The flowchart maps the complete booking transaction as a directed decision graph with two distinct failure exits. The first diamond โ "All seats AVAILABLE?" โ is the cheap early-exit: if any seat is already LOCKED or BOOKED by a concurrent user, the request is rejected before a lock is ever acquired on the current user's behalf, requiring zero cleanup. Only after seats are successfully locked does control reach the payment gateway, whose failure branch explicitly releases the hold and returns inventory to AVAILABLE โ this explicit release is what separates a well-designed system from one that silently leaks inventory after a card decline.
Two distinct failure paths keep inventory accurate:
- Pre-lock failure โ seats are already LOCKED by a concurrent user. The second user receives a graceful "unavailable" response immediately, before any lock is created on their behalf.
- Post-lock failure โ the user's payment times out or fails. The LOCKED seats revert to AVAILABLE either via TTL expiry (handled by a background cleanup job) or an explicit release call in the payment failure handler.
The 10-minute lock TTL is the safety net for abandoned sessions: a user who closes the browser mid-checkout will not freeze a seat for the rest of the evening.
๐ง Deep Dive: Synchronized Booking Implementation
public class Show {
private final int id;
private final List<Seat> seats;
public synchronized BookingResult reserveSeats(User user, List<Integer> seatIds) {
// Phase 1: Check all requested seats are available
List<Seat> selected = new ArrayList<>();
for (int id : seatIds) {
Seat s = findSeat(id);
if (s.getStatus() != SeatStatus.AVAILABLE) {
return BookingResult.failure("Seat " + id + " is not available");
}
selected.add(s);
}
// Phase 2: Lock all seats together (atomic within synchronized block)
Instant lockExpiry = Instant.now().plusSeconds(600);
for (Seat s : selected) {
s.setStatus(SeatStatus.LOCKED);
s.setLockExpiry(lockExpiry);
}
return BookingResult.success(new Booking(user, this, selected));
}
}
synchronized on the Show method ensures that the check-and-lock is atomic โ no two threads can interleave between the check (AVAILABLE) and the lock (LOCKED).
๐ Reserve Seats: Booking Sequence
sequenceDiagram
participant U as User
participant BS as BookingService
participant SH as Show
participant S as Seat
participant PG as PaymentGateway
U->>BS: reserveSeats(user, show, seatIds)
BS->>SH: reserveSeats(user, seatIds) synchronized
SH->>S: status == AVAILABLE?
S-->>SH: true
SH->>S: reserve(bookingId) LOCKED
SH-->>BS: BookingResult.success(booking)
BS->>PG: charge(user, amount, bookingId)
PG-->>BS: PaymentResult.success
BS->>S: confirm() BOOKED
BS-->>U: Booking{id, seats, CONFIRMED}
The sequence diagram traces the critical path for seat reservation โ from the initial lock request (preventing other threads from booking the same seat) through payment confirmation to the final state transition to BOOKED. The synchronized annotation on the BS->>SH call marks the boundary of the atomic monitor: every arrow that follows โ the status check (SH->>S: status == AVAILABLE?) and the lock (SH->>S: reserve(bookingId)) โ executes inside a single thread-held critical section, making interleaving between the check and the commit impossible. Notice that confirm() โ BOOKED is called by BookingService only after the PaymentGateway responds, keeping the slow network I/O outside the synchronized block and avoiding holding the seat lock during the entire payment round-trip.
โ๏ธ Pricing Strategy Pattern
Different shows and seat types need different prices:
public interface PricingStrategy {
Money calculatePrice(Seat seat, Show show, Customer customer);
}
public class HourlyPricing implements PricingStrategy {
public Money calculatePrice(Seat seat, Show show, Customer customer) {
double base = seat.getType() == SeatType.GOLD ? 15.0 : 10.0;
double multiplier = show.isWeekend() ? 1.2 : 1.0; // weekend surcharge
return Money.of(base * multiplier);
}
}
public class DynamicPricing implements PricingStrategy {
public Money calculatePrice(Seat seat, Show show, Customer customer) {
double occupancy = show.getBookedPercent();
return Money.of(BASE_PRICE * (1 + occupancy)); // price rises with demand
}
}
๐ Enhanced Booking Domain Class Diagram
classDiagram
class Seat {
-SeatStatus status
+String row
+int col
+SeatType type
+reserve(String bookingId) void
+release() void
+confirm() void
}
class PremiumSeat {
+boolean hasRecline
+boolean hasFootrest
}
class GroupSeat {
+String groupReservationId
}
class SeatType {
<<enumeration>>
PLATINUM
GOLD
SILVER
}
class SeatStatus {
<<enumeration>>
AVAILABLE
LOCKED
BOOKED
}
class PricingStrategy {
<<interface>>
+calculatePrice(Seat, Show, Customer) Money
}
class HourlyPricing {
+calculatePrice(Seat, Show, Customer) Money
}
class DynamicPricing {
+calculatePrice(Seat, Show, Customer) Money
}
class SeatSelector {
<<interface>>
+findAvailable(Show, SeatType, int) List~Seat~
}
class PaymentGateway {
<<interface>>
+charge(Customer, Money, String) PaymentResult
}
class BookingService {
-PricingStrategy pricer
-SeatSelector selector
-PaymentGateway gateway
+reserveSeats(User, Show, List~Seat~) Booking
+confirmBooking(Booking) void
}
PremiumSeat --|> Seat : extends
GroupSeat --|> Seat : extends
Seat --> SeatStatus : state
Seat --> SeatType : type
HourlyPricing ..|> PricingStrategy : implements
DynamicPricing ..|> PricingStrategy : implements
BookingService o-- PricingStrategy : uses (injected)
BookingService o-- SeatSelector : uses (injected)
BookingService o-- PaymentGateway : uses (injected)
This enhanced diagram isolates the Seat subsystem with its full type hierarchy and wires in the three Strategy/Gateway interfaces that BookingService depends on. The enumeration classes SeatType and SeatStatus appear as owned associations of Seat (solid arrows from Seat), making explicit that price tier and booking state are intrinsic to the seat itself โ not to the Booking or the Show. The dashed implementation arrows (..>) from HourlyPricing and DynamicPricing to PricingStrategy are the Strategy pattern in UML form: both are interchangeable at the BookingService injection site, so the active pricing rule switches at Spring configuration time without a single line of booking logic changing.
๐ Interface Contracts: The Three Boundaries That Keep Booking Decoupled
Every cross-module dependency in the booking system is expressed as a Java interface. This locks in the contract while leaving each implementation free to change independently.
// Abstracts price computation โ BookingService never hardcodes a fee rule
interface PricingStrategy {
Money calculatePrice(Seat seat, Show show, Customer customer);
}
// Abstracts seat discovery โ BookingService never knows the screen layout algorithm
interface SeatSelector {
List<Seat> findAvailable(Show show, SeatType type, int count);
}
// Abstracts the payment provider โ BookingService never touches a provider SDK
interface PaymentGateway {
PaymentResult charge(Customer customer, Money amount, String idempotencyKey);
}
| Interface | What It Abstracts | Implementors | Consumer |
PricingStrategy | Fee calculation rules | HourlyPricing, DynamicPricing, GroupRatePricing | BookingService |
SeatSelector | Seat availability search and ranking | DefaultSeatSelector, AdjacentSeatSelector | BookingService |
PaymentGateway | Payment provider API calls | StripeGateway, RazorpayGateway | BookingService |
The idempotencyKey in PaymentGateway.charge() (typically bookingId + "-" + attemptNumber) lets the payment provider safely deduplicate retried charges โ critical when a network timeout causes BookingService to retry after the first charge already succeeded.
๐งฑ OOP Pillars Applied to the Booking Domain
Each of the four OOP pillars has a concrete engineering role in this design โ not as theoretical labels but as decisions that keep the system correct and extensible.
Encapsulation โ Seat Owns Its Own State Machine
SeatStatus is a private field. No class outside Seat can call setStatus(SeatStatus.BOOKED) directly. External code uses the explicit transition methods, and Seat enforces invariants at every step:
public class Seat {
private SeatStatus status = SeatStatus.AVAILABLE; // hidden โ no direct setter
public synchronized void reserve(String bookingId) {
if (status != SeatStatus.AVAILABLE)
throw new IllegalStateException("Seat already held");
this.status = SeatStatus.LOCKED;
this.bookingId = bookingId;
}
public synchronized void release() {
if (status == SeatStatus.LOCKED) {
this.status = SeatStatus.AVAILABLE;
this.bookingId = null;
}
}
public synchronized void confirm() {
if (status != SeatStatus.LOCKED)
throw new IllegalStateException("Cannot confirm an unlocked seat");
this.status = SeatStatus.BOOKED;
}
// No public setStatus() โ external code cannot bypass the state machine
}
synchronized on each method means the state machine is also thread-safe: a LOCKED seat cannot be accidentally set back to AVAILABLE by one thread while another thread is confirming it.
Abstraction โ PricingStrategy Hides the Fee Rules
BookingService calls one method and gets a price. Whether the active strategy applies a peak-hour surcharge, a loyalty discount, or a group rate is invisible to the caller:
// BookingService only knows this interface โ not which implementation is active
Money price = pricingStrategy.calculatePrice(seat, show, customer);
The abstraction also insulates the service from future business-logic changes: adding a holiday-surcharge rule never requires touching BookingService โ it only requires a new HolidayPricing implements PricingStrategy.
Inheritance โ PremiumSeat and GroupSeat Extend the Base Contract
The physical seat hierarchy maps directly to Java inheritance. Both subclasses inherit reserve(), release(), and confirm() without duplicating concurrency logic:
public class PremiumSeat extends Seat {
private boolean hasRecline;
private boolean hasFootrest;
// inherits the full SeatStatus state machine โ no duplication
}
public class GroupSeat extends Seat {
private String groupReservationId;
@Override
public synchronized void reserve(String bookingId) {
super.reserve(bookingId); // delegate to parent state machine
this.groupReservationId = bookingId; // add group-level metadata
}
}
The hierarchy models physical auditoriums: every premium recliner and group-linked seat is still fundamentally a Seat with the same lifecycle.
Polymorphism โ One Loop, All Seat Types
Show holds a List<Seat>. At runtime the list may contain Seat, PremiumSeat, and GroupSeat objects. Pricing and state transitions work uniformly across all of them:
List<Seat> seats = show.getSeats(); // may be StandardSeat, PremiumSeat, GroupSeat
for (Seat seat : seats) {
// runtime dispatch: correct pricing rule chosen based on actual type + strategy
Money price = pricingStrategy.calculatePrice(seat, show, customer);
}
PricingStrategy polymorphism complements this: DynamicPricing, HourlyPricing, and GroupRatePricing each implement calculatePrice differently, but BookingService uses a single call site regardless of which implementation is injected.
โ SOLID Principles in the Booking Design
| Principle | How It Appears in the Design |
| SRP | Seat manages its own state transitions; Show manages seat inventory for a screening; BookingService orchestrates a single booking transaction โ each class has exactly one reason to change |
| OCP | Adding a VIP pod seat type = VipSeat extends Seat + VipPricingStrategy implements PricingStrategy. No existing class is modified; the system is open for extension, closed for modification |
| LSP | PremiumSeat and StandardSeat both honour Seat's reserve()/release()/confirm() contract โ they are substitutable anywhere a Seat is expected, and a List<Seat> works correctly regardless of the concrete type it holds |
| ISP | PricingStrategy is a single-method interface; SeatSelector is a separate interface. A class that only discovers seats never has to implement calculatePrice, and vice versa |
| DIP | BookingService depends on PricingStrategy and PaymentGateway abstractions injected via constructor โ it never references HourlyPricing or StripeGateway directly |
DIP in practice โ constructor injection keeps BookingService provider-agnostic:
@Service
public class BookingService {
private final PricingStrategy pricingStrategy; // abstraction, not a concrete class
private final PaymentGateway paymentGateway; // abstraction, not a concrete class
public BookingService(PricingStrategy pricingStrategy, PaymentGateway paymentGateway) {
this.pricingStrategy = pricingStrategy;
this.paymentGateway = paymentGateway;
}
// Swapping DynamicPricing for HourlyPricing is a Spring config change, not a code change
}
In Spring Boot the active PricingStrategy bean (e.g., DynamicPricing) is injected by the container at startup. Switching strategies for a feature flag or A/B test requires only a configuration change.
๐ Booking Lifecycle State Diagram
stateDiagram-v2
[*] --> SEAT_AVAILABLE : show created
SEAT_AVAILABLE --> SEAT_LOCKED : user selects seat 10-min TTL
SEAT_LOCKED --> SEAT_AVAILABLE : payment timeout or failure
SEAT_LOCKED --> SEAT_BOOKED : payment confirmed
SEAT_BOOKED --> [*] : show completed
SEAT_BOOKED --> SEAT_AVAILABLE : user cancels booking
note right of SEAT_LOCKED : TTL-based auto-release protects against abandoned checkouts
This state diagram is the formal contract for Seat's state machine โ every if (status != ...) guard in Seat.reserve(), release(), and confirm() enforces exactly the transitions shown here, and no others. The cancellation arc (SEAT_BOOKED โ SEAT_AVAILABLE) makes explicit that cancellation is a first-class transition, not an afterthought: confirmed seats must revert cleanly without passing through LOCKED. The annotated note on SEAT_LOCKED elevates the 10-minute TTL to a correctness requirement: without it, a user who closes the browser mid-checkout permanently freezes a seat until the show starts, and no manual intervention is possible at scale.
๐ Spring REST Endpoint: Booking via HTTP
The domain model is complete. A @RestController bridges the booking service to HTTP clients โ the booking engine itself is unaware of HTTP.
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/bookings")
public class BookingController {
private final BookingService bookingService;
public BookingController(BookingService bookingService) {
this.bookingService = bookingService;
}
/**
* POST /api/bookings
* Atomically reserves the requested seats for a show.
* Returns 409 Conflict if any seat is already BOOKED or RESERVED by another user.
*/
@PostMapping
public ResponseEntity<BookingConfirmation> book(@RequestBody BookingRequest request) {
try {
BookingConfirmation confirmation = bookingService.book(
request.getShowId(),
request.getUserId(),
request.getSeatIds()
);
return ResponseEntity.ok(confirmation);
} catch (SeatUnavailableException ex) {
return ResponseEntity.status(409).build();
}
}
/**
* DELETE /api/bookings/{bookingId}
* Cancels a booking and transitions seats back to AVAILABLE.
*/
@DeleteMapping("/{bookingId}")
public ResponseEntity<Void> cancel(@PathVariable String bookingId) {
bookingService.cancel(bookingId);
return ResponseEntity.noContent().build();
}
/**
* GET /api/bookings/{bookingId}
* Returns confirmation details for an existing booking.
*/
@GetMapping("/{bookingId}")
public ResponseEntity<BookingConfirmation> get(@PathVariable String bookingId) {
return bookingService.find(bookingId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
Key design choices in this endpoint:
BookingServiceis injected via constructor (notnew) โ this decouples the HTTP layer from the domain. SwappingBookingServicefor a mock in tests requires zero changes to the controller.POST /api/bookingsmaps to thebook()use case. Thesynchronizedblock insideBookingService.book()ensures atomicity at the JVM level; for distributed deployments, replace it with a Redisson distributed lock (see the ๐ ๏ธ section).DELETE /api/bookings/{bookingId}triggers seat state transitions (BOOKED โ AVAILABLE) entirely through the domain model โ the controller carries no business logic.SeatUnavailableExceptionis a domain exception; the controller converts it to a409 Conflictwithout leaking domain details to the client.
โ๏ธ OOP Design Trade-offs and Failure Modes in the Booking System
| Design Decision | Trade-off |
| 3-state model vs. 2-state | Three states (AVAILABLE โ LOCKED โ BOOKED) prevent double-booking but add a cleanup responsibility: LOCKED seats must expire. A 2-state model is simpler but makes race conditions inexpressible in the design |
| Encapsulated state machine vs. direct field access | Seat.reserve() / release() / confirm() enforce invariants at every transition. Exposing setStatus() is simpler to write but shifts correctness responsibility to every caller |
Interface injection vs. new inside the method | Injecting PricingStrategy makes BookingService independently testable with any fake strategy. Using new HourlyPricing() inside the method couples the service to one rule and makes substitution impossible without modifying the class |
| Inheritance depth | One level (PremiumSeat extends Seat) is safe โ the state machine is inherited cleanly. Deeper hierarchies (e.g., VipReclinerSeat extends PremiumSeat extends Seat) make reserve() override chains hard to trace. Prefer composition for third-level specialisation |
| Single-method interfaces vs. fat interfaces | PricingStrategy with one method is easy to implement as a lambda and easy to test in isolation. A fat BookingOperations interface forces implementors to provide methods they don't use and violates ISP |
Failure mode โ skipping the LOCKED state:
If code transitions directly from AVAILABLE to BOOKED in a single step, any failure mid-payment (timeout, gateway error, network drop) leaves the seat permanently BOOKED with no completed payment. The LOCKED intermediate state isolates the inventory hold from the payment outcome, making reversal explicit, TTL-bounded, and safe.
๐ Real-World Applications of the Movie Booking Pattern
The AVAILABLE โ LOCKED โ BOOKED pattern is not unique to cinemas. Any system where a limited, enumerable resource must be reserved under concurrent demand faces the same engineering challenge:
| Domain | Resource Being Reserved | Typical Lock TTL |
| Flight booking | Aircraft seat | 15โ30 min (complex international payment) |
| Concert tickets | Venue seat or GA slot | 5โ10 min (short to limit bot abuse) |
| Hotel rooms | Room-night inventory | 15 min |
| Sports events | Stadium seat | 8โ12 min |
| Exam slot booking | Test center seat + timeslot | 20 min |
| Parking reservations | Parking space | Variable |
The key differentiator across domains is TTL tuning: flight bookings allow longer locks because international payment flows involve more redirect steps. High-demand concert systems use shorter TTLs to prevent automated bots from freezing large seat blocks and releasing them at the last moment.
The BookMyShow design is the canonical LLD interview question because it is scope-limited enough to whiteboard in 45 minutes while covering every essential OOD concept: domain hierarchy, state machines, concurrency control, and behavioral patterns like Strategy.
๐งช Exercises: Extend the Booking System Yourself
Work through these exercises to move from understanding the design to being able to implement it under interview conditions.
Exercise 1 โ Add a Cancellation Flow
Implement a cancelBooking(Booking b) method on Show. What state transition should the seat undergo: directly back to AVAILABLE, or through an intermediate CANCELLATION_PENDING state? Handle the edge case of a partial cancellation โ the user booked 3 seats and wants to cancel only 1. Should the remaining 2 seats stay BOOKED?
Exercise 2 โ Expired Lock Cleanup Job
Write a ScheduledJob that runs every 60 seconds and reverts all LOCKED seats where lockExpiry < Instant.now() back to AVAILABLE. Now consider: what happens if two instances of this job run concurrently on a two-node deployment? Design a guard (using DB-level CAS or a distributed lock) to prevent the same seat from being released twice.
Exercise 3 โ Adjacent Seat Recommendation
Given a user who requests n adjacent seats, write a method List<Seat> findBestAdjacentSeats(Screen screen, int n). Return the best available adjacent block ranked by SeatType, preferring center rows first. Define "best" explicitly, and handle the edge case where no block of n adjacent seats exists in any row.
๐งญ OOP Decision Guide: Patterns and Extension Points
| Decision Point | Recommendation |
| New seat category (VIP pod, wheelchair) | VipSeat extends Seat โ inherit the full state machine, add category-specific fields. Only override reserve() if the locking behaviour genuinely differs for that category |
| New pricing rule | HolidayPricing implements PricingStrategy โ never add an if (isHoliday) branch inside BookingService. The Strategy pattern exists to absorb exactly this kind of change |
| New seat selection algorithm | AdjacentSeatSelector implements SeatSelector โ the interface isolates layout logic from booking orchestration. Swap selectors via constructor injection without touching BookingService |
| Payment provider switch | RazorpayGateway implements PaymentGateway โ the gateway interface abstracts the entire provider SDK. BookingService needs zero changes |
synchronized vs. distributed locking | synchronized on Seat methods is correct for object-level thread safety within one JVM. Move to Redisson RLock only when the locking boundary crosses a JVM. Keep the method-level interface contract identical in both cases |
Abstract class vs. interface for Seat | Seat is a concrete class (not abstract) because the base seat type is valid on its own. PricingStrategy and PaymentGateway are interfaces because they have no shared state โ each implementation is completely independent |
Start with synchronized on Seat methods and constructor-injected strategies. Extend the design by adding new subclasses and strategy implementations โ never by adding conditional branches inside existing classes.
๐ ๏ธ Spring Data JPA and Redisson: Transactional Booking with a Distributed Lock
Spring Data JPA maps the Seat entity and its SeatStatus enum to a relational table and provides @Transactional isolation so concurrent threads cannot both read AVAILABLE and commit a BOOKED update for the same seat. Redisson is a Redis-based Java client that provides RLock โ a distributed reentrant lock with automatic expiry โ to prevent double-booking across multiple JVM instances (the multi-server scenario synchronized cannot solve).
How they solve the problem in this post: The 3-state model (AVAILABLE โ LOCKED โ BOOKED) maps directly to JPA's optimistic locking via @Version. Redisson's RLock acquires a per-seat Redis lock for the 10-minute payment window, releasing it on payment or expiry โ handling the abandoned-lock scenario automatically.
// โโโ Seat entity with optimistic locking via @Version โโโโโโโโโโโโโโโโโโโโโโโโโ
import jakarta.persistence.*;
@Entity
@Table(name = "seats")
public class Seat {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private int row;
private int column;
@Enumerated(EnumType.STRING)
private SeatStatus status = SeatStatus.AVAILABLE;
@Version // optimistic lock: incremented on each update
private int version; // concurrent update โ OptimisticLockException โ retry
// getters / setters (or use Lombok @Data)
}
public enum SeatStatus { AVAILABLE, LOCKED, BOOKED }
// โโโ Repository: Spring Data JPA โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
public interface SeatRepository extends JpaRepository<Seat, Long> {
// Pessimistic write lock at DB level: SELECT ... FOR UPDATE
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Seat s WHERE s.id = :id")
java.util.Optional<Seat> findByIdForUpdate(Long id);
}
// โโโ Booking service: Redisson distributed lock + @Transactional โโโโโโโโโโโโโโ
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.TimeUnit;
@Service
public class BookingService {
private final SeatRepository seatRepo;
private final RedissonClient redisson; // injected via Spring Boot auto-config
public BookingService(SeatRepository seatRepo, RedissonClient redisson) {
this.seatRepo = seatRepo;
this.redisson = redisson;
}
/**
* Lock the seat for 10 minutes (payment window).
* Redisson RLock prevents two JVM nodes booking the same seat concurrently.
*/
@Transactional
public String lockSeat(Long seatId, String userId) throws InterruptedException {
String lockKey = "seat:lock:" + seatId;
RLock lock = redisson.getLock(lockKey);
// Try to acquire; fail fast if another user already holds it (0 wait)
boolean acquired = lock.tryLock(0, 10, TimeUnit.MINUTES);
if (!acquired) {
return "SEAT_ALREADY_LOCKED";
}
Seat seat = seatRepo.findByIdForUpdate(seatId)
.orElseThrow(() -> new IllegalArgumentException("Seat not found: " + seatId));
if (seat.getStatus() != SeatStatus.AVAILABLE) {
lock.unlock();
return "SEAT_NOT_AVAILABLE";
}
seat.setStatus(SeatStatus.LOCKED);
seatRepo.save(seat); // @Transactional ensures DB + Redisson are in sync
return "LOCKED"; // caller proceeds to payment flow
}
/** Confirm booking after successful payment */
@Transactional
public void confirmBooking(Long seatId) {
Seat seat = seatRepo.findByIdForUpdate(seatId).orElseThrow();
if (seat.getStatus() != SeatStatus.LOCKED) {
throw new IllegalStateException("Seat " + seatId + " is not in LOCKED state");
}
seat.setStatus(SeatStatus.BOOKED);
seatRepo.save(seat);
// Redisson lock auto-expires after 10 min โ no explicit unlock needed on confirm
}
}
@Version handles single-JVM concurrency: if two threads read the same version=5 and both try to UPDATE, the second update throws OptimisticLockException โ the caller retries. Redisson's RLock handles multi-JVM concurrency: only one node across the entire cluster can hold seat:lock:42 at a time, and the 10-minute TTL automatically releases abandoned locks without a background job.
For a full deep-dive on Spring Data JPA pessimistic/optimistic locking and Redisson distributed patterns, a dedicated follow-up post is planned.
๐ Design Lessons from the Booking System
- Never go directly from check to commit. The AVAILABLE โ LOCKED โ BOOKED progression prevents the classic TOCTOU (Time-of-Check to Time-of-Use) race condition. Any two-step "check then act" operation needs an atomic transition protecting it.
- TTLs are a correctness requirement, not an optimization. Without lock expiry, one abandoned checkout session can freeze a seat until the show starts. Design every temporary lock with a TTL from day one.
synchronizedis a valid starting point. Start simple withsynchronizedon the Show method for single-server deployments. Migrate to Redis SETNX or DB optimistic locking only when operational scale demands it โ don't over-engineer upfront.- Strategy Pattern keeps pricing logic decoupled. Hardcoding seat prices inside the booking method creates a maintenance trap. Pricing rules change seasonally and by event; the booking engine should never change with them.
- The domain hierarchy shapes your database indexes. City โ Cinema โ Screen โ Show โ Seat is not just an OOP diagram โ it determines how you index, partition, and cache your data at scale.
๐ TLDR: Summary & Key Takeaways
- Encapsulation:
Seatowns itsSeatStatusstate machine โ external code callsreserve()/release()/confirm(), never sets status directly.synchronizedmethods enforce both the contract and thread safety. - 3-state seat model (AVAILABLE โ LOCKED โ BOOKED) prevents double-booking and handles abandoned checkouts via TTL expiry.
synchronized bookSeats()makes the check-and-lock atomic; no two threads can hold the same seat simultaneously.- Abstraction + Strategy Pattern:
PricingStrategydecouples fee rules fromBookingServiceโ addHolidayPricingwithout touching the service. - SOLID OCP: every extension point (
VipSeat,RazorpayGateway,AdjacentSeatSelector) is a new class, never a modification to an existing one. - DIP:
BookingServicedepends onPricingStrategyandPaymentGatewayabstractions injected via constructor โ concrete providers are swappable at configuration time.
๐ Related Posts
- LLD for Parking Lot System โ another bounded-inventory OOD problem; slots use the same AVAILABLE/OCCUPIED state model
- LLD for Elevator System โ state machine design applied to request scheduling and direction logic
- LLD for URL Shortener โ LLD with hashing, collision handling, and storage trade-offs
- LLD for LRU Cache โ concurrency-aware caching with eviction policies; pairs well with the seat-lock TTL pattern
- Single Responsibility Principle โ the OOD principle driving class separation in the domain hierarchy above
- Strategy Design Pattern โ the pattern powering the pluggable pricing engine
Test Your Knowledge
Ready to test what you just learned?
AI will generate 4 questions based on this article's content.

Written by
Abstract Algorithms
@abstractalgorithms
More Posts
RAG vs Fine-Tuning: When to Use Each (and When to Combine Them)
TLDR: RAG gives LLMs access to current knowledge at inference time; fine-tuning changes how they reason and write. Use RAG when your data changes. Use fine-tuning when you need consistent style, tone, or domain reasoning. Use both for production assi...
Fine-Tuning LLMs with LoRA and QLoRA: A Practical Deep-Dive
TLDR: LoRA freezes the base model and trains two tiny matrices per layer โ 0.1 % of parameters, 70 % less GPU memory, near-identical quality. QLoRA adds 4-bit NF4 quantization of the frozen base, enabling 70B fine-tuning on 2ร A100 80 GB instead of 8...
Build vs Buy: Deploying Your Own LLM vs Using ChatGPT, Gemini, and Claude APIs
TLDR: Use the API until you hit $10K/month or a hard data privacy requirement. Then add a semantic cache. Then evaluate hybrid routing. Self-hosting full model serving is only cost-effective at > 50M tokens/day with a dedicated MLOps team. The build ...
Watermarking and Late Data Handling in Spark Structured Streaming
TLDR: A watermark tells Spark Structured Streaming: "I will accept events up to N minutes late, and then I am done waiting." Spark tracks the maximum event time seen per partition, takes the global minimum across all partitions, subtracts the thresho...
