All Posts

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 AlgorithmsAbstract Algorithms
ยทยท25 min read

AI-assisted content.

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:

  1. Double-booking: User A and User B click "Book Seat A1" at the same millisecond. Only one should succeed.
  2. 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:

EntityWhat It RepresentsKey Attributes
MovieThe film being screenedtitle, duration, genre
CinemaThe physical venuename, city, list of screens
ScreenA single auditoriumid, total seats, seat layout
ShowOne screening of a movie on a screenmovie, screen, startTime
SeatAn individual seat in a screenrow, column, SeatType, SeatStatus
BookingA confirmed reservationuser, show, seats, paymentStatus
UserThe person making the reservationname, email, booking history

SeatType classifies seats by price tier:

  • PLATINUM โ€” premium recliner seats (front/center)
  • GOLD โ€” standard premium tier
  • SILVER โ€” economy tier

SeatStatus is the concurrency-critical state that drives the entire booking engine:

  • AVAILABLE โ€” free to book by any user
  • LOCKED โ€” 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:

TimeUser AUser BSeat A1 Status
10:00:00Selects A1โ€”AVAILABLE
10:00:01System locks A1โ€”LOCKED (expires 10:10)
10:00:02Directed to paymentSelects A1โ€”
10:00:02โ€”System: seat unavailableLOCKED (no change)
10:05:00Payment 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:

  1. 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.
  2. 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);
}
InterfaceWhat It AbstractsImplementorsConsumer
PricingStrategyFee calculation rulesHourlyPricing, DynamicPricing, GroupRatePricingBookingService
SeatSelectorSeat availability search and rankingDefaultSeatSelector, AdjacentSeatSelectorBookingService
PaymentGatewayPayment provider API callsStripeGateway, RazorpayGatewayBookingService

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

PrincipleHow It Appears in the Design
SRPSeat 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
OCPAdding 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
LSPPremiumSeat 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
ISPPricingStrategy is a single-method interface; SeatSelector is a separate interface. A class that only discovers seats never has to implement calculatePrice, and vice versa
DIPBookingService 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:

  • BookingService is injected via constructor (not new) โ€” this decouples the HTTP layer from the domain. Swapping BookingService for a mock in tests requires zero changes to the controller.
  • POST /api/bookings maps to the book() use case. The synchronized block inside BookingService.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.
  • SeatUnavailableException is a domain exception; the controller converts it to a 409 Conflict without leaking domain details to the client.

โš–๏ธ OOP Design Trade-offs and Failure Modes in the Booking System

Design DecisionTrade-off
3-state model vs. 2-stateThree 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 accessSeat.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 methodInjecting 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 depthOne 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 interfacesPricingStrategy 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:

DomainResource Being ReservedTypical Lock TTL
Flight bookingAircraft seat15โ€“30 min (complex international payment)
Concert ticketsVenue seat or GA slot5โ€“10 min (short to limit bot abuse)
Hotel roomsRoom-night inventory15 min
Sports eventsStadium seat8โ€“12 min
Exam slot bookingTest center seat + timeslot20 min
Parking reservationsParking spaceVariable

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 PointRecommendation
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 ruleHolidayPricing implements PricingStrategy โ€” never add an if (isHoliday) branch inside BookingService. The Strategy pattern exists to absorb exactly this kind of change
New seat selection algorithmAdjacentSeatSelector implements SeatSelector โ€” the interface isolates layout logic from booking orchestration. Swap selectors via constructor injection without touching BookingService
Payment provider switchRazorpayGateway implements PaymentGateway โ€” the gateway interface abstracts the entire provider SDK. BookingService needs zero changes
synchronized vs. distributed lockingsynchronized 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 SeatSeat 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.
  • synchronized is a valid starting point. Start simple with synchronized on 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: Seat owns its SeatStatus state machine โ€” external code calls reserve()/release()/confirm(), never sets status directly. synchronized methods 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: PricingStrategy decouples fee rules from BookingService โ€” add HolidayPricing without touching the service.
  • SOLID OCP: every extension point (VipSeat, RazorpayGateway, AdjacentSeatSelector) is a new class, never a modification to an existing one.
  • DIP: BookingService depends on PricingStrategy and PaymentGateway abstractions injected via constructor โ€” concrete providers are swappable at configuration time.


Share

Test Your Knowledge

๐Ÿง 

Ready to test what you just learned?

AI will generate 4 questions based on this article's content.

Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms