Problem 04 · Complete walkthrough•Intermediate•50 min

Movie Ticket Booking

Prefer to learn one decision at a time?

Try questions, reveal the reasoning, trace the code, and finish with the simulator.

Start Focus Mode

Design a movie-ticket booking service that lets a user hold seats for a short time and confirm them without ever selling one seat to two people.

Two moviegoers selecting the same highlighted cinema seats from their phones while one confirmed ticket is shown
The visible action is simple—choose seats and receive a ticket—but the design must decide who wins when two people choose the same seat together.

1. Requirements (~5 minutes)

The interviewer begins with one sentence:

“Design a movie-ticket booking system.”

Do not start drawing Movie, Theatre, and Payment classes. The sentence does not yet tell us what “booking” means. Does selecting a seat immediately buy it? Can a group reserve several seats together? What happens if payment takes two minutes? Most importantly, what happens when two users press the same seat at almost the same time?

Turn the story into actions and rules first. The classes will come from those answers.

Use these four question groups every time

1
Actions

What must a user be able to do?

2
Rules

When does an action succeed, fail, or change state?

3
Errors

Which invalid actions must we reject?

4
Boundaries

What should we deliberately not build?

Ask these questions

AskWhat can the user do in the first version?

Interviewer answer

Shows already exist. A user chooses one show and one or more seat IDs, holds them, and then confirms the hold.

AskWhy use a hold instead of booking the selected seat immediately?

Interviewer answer

A successful hold owns the requested seats for five minutes. Confirmation during that time makes them permanently booked.

AskIf a user requests A5 and A6 but A6 is unavailable, may we hold only A5?

Interviewer answer

No. Holding a group is all-or-nothing. Either every requested seat is held or none of them changes.

AskWhat happens when the five-minute hold expires?

Interviewer answer

The next operation releases expired seats. Confirming an expired hold returns HOLD_EXPIRED.

AskMay another user confirm my hold?

Interviewer answer

No. Only the user who created the live hold may confirm it.

AskCan two users request the same seat at the same time?

Interviewer answer

Yes. In this interview version everything runs in one Java process. Use one lock per show, and exactly one competing hold may succeed.

AskAre payment, refunds, a database, and several application servers in scope?

Interviewer answer

No. Treat successful confirmation as payment having completed. Keep everything in memory in one process. Discuss production alternatives later.

Confirm the specification

Confirmed specification

7 requirements · 7 boundaries

The first version must

  1. 1.Shows are created before booking. Each show has its own copy of every physical screen seat's booking state.
  2. 2.A user may request one or more unique seat IDs for one show.
  3. 3.Holding a group is all-or-nothing: every seat becomes HELD for that user, or no requested seat changes.
  4. 4.A hold lasts five minutes. Expired HELD seats become AVAILABLE again.
  5. 5.Only the hold owner may confirm a live hold. Confirmation changes every held seat to BOOKED and returns a Booking.
  6. 6.Unknown shows or seats, duplicate seat IDs, unavailable seats, unknown holds, expired holds, and the wrong owner return named result values.
  7. 7.When two threads request the same seat in one Java process, a per-show lock makes exactly one request succeed.
Not building in this version7 boundaries
  • Movie and theatre search
  • Real payment processing
  • Cancellation and refunds
  • Persistent database storage
  • Several application servers or distributed locking
  • Seat pricing and discount policies
  • Notifications and ticket delivery

2. Entities and relationships (~3 minutes)

Now scan the confirmed requirements for nouns: movie, screen, seat, show, show seat, user, hold, booking, payment, and booking service. These are only candidates. A real thing becomes a class in our program only when it owns meaningful information or a rule.

Final model

Types we will create

Class
Owns changing state or coordinates rules.
Record
A Java record for validated, immutable data with value equality.
Interface
Defines behavior that another implementation can replace.
MovieRecord

Keeps a movie's stable ID and title.

SeatRecord

Identifies one permanent physical seat.

ScreenClass

Owns the permanent physical seat layout.

ShowSeatClass

Owns one seat's state for one screening.

ShowClass

Protects group seat changes and the per-show lock.

SeatHoldRecord

Records an accepted temporary seat claim.

BookingRecord

Records a completed confirmation.

BookingServiceClass

Coordinates lookup, IDs, holds, and bookings.

HoldResultRecord

Returns a hold status and accepted hold.

ConfirmationResultRecord

Returns a confirmation status and booking.

Enums

Enums name a small, fixed set of valid choices or outcomes.

SeatState

Limits a show seat to AVAILABLE, HELD, or BOOKED.

HoldStatus

Explains the result of a hold request.

ConfirmationStatus

Explains the result of confirming a hold.

Fields

Fields are simple values stored inside another type; they do not need independent behavior in this version.

userId

Identifies who owns a temporary hold.

showId

Finds the screening whose seats may change.

startTime

Places a Show at a fixed screening time.

expiresAt

Defines when a held seat becomes available again.

See a seat request flow through the objects

Swipe to follow the flow →

A moviegoer selects A7, BookingService asks a locked Show to check its ShowSeat state, and returns a SeatHold or rejection before confirmation creates a Booking
Read left to right: the service coordinates, the show protects the group operation, and each show seat owns its state.

Follow the arrows. The moviegoer selects A7 and calls BookingService. The service finds the correct Show and asks it to hold the seat. Show acquires its one lock, checks the relevant ShowSeat, and changes AVAILABLE to HELD only if the whole request is valid. A successful change creates a SeatHold; otherwise the caller receives a rejection. Later, confirmation follows the hold back into the same Show, changes HELD to BOOKED, and creates a Booking.

The key ownership decision is visible in the middle: BookingService finds and records, Show protects the all-or-nothing operation, and ShowSeat owns one seat's valid state. No object reaches across those boundaries to set raw fields.

3. Class design (10–15 minutes)

Use the same bridge as every other lesson:

requirement → responsible object → state it remembers → method that changes or reads that state

Start with BookingService.holdSeats, the action visible to the caller. Then follow its needs into Show.tryHold and finally into ShowSeat transitions.

One class at a time

Design the state owners

Start with the coordinator, then follow the request into the classes that protect changing state. Select a class to see only the fields and behavior needed for its responsibility.

Class 1 of 4

BookingService

Coordinates show lookup, IDs, accepted holds, and confirmed bookings.

Requirement behind this class

Callers hold a group, confirm by owner before expiry, query seat state, and receive named rejection results.

State it remembers

Private information required to perform this class's job.

Map<String, Show> showsById

Finds the screening whose seats may change.

Map<String, SeatHold> holdsById

Finds accepted holds during confirmation.

Map<String, Booking> bookingsById

Keeps completed bookings available for safe reads.

Clock clock

Makes expiry decisions deterministic and testable.

Duration holdDuration

Defines the confirmed temporary ownership window.

AtomicInteger holdSequence, bookingSequence

Allocates unique in-process IDs across concurrent calls.

Behavior it owns

Public use cases and internal rules kept beside that state.

holdSeats(showId, userId, seatIds)Public API

Looks up the Show and records a hold only after atomic seat success.

confirmHold(holdId, userId)Public API

Delegates protected confirmation and records the accepted Booking.

seatStates(showId) / booking(id)Public API

Expose immutable snapshots and accepted results.

allocate IDs / update indexesInternal

Record success after Show completes the protected operation.

Invariant it protects
Service indexes contain only holds and bookings accepted by the owning Show.
Works with
Show, SeatHold, Booking, Clock, and named result records.
Does not own
Individual seat transitions, group atomicity, or lock mechanics.
Principle demonstrated
Single responsibility keeps lookup and indexing separate from protected seat state.

Supporting types

These types carry stable data or define replaceable behavior. They support the state owners without becoming another place to store live workflow state.

Movie, Seat, SeatHold, BookingRecord

Carry stable catalog, layout, hold, and booking facts.

Validated immutable data can cross thread and service boundaries without exposing mutable Show state.

HoldResult, ConfirmationResultRecord

Return expected success or rejection as named data.

Callers can branch on status without exceptions or partially constructed domain state.

The explorer separates the stable Screen layout from each Show's mutable seats. The next sections deepen the two rules owned by Show and ShowSeat: legal state transitions and atomic group changes.

Understand the seat state machine

A state machine is simply a list of allowed states and allowed moves between them. Here there are only three:

AVAILABLE --successful hold--> HELD --confirmation--> BOOKED
                              |
                              +--expiry--> AVAILABLE

There is no direct AVAILABLE → BOOKED move because the confirmed requirements include a hold. There is no BOOKED → AVAILABLE move because cancellation is outside scope. Writing these omissions is useful: it stops an interviewer from wondering whether we forgot them.

Concurrency: why a normal availability check is not enough

Imagine User A and User B both request A7. This unsafe code looks reasonable:

if (seat.isAvailable()) {
    seat.placeHold(...);
    return HELD;
}

The problem is that the if check and the state change are separate actions. The operating system may pause one thread between them.

  1. 1User A checks A7. The answer is AVAILABLE.
  2. 2Before A changes anything, User B checks A7. B also sees AVAILABLE.
  3. 3A changes A7 to HELD and returns success.
  4. 4B continues using its old answer, changes A7, and also returns success.
  5. 5Two users were promised one seat.

This ordering is called an interleaving: the steps of two callers are mixed. The bug is a race condition because the result depends on which thread happens to run at each moment.

The three steps below form the critical section—the part that only one thread may execute at a time:

check every requested seat → change every seat to HELD → record success

An operation is atomic when other threads observe it as one unit: they see either the state before it or the state after it, never a half-completed group.

Our choice: one lock per Show

Every Show owns one ReentrantLock. A caller acquires it before releasing expired holds, validating the entire group, and changing the group. The caller releases it in a finally block, so an unexpected exception cannot leave the show permanently locked.

This is a coarse lock because it covers every seat in one show. It is a good interview choice:

  • The correctness argument is short: only one thread can change a show's seat map at a time.
  • A request for A5 and A6 needs only one lock, so there is no partial group booking.
  • Different shows have different locks, so the 7:00 PM and 10:00 PM shows can still process work together.
  • The trade-off is lower parallel work: a user holding A1 briefly makes a user holding F20 wait, even though the seats do not overlap.

A fine lock per seat would let unrelated seats proceed together. But a group request would need several locks. Every caller would have to acquire them in the same sorted order; otherwise one thread could hold A5 while waiting for A6 and another could hold A6 while waiting for A5. That cycle is a deadlock: both wait forever. The added complexity is not justified for this in-memory interview scope.

Why holds need an injected Clock

Code that calls Instant.now() everywhere is difficult to test: a five-minute expiry test would either wait or depend on timing luck. BookingService receives a Java Clock. Production can use the system clock; the demo uses MutableClock and advances time immediately. The business rule stays real while the test stays fast and repeatable.

Complexity and limits

For k requested seats, checking and updating the group costs O(k). This lesson scans the seats in one show to release expired holds, so an operation also costs O(S) for S seats. State uses O(S + H + B) space for show seats, accepted holds, and bookings. A larger service could maintain an expiry queue, but that would add bookkeeping that this interview version does not need.

Decide whether patterns help

Do not force: State pattern

We do have states, but only three simple transitions and very little state-specific behavior. An enum plus guarded methods in ShowSeat is easier to follow. Separate AVAILABLE, HELD, and BOOKED classes would add indirection without solving a current problem.

Do not force: Strategy for pricing or payment

The core design has no price calculation or payment provider. There is no changing rule to isolate yet. If Extensions add several pricing policies or gateways, that requirement would create a Strategy interface at that time.

Do not force: Singleton BookingService

A global instance would make tests share state and would not make several servers consistent. Normal construction and dependency injection make the clock and sample shows explicit.

Compare the important production alternatives

Optimistic version checkAllow callers to read together, but accept a write only if the seat version has not changed.

A seat row can store a version number. A caller reads A7 at version 12 and attempts an update with “only if version is still 12.” One caller changes it to 13; the other update affects zero rows and must retry or reject. This permits more parallel reads, but it needs a shared storage system that can perform the conditional write atomically.

Database transaction and constraintLet one shared database make the final ownership decision across application servers.

A transaction can lock the requested seat rows, verify them, insert the hold, and update the states before committing. A unique constraint can prevent two active ownership records for the same show and seat. This is the usual direction when the system moves beyond one process, but database design is outside this LLD implementation.

Temporary hold serviceStore holds in shared storage and expire them reliably when payment may run on another server.

Production systems often keep a shared hold record with an expiry and a background cleanup process. The final booking operation verifies the live hold inside a transaction. Our SeatHold models the same idea locally, which makes the later upgrade understandable rather than magical.

Prove the rules interactively

Use Booking flow to hold two seats, advance time, confirm, or load an all-or-nothing rejection. Then use Concurrency race to compare the same A-versus-B ordering with and without the show lock.

Try the state and lock rules

Movie booking lab

Screen 1 · 7:00 PM

Row A

minute 0
Screen
6 available0 held0 booked

User YOU

Selected seats

A2, A3

What the objects did

  1. Show opened. Every seat is available.

Connect the classes and review the final blueprint

BookingService composes Shows; each Show references one Movie and Screen and owns its own ShowSeat collection. These are has-a relationships. Inheritance would not express the model: a Show is not a Screen, and a BookingService is not a Show.

  • Immutability: requests and accepted receipts cannot change after creation.
  • Encapsulation: ShowSeat guards one lifecycle; Show guards the whole requested group.
  • Single responsibility: Screen owns layout, Show owns per-screening state, and BookingService owns lookup and accepted records.
  • Thread safety: one Show lock protects check-all, change-all, and confirmation inside one process.
  • Composition: the coordinator delegates into state owners instead of reaching into their fields.
Sketch UML of Movie Booking records

How to read it: Movie, Seat, hold, booking, and result records preserve facts that should not change after creation.

Design principle: Immutability keeps request intent and accepted receipts stable.

4. Java implementation (~10 minutes)

What the code does

This Java 17 program creates movies, screens, and shows; accepts all-or-nothing five-minute seat holds; confirms only a live hold's owner; releases expired seats; and proves that two concurrent requests for A7 produce exactly one success.

Why it is structured this way

BookingService coordinates IDs and lookup. Show owns the lock and the group operation. ShowSeat owns one seat's valid state. Immutable records carry accepted results. An injected Clock makes expiry deterministic.

Follow one request through the code

  1. 1BookingService finds the show and creates a candidate hold ID and expiry.
  2. 2Show acquires its lock and releases any expired HELD seats.
  3. 3Show validates every requested ID and state without changing a requested seat.
  4. 4Only after all checks pass, each ShowSeat moves to HELD for the same hold.
  5. 5BookingService records and returns the immutable SeatHold.
  6. 6Confirmation checks the owner, then Show changes the same seats to BOOKED under its lock.
ReentrantLockThe one primitive that protects a show's check-and-change section.

lock.lock() waits until the current thread owns the show lock. While it owns the lock, another thread calling the protected methods on that Show waits. unlock() is placed in finally, which guarantees release even if the protected code throws. We never acquire a second lock while holding this one, so this design cannot form a lock cycle.

ConcurrentHashMapThread-safe service indexes, not the seat-booking guarantee.

Competing calls may add or remove different hold and booking records. ConcurrentHashMap keeps those map operations safe. It does not make “check several seats, then change several seats” atomic; the Show lock provides that larger guarantee.

AtomicIntegerGenerate unique in-process IDs when several threads call the service.

getAndIncrement() returns one number and advances it as a single atomic action. Two threads therefore cannot receive the same hold sequence number. Like the lock, this uniqueness exists only inside this service process.

CountDownLatchStart both demo tasks together without relying on sleep or timing luck.

Each task signals that it is ready and waits at the start latch. The demo releases both after both are ready, then counts the successful results. We do not assert which user wins because thread scheduling is intentionally unknown; we assert the correctness property that exactly one wins.

SeatState.java
Names the only three seat states in scope.
public enum SeatState {
  AVAILABLE,
  HELD,
  BOOKED
}
HoldStatus.java
Explains every expected hold result without exceptions.
public enum HoldStatus {
  HELD,
  INVALID_REQUEST,
  SHOW_NOT_FOUND,
  SEAT_NOT_FOUND,
  SEAT_UNAVAILABLE
}
ConfirmationStatus.java
Explains every expected confirmation result.
public enum ConfirmationStatus {
  BOOKED,
  HOLD_NOT_FOUND,
  HOLD_EXPIRED,
  NOT_HOLD_OWNER
}
Movie.java
Immutable identity and display title for a movie.
public record Movie(String id, String title) {
  public Movie {
      if (id == null || id.isBlank()) {
          throw new IllegalArgumentException("Movie id is required");
      }
      if (title == null || title.isBlank()) {
          throw new IllegalArgumentException("Movie title is required");
      }
  }
}
Seat.java
Describes one permanent physical seat in a screen.
public record Seat(String id) {
  public Seat {
      if (id == null || id.isBlank()) {
          throw new IllegalArgumentException("Seat id is required");
      }
  }
}
Screen.java
Owns a fixed, unique set of physical seats.
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public final class Screen {
  private final String id;
  private final Map<String, Seat> seatsById;

  public Screen(String id, List<Seat> seats) {
      if (id == null || id.isBlank()) {
          throw new IllegalArgumentException("Screen id is required");
      }
      if (seats == null || seats.isEmpty()) {
          throw new IllegalArgumentException("A screen needs seats");
      }

      this.id = id;
      this.seatsById = new LinkedHashMap<>();
      for (Seat seat : seats) {
          if (seatsById.putIfAbsent(seat.id(), seat) != null) {
              throw new IllegalArgumentException("Duplicate seat: " + seat.id());
          }
      }
  }

  public String id() {
      return id;
  }

  public List<Seat> seats() {
      return List.copyOf(seatsById.values());
  }
}
ShowSeat.java
Owns one seat's state and legal transitions for one show.
import java.time.Instant;

public final class ShowSeat {
  private final Seat seat;
  private SeatState state = SeatState.AVAILABLE;
  private String holdId;
  private String heldBy;
  private Instant expiresAt;

  public ShowSeat(Seat seat) {
      if (seat == null) {
          throw new IllegalArgumentException("Seat is required");
      }
      this.seat = seat;
  }

  public String id() {
      return seat.id();
  }

  public SeatState state() {
      return state;
  }

  public boolean isAvailable() {
      return state == SeatState.AVAILABLE;
  }

  public void placeHold(String holdId, String userId, Instant expiresAt) {
      if (!isAvailable()) {
          throw new IllegalStateException("Seat is not available: " + id());
      }
      this.state = SeatState.HELD;
      this.holdId = holdId;
      this.heldBy = userId;
      this.expiresAt = expiresAt;
  }

  public boolean isHeldBy(String expectedHoldId, String userId) {
      return state == SeatState.HELD
              && holdId.equals(expectedHoldId)
              && heldBy.equals(userId);
  }

  public boolean confirmHold(String expectedHoldId, String userId) {
      if (!isHeldBy(expectedHoldId, userId)) {
          return false;
      }
      state = SeatState.BOOKED;
      clearHoldDetails();
      return true;
  }

  public boolean releaseIfExpired(Instant now) {
      if (state == SeatState.HELD && !now.isBefore(expiresAt)) {
          state = SeatState.AVAILABLE;
          clearHoldDetails();
          return true;
      }
      return false;
  }

  private void clearHoldDetails() {
      holdId = null;
      heldBy = null;
      expiresAt = null;
  }
}
SeatHold.java
Immutable accepted temporary claim on a group of seats.
import java.time.Instant;
import java.util.List;

public record SeatHold(
      String id,
      String showId,
      String userId,
      List<String> seatIds,
      Instant expiresAt
) {
  public SeatHold {
      if (id == null || id.isBlank()
              || showId == null || showId.isBlank()
              || userId == null || userId.isBlank()) {
          throw new IllegalArgumentException("Hold identity fields are required");
      }
      if (seatIds == null || seatIds.isEmpty() || expiresAt == null) {
          throw new IllegalArgumentException("Hold seats and expiry are required");
      }
      seatIds = List.copyOf(seatIds);
  }
}
Booking.java
Immutable receipt created after a successful confirmation.
import java.time.Instant;
import java.util.List;

public record Booking(
      String id,
      String showId,
      String userId,
      List<String> seatIds,
      Instant bookedAt
) {
  public Booking {
      if (id == null || id.isBlank()
              || showId == null || showId.isBlank()
              || userId == null || userId.isBlank()) {
          throw new IllegalArgumentException("Booking identity fields are required");
      }
      if (seatIds == null || seatIds.isEmpty() || bookedAt == null) {
          throw new IllegalArgumentException("Booking seats and time are required");
      }
      seatIds = List.copyOf(seatIds);
  }
}
HoldResult.java
Carries a status and an optional accepted hold.
import java.util.Optional;

public record HoldResult(HoldStatus status, Optional<SeatHold> hold) {
  public HoldResult {
      if (status == null || hold == null) {
          throw new IllegalArgumentException("Result fields are required");
      }
  }

  public static HoldResult accepted(SeatHold hold) {
      return new HoldResult(HoldStatus.HELD, Optional.of(hold));
  }

  public static HoldResult rejected(HoldStatus status) {
      if (status == HoldStatus.HELD) {
          throw new IllegalArgumentException("Use accepted for HELD");
      }
      return new HoldResult(status, Optional.empty());
  }
}
ConfirmationResult.java
Carries a status and an optional completed booking.
import java.util.Optional;

public record ConfirmationResult(
      ConfirmationStatus status,
      Optional<Booking> booking
) {
  public ConfirmationResult {
      if (status == null || booking == null) {
          throw new IllegalArgumentException("Result fields are required");
      }
  }

  public static ConfirmationResult accepted(Booking booking) {
      return new ConfirmationResult(
              ConfirmationStatus.BOOKED,
              Optional.of(booking)
      );
  }

  public static ConfirmationResult rejected(ConfirmationStatus status) {
      if (status == ConfirmationStatus.BOOKED) {
          throw new IllegalArgumentException("Use accepted for BOOKED");
      }
      return new ConfirmationResult(status, Optional.empty());
  }
}
Show.java
Owns per-show seat state, the lock, and atomic group transitions.
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.ReentrantLock;

public final class Show {
  private final String id;
  private final Movie movie;
  private final Screen screen;
  private final Instant startsAt;
  private final Map<String, ShowSeat> seatsById = new LinkedHashMap<>();
  private final ReentrantLock lock = new ReentrantLock();

  public Show(String id, Movie movie, Screen screen, Instant startsAt) {
      if (id == null || id.isBlank() || movie == null
              || screen == null || startsAt == null) {
          throw new IllegalArgumentException("Complete show details are required");
      }
      this.id = id;
      this.movie = movie;
      this.screen = screen;
      this.startsAt = startsAt;
      for (Seat seat : screen.seats()) {
          seatsById.put(seat.id(), new ShowSeat(seat));
      }
  }

  public String id() {
      return id;
  }

  public Movie movie() {
      return movie;
  }

  public Screen screen() {
      return screen;
  }

  public Instant startsAt() {
      return startsAt;
  }

  public HoldStatus tryHold(
          String holdId,
          String userId,
          List<String> requestedSeatIds,
          Instant now,
          Instant expiresAt
  ) {
      if (holdId == null || holdId.isBlank()
              || userId == null || userId.isBlank()
              || requestedSeatIds == null || requestedSeatIds.isEmpty()
              || now == null || expiresAt == null
              || !expiresAt.isAfter(now)) {
          return HoldStatus.INVALID_REQUEST;
      }

      Set<String> uniqueSeatIds = new LinkedHashSet<>(requestedSeatIds);
      if (uniqueSeatIds.size() != requestedSeatIds.size()
              || uniqueSeatIds.stream().anyMatch(id -> id == null || id.isBlank())) {
          return HoldStatus.INVALID_REQUEST;
      }

      lock.lock();
      try {
          releaseExpired(now);

          // First pass: validate the complete group without changing it.
          for (String seatId : uniqueSeatIds) {
              ShowSeat seat = seatsById.get(seatId);
              if (seat == null) {
                  return HoldStatus.SEAT_NOT_FOUND;
              }
              if (!seat.isAvailable()) {
                  return HoldStatus.SEAT_UNAVAILABLE;
              }
          }

          // Second pass: all checks passed, so change the whole group.
          for (String seatId : uniqueSeatIds) {
              seatsById.get(seatId).placeHold(holdId, userId, expiresAt);
          }
          return HoldStatus.HELD;
      } finally {
          lock.unlock();
      }
  }

  public ConfirmationStatus confirmHold(SeatHold hold, Instant now) {
      lock.lock();
      try {
          if (!now.isBefore(hold.expiresAt())) {
              releaseExpired(now);
              return ConfirmationStatus.HOLD_EXPIRED;
          }

          for (String seatId : hold.seatIds()) {
              ShowSeat seat = seatsById.get(seatId);
              if (seat == null || !seat.isHeldBy(hold.id(), hold.userId())) {
                  return ConfirmationStatus.HOLD_NOT_FOUND;
              }
          }
          for (String seatId : hold.seatIds()) {
              seatsById.get(seatId).confirmHold(hold.id(), hold.userId());
          }
          return ConfirmationStatus.BOOKED;
      } finally {
          lock.unlock();
      }
  }

  public Map<String, SeatState> seatStates(Instant now) {
      lock.lock();
      try {
          releaseExpired(now);
          Map<String, SeatState> snapshot = new LinkedHashMap<>();
          for (ShowSeat seat : seatsById.values()) {
              snapshot.put(seat.id(), seat.state());
          }
          return Map.copyOf(snapshot);
      } finally {
          lock.unlock();
      }
  }

  private void releaseExpired(Instant now) {
      for (ShowSeat seat : seatsById.values()) {
          seat.releaseIfExpired(now);
      }
  }
}
BookingService.java
Coordinates lookup, IDs, result records, and the injected clock.
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

public final class BookingService {
  private final Map<String, Show> showsById;
  private final Map<String, SeatHold> holdsById = new ConcurrentHashMap<>();
  private final Map<String, Booking> bookingsById = new ConcurrentHashMap<>();
  private final AtomicInteger holdSequence = new AtomicInteger(1);
  private final AtomicInteger bookingSequence = new AtomicInteger(1);
  private final Clock clock;
  private final Duration holdDuration;

  public BookingService(List<Show> shows, Clock clock, Duration holdDuration) {
      if (shows == null || shows.isEmpty() || clock == null
              || holdDuration == null || holdDuration.isNegative()
              || holdDuration.isZero()) {
          throw new IllegalArgumentException("Shows, clock, and hold duration are required");
      }
      this.clock = clock;
      this.holdDuration = holdDuration;
      this.showsById = new LinkedHashMap<>();
      for (Show show : shows) {
          if (showsById.putIfAbsent(show.id(), show) != null) {
              throw new IllegalArgumentException("Duplicate show: " + show.id());
          }
      }
  }

  public HoldResult holdSeats(
          String showId,
          String userId,
          List<String> seatIds
  ) {
      Show show = showsById.get(showId);
      if (show == null) {
          return HoldResult.rejected(HoldStatus.SHOW_NOT_FOUND);
      }

      Instant now = clock.instant();
      String holdId = "H" + holdSequence.getAndIncrement();
      Instant expiresAt = now.plus(holdDuration);
      HoldStatus status = show.tryHold(
              holdId,
              userId,
              seatIds,
              now,
              expiresAt
      );
      if (status != HoldStatus.HELD) {
          return HoldResult.rejected(status);
      }

      SeatHold hold = new SeatHold(
              holdId,
              showId,
              userId,
              seatIds,
              expiresAt
      );
      holdsById.put(holdId, hold);
      return HoldResult.accepted(hold);
  }

  public ConfirmationResult confirmHold(String holdId, String userId) {
      SeatHold hold = holdsById.get(holdId);
      if (hold == null) {
          return ConfirmationResult.rejected(
                  ConfirmationStatus.HOLD_NOT_FOUND
          );
      }
      if (!hold.userId().equals(userId)) {
          return ConfirmationResult.rejected(
                  ConfirmationStatus.NOT_HOLD_OWNER
          );
      }

      Instant now = clock.instant();
      Show show = showsById.get(hold.showId());
      ConfirmationStatus status = show.confirmHold(hold, now);
      if (status != ConfirmationStatus.BOOKED) {
          if (status == ConfirmationStatus.HOLD_EXPIRED) {
              holdsById.remove(holdId, hold);
          }
          return ConfirmationResult.rejected(status);
      }

      Booking booking = new Booking(
              "B" + bookingSequence.getAndIncrement(),
              hold.showId(),
              hold.userId(),
              hold.seatIds(),
              now
      );
      bookingsById.put(booking.id(), booking);
      holdsById.remove(holdId, hold);
      return ConfirmationResult.accepted(booking);
  }

  public Map<String, SeatState> seatStates(String showId) {
      Show show = showsById.get(showId);
      if (show == null) {
          return Map.of();
      }
      return show.seatStates(clock.instant());
  }

  public Optional<Booking> booking(String bookingId) {
      return Optional.ofNullable(bookingsById.get(bookingId));
  }
}
MutableClock.java
Lets the demo advance five minutes instantly and repeatably.
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.concurrent.atomic.AtomicReference;

public final class MutableClock extends Clock {
  private final AtomicReference<Instant> current;
  private final ZoneId zone;

  public MutableClock(Instant initialInstant, ZoneId zone) {
      if (initialInstant == null || zone == null) {
          throw new IllegalArgumentException("Initial time and zone are required");
      }
      this.current = new AtomicReference<>(initialInstant);
      this.zone = zone;
  }

  public void advance(Duration duration) {
      if (duration == null || duration.isNegative()) {
          throw new IllegalArgumentException("Advance duration cannot be negative");
      }
      current.updateAndGet(instant -> instant.plus(duration));
  }

  @Override
  public ZoneId getZone() {
      return zone;
  }

  @Override
  public Clock withZone(ZoneId newZone) {
      return new MutableClock(current.get(), newZone);
  }

  @Override
  public Instant instant() {
      return current.get();
  }
}
MovieBookingDemo.java
Runs normal, rejection, expiry, and deterministic concurrency checks.
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public final class MovieBookingDemo {
  public static void main(String[] args) throws Exception {
      MutableClock clock = new MutableClock(
              Instant.parse("2026-08-14T13:00:00Z"),
              ZoneOffset.UTC
      );

      BookingService normal = sampleService(clock);
      HoldResult hold = normal.holdSeats("S1", "user-a", List.of("A5", "A6"));
      System.out.println("normal hold: " + hold.status());
      String holdId = hold.hold().orElseThrow().id();
      System.out.println("normal confirm: "
              + normal.confirmHold(holdId, "user-a").status());

      BookingService group = sampleService(clock);
      group.holdSeats("S1", "first-user", List.of("A2"));
      HoldResult rejected = group.holdSeats(
              "S1",
              "second-user",
              List.of("A1", "A2")
      );
      System.out.println("group rejection: " + rejected.status());
      System.out.println("A1 after rejection: " + group.seatStates("S1").get("A1"));

      BookingService expiry = sampleService(clock);
      SeatHold expiring = expiry.holdSeats(
              "S1",
              "user-a",
              List.of("A3")
      ).hold().orElseThrow();
      clock.advance(Duration.ofMinutes(6));
      System.out.println("expired confirmation: "
              + expiry.confirmHold(expiring.id(), "user-a").status());
      System.out.println("seat reusable: "
              + expiry.holdSeats("S1", "user-b", List.of("A3")).status());

      BookingService concurrent = sampleService(clock);
      ExecutorService pool = Executors.newFixedThreadPool(2);
      CountDownLatch ready = new CountDownLatch(2);
      CountDownLatch start = new CountDownLatch(1);

      Callable<HoldStatus> userA = contender(
              concurrent,
              "user-a",
              ready,
              start
      );
      Callable<HoldStatus> userB = contender(
              concurrent,
              "user-b",
              ready,
              start
      );
      Future<HoldStatus> resultA = pool.submit(userA);
      Future<HoldStatus> resultB = pool.submit(userB);
      ready.await();
      start.countDown();

      long successCount = List.of(resultA.get(), resultB.get()).stream()
              .filter(status -> status == HoldStatus.HELD)
              .count();
      pool.shutdown();

      System.out.println("concurrent success count: " + successCount);
      System.out.println("A7 final state: "
              + concurrent.seatStates("S1").get("A7"));
      if (successCount != 1) {
          throw new AssertionError("Exactly one competing hold must succeed");
      }
  }

  private static Callable<HoldStatus> contender(
          BookingService service,
          String userId,
          CountDownLatch ready,
          CountDownLatch start
  ) {
      return () -> {
          ready.countDown();
          start.await();
          return service.holdSeats("S1", userId, List.of("A7")).status();
      };
  }

  private static BookingService sampleService(MutableClock clock) {
      Movie movie = new Movie("M1", "The Clockwork City");
      Screen screen = new Screen("SCREEN-1", List.of(
              new Seat("A1"),
              new Seat("A2"),
              new Seat("A3"),
              new Seat("A4"),
              new Seat("A5"),
              new Seat("A6"),
              new Seat("A7")
      ));
      Show show = new Show(
              "S1",
              movie,
              screen,
              Instant.parse("2026-08-14T19:00:00Z")
      );
      return new BookingService(
              List.of(show),
              clock,
              Duration.ofMinutes(5)
      );
  }
}

What to notice in the code

Show.tryHold intentionally makes two passes while holding one lock. The first pass only checks; the second pass changes. Returning during the first pass cannot leave A1 held when A2 fails. ShowSeat.placeHold still checks its own state, which protects its local rule against programmer misuse.

Show.confirmHold checks expiry before confirming. The same Show lock prevents a confirmation and a competing hold from changing the seats at the same moment. BookingService creates a Booking only after Show reports BOOKED, so the receipt never claims success before the state change succeeds.

The demo's concurrent winner is intentionally unknown, but its output property is deterministic: concurrent success count must always be 1 and A7 must finish as HELD.

5. Extensions (~5 minutes)

In an interview, extend the design by naming the new rule first. Then show the smallest boundary it changes.

Add real payment without holding a lock during a network call

Never keep the Show lock while calling a payment provider. Network calls can take seconds or fail. The hold already provides the safe boundary:

  1. Create a five-minute hold under the Show lock.
  2. Release the lock.
  3. Call the payment provider using the hold ID as the order reference.
  4. If payment succeeds before expiry, confirm the hold under the lock.
  5. If it fails, release the hold or let it expire.

If several providers are supported, PaymentStrategy becomes useful because the requirement now contains a replaceable behavior. It was not useful in the core design.

Move from one process to several servers

Replace the in-memory authority with shared storage. A database transaction should verify the live hold and create the booking together. Use conditional updates or constraints so only one server can claim a show-seat record. Keep the domain objects and state names, but do not pretend ReentrantLock crosses the network.

Make expiry efficient

The simple implementation scans a show's seats when an operation arrives. A busy service could keep holds ordered by expiresAt in a priority queue or a shared expiry index. A background worker releases the earliest expired records first. The difficult requirement is not the timer—it is making cleanup and confirmation agree atomically in shared storage.

Add pricing, seat categories, and refunds

Add SeatType and a pricing rule only when prices enter the requirements. If pricing varies by show, day, or promotion, a PricingStrategy can isolate that calculation. Cancellation would add a BOOKED → AVAILABLE transition only after the refund and cancellation rules are clarified; it should not be smuggled into the current state machine.