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
ยทยท21 min read
Share
Share on X / Twitter
Share on LinkedIn
Copy link

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 {
        <>
        +calculatePrice(Seat, Show, Customer) Money
    }
    class PaymentGateway {
        <>
        +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)
  • 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])

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).


โš™๏ธ 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
    }
}

๐Ÿ“ 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.


โš–๏ธ 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.


๐Ÿ“š 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.

๐Ÿงญ 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.


๐Ÿ“Œ 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.

๐Ÿ“ Practice Quiz

  1. Why is a 3-state model (AVAILABLE โ†’ LOCKED โ†’ BOOKED) better than a 2-state model (AVAILABLE โ†’ BOOKED)?

    • A) It reduces database writes.
    • B) The LOCKED state holds the seat for the user during payment without permanently booking it, allowing auto-release on timeout.
    • C) It's required by cinema software standards.
      Correct Answer: B โ€” The LOCKED state holds the seat during payment without permanently booking it, allowing automatic release on timeout or failure.
  2. In the single-server Java implementation, what prevents two threads from both seeing a seat as AVAILABLE and both locking it?

    • A) volatile on the seat status field.
    • B) synchronized on bookSeats() โ€” only one thread executes the check-and-lock block at a time.
    • C) A database constraint.
      Correct Answer: B โ€” synchronized on bookSeats() ensures only one thread executes the check-and-lock block at a time, making the check-then-lock atomic.
  3. At multi-server scale, which mechanism replaces Java's synchronized keyword for preventing double booking?

    • A) Thread pools with fixed queue size.
    • B) Distributed lock via Redis SETNX (or DB optimistic locking with CAS).
    • C) Read replicas for the seat table.
      Correct Answer: B โ€” Redis SETNX with a TTL-keyed lock (or DB CAS) replaces the JVM synchronized block when booking requests span multiple server instances.

๐Ÿ› ๏ธ 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.



Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms