# Movie Ticket Booking — decision log

The Phase 5 answer key. Every entry is a question an interviewer actually asks ("why is that a
separate class?", "what exactly does that lock protect?") and the answer that earns the mark:
**what change or failure the decision anticipates, and what it would cost without it.**

Eleven classes, none close to 150 lines. `Show` is the biggest at around 90 lines and is the only
one with a lock in it; `MovieBookingSystem` has none.

| File | One reason to change |
|---|---|
| `MovieBookingSystem` | the sequence of validating a request and routing it to a show changes |
| `Show` | the mechanics of one show's holds, seats, or the reclaim rule change |
| `SeatSlot` | what is recorded about one seat's live state changes |
| `HoldRecord` | what is recorded about one hold's history changes |
| `HoldOutcome` | a new terminal state for a hold is introduced |
| `PricingPolicy` / `StandardPricing` | what a seat costs changes |
| `StandardCatalog` | what the fixed catalogue is made of changes |
| `Seat`, `SeatType`, `SeatStatus`, `HoldResult`, `Booking` | given by the contract, not ours to change |
| `Demo` | what we show when we run it changes |

---

## Concurrency first, because this problem is about concurrency

### The shared mutable state, named out loud

Exactly two pieces, and telling them apart is the design — the same shape elevator and
notification-service both use, for the same underlying reason.

1. **Each show's own seats and holds.** A `Show`'s `seats` map and `holds` map move together: a
   hold's seats and its record change state in lockstep, and nothing may observe one without the
   other. They share one lock, `Show`'s own monitor, and every method that touches either map is
   `synchronized`.
2. **`holdIndex`, the map from a `holdId` to the show that owns it.** This is the one piece of
   state that spans shows, and it is *not* protected by any show's lock, because it does not need
   to be: every key is written **exactly once**, by the thread that just minted a brand-new id,
   and never written again. A `ConcurrentHashMap` gives that single insert atomically for free.
   `MovieBookingSystem` never takes a lock of its own — there is nothing left for one to guard.

**What is deliberately not shared mutable state.** The map of shows itself (`Map<String, Show>`)
is built once in the constructor and never modified afterward — it is safely published through a
`final` field, the same reasoning elevator gives for its `lifts` list. That is why the shows map is
a plain `HashMap` and not a `ConcurrentHashMap`: nothing about its structure ever changes after
construction, so there is no concurrent write for a concurrent collection to protect against.

### Lock granularity: one lock per show, not one per building

`carCall`-style contention only matters within one show, so `Show` takes **only its own monitor**
and never anyone else's. Two different shows are two different objects with two different locks,
so hammering one can never delay the other from *acquiring* its own lock. See the honest limit of
this claim under "E3, and what was actually measured" below — the design is real, and a throughput
proof of it is not shipped, because one was tried and did not hold up.

### The hazard this problem is built around: reclaim-then-take must be one atomic step

`MovieBookingApi` states it directly: "is the current hold on this seat expired, and if so is the
seat free to take?" is two questions, and they must be answered as **one** atomic step. `Show`
answers both inside the same `synchronized` method call, every time — `tryHold` reclaims every
requested seat's lapsed hold, if any, and then checks and grants, all under one lock acquisition,
never releasing it in between.

**The bug this guards against, built and measured.** A variant of `tryHold` that decides whether a
seat is free — including whether its hold has lapsed — with a plain, `volatile` field read, and
only takes the lock afterward to grant the hold, is caught by
`tests_concurrency/MovieBookingStressTest` on the very first repetition, every run, reported as
`kind: "lost-update"` with `expected: <1> but was: <2>` (a never-before-touched seat) and
`expected: <1> but was: <4>` (a seat whose hold is mid-lapse). The field is deliberately made
`volatile` in that variant rather than left as a plain field, because the interesting failure is
not "a stale read" — it is "a correctly, promptly visible read, acted on one step too late." A
candidate who says "I made it volatile so it's thread-safe" has fixed the half of this bug that
was never the hard half.

### Atomicity vs visibility, stated as its own claim (E2)

`volatile` and `ConcurrentHashMap` both solve **visibility**: once one thread writes, another
thread's next read sees it. Neither solves **atomicity**: "read the current answer, then act on
it" is still two steps unless something serialises them. `tryHold`'s fix is not a `volatile` field
or a concurrent collection at all — it is that the read and the write happen inside the same
`synchronized` block, so no other thread can observe or change anything in between. This is the
single most important sentence in this design: making state visible faster does not make a
check-then-act atomic, and the two are easy to conflate because they are fixed by different tools.

### E3, and what was actually measured

The granularity half of E3 is real and stated plainly: no method in this design ever takes a
second show's lock, so two shows genuinely cannot contend with each other for the same monitor.
`tests_concurrency`'s fourth scenario hammers both shows at once and asserts each one ends up
exactly as its own traffic implies — a real concurrency test, not a placeholder.

What is **not** claimed is a throughput proof. Before writing that scenario, a standalone timing
probe (not shipped, not part of the suite) measured one `EVENING` hold-then-confirm's wall-clock
time while 20, 60, and then 200 threads hammered `MATINEE`'s seats in a tight hold-then-release
loop, against both this reference and a variant where `StandardCatalog` hands every `Show` the
*same* lock object instead of its own:

| Contending threads on MATINEE | Per-show lock (this reference) | One lock shared by every show |
|---|---|---|
| 20 × 200 rounds | 29 ms | 27 ms |
| 60 × 400 rounds | 69 ms | 47 ms |
| 100 × 1000 rounds | 62 ms | 61 ms |
| 200 × 20000 rounds | 114 ms | 128 ms |

The two designs never came apart. This problem's operations are cheap enough — a few map
lookups and field writes under a monitor, no I/O, no sleeping — and the JVM's monitor scheduling
fair enough, that a lone arriving thread does not actually queue behind the whole crowd contending
for someone else's lock. Shipping a wall-clock assertion on top of this data would have meant
shipping a test that passes both the correct design and the coarse-grained one — exactly the false
confidence the concurrency spec warns against — so no such assertion exists in the stress suite.
The lock-ordering half of E3 is not exercised at all, honestly: `holdIndex` is lock-free by
construction, so there is no second lock anywhere in this design for a cycle to form with. Elevator
is where E3 is owned end to end, including the deadlock reproduction this problem's shape cannot
produce.

### Snapshots, not handles

`seatsOf`, `availableSeats`, `hold`'s `unavailable` list and `Booking.seatIds()` all return
`List.copyOf` results built while the relevant show's monitor was held (or, for the purely
structural `layout()`, never needed it — see below). Two threads reading at the same moment may get
different, both-true answers; neither is stale, because a seat really can change between two
separate calls, and no invariant spanning two API calls is promised or asserted by the base suite.

### Structural reads need no lock, and saying why is worth a sentence

`Show.hasSeat` and `Show.layout()` are **not** `synchronized`. The seat *set* and each seat's
*type* are fixed at construction and never change afterward — only each `SeatSlot`'s live `status`
and `holdId` do, and neither of those methods touches them. Locking a read that touches nothing
mutable would be theatre, not correctness, and saying so is the answer to "why isn't this one
synchronized too?"

---

## Seam 1 · `PricingPolicy` — the pricing seam, and its one deliberate anticipation

**Anticipates:** any rule that changes what a seat costs — surge pricing, member discounts, a
seasonal promotion.

`long priceMinor(SeatType type, int bookedSeats, int totalSeats)` takes more than the base problem
strictly needs — `StandardPricing` ignores both counts — on the same footing as shopping-cart's
`Promotion.discountMinor(lines, owed)`: the honest defence of one piece of anticipation in an
otherwise unspeculative design is that the shape it anticipates is not exotic, and the measured
cost of being wrong about it would have been a signature change touching every implementation and
every call site. Curveball 01 is where that anticipation is cashed in, for a decorator and one
wiring line.

**Where the count is read, and why it matters.** `Show.tryHold` computes `bookedSeats` **once**,
before granting any of the requested seats, and every seat in one group hold is priced from that
same snapshot. A version that recomputed it between seats inside the loop would surge the second
seat of a group hold that only crossed a threshold because of the first seat in the same call —
curveball 01's `groupHoldIsPricedAtOneOccupancySnapshot` test exists to catch exactly that.

**Price is quoted once, at hold time, and never recomputed.** `HoldRecord.quotedTotalPriceMinor`
is computed inside `tryHold` and `confirm` simply returns it. This is stated in the base contract
before any curveball exists, and it is what makes "a hold made before a price change still
confirms at its original price" true for free rather than needing a special case.

## Seam 2 · `Show` — the lock, and the reclaim rule, live in exactly one place

Already covered above as the concurrency answer; worth restating as the D1/C1 answer too. `Show`
has no pricing rule and no knowledge of any other show. `MovieBookingSystem` has no seat-state rule
and no lock of its own. Splitting them this way is what makes `holdIndex` expressible as a plain,
lock-free map: if `MovieBookingSystem` held seat state directly, resolving a `holdId` to a show and
then acting on it would be two operations racing against a third thread reassigning the same
`holdId` — which cannot happen here, because a `holdId` is written to `holdIndex` exactly once and
never again.

## Modelling · entity vs value (A4)

`Show` is the entity: identified by its `showId`, carrying mutable state that genuinely mutates,
and never compared by its contents. `Seat`, `Booking`, `HoldResult` and `RefundResult` are values:
two `Booking`s with the same fields are the same fact, records give them `equals`/`hashCode` for
free, and none of them is ever mutated after construction. `SeatSlot` and `HoldRecord` are the
internal exception worth naming — they are **not** values despite looking like simple data holders,
because their whole job is to be mutated in place under `Show`'s lock; they never cross the API
boundary, which is exactly why they are allowed to be mutable.

## Modelling · why there is no `HoldOutcome` transition table (a considered non-adoption of B6)

`HoldOutcome` is a real, enforced state machine — `HELD` is the only state a hold starts in, the
other four are terminal, and `Show` never lets one hold move between two terminal states. It is
**not** built as a literal transition table (a `Map<HoldOutcome, Set<HoldOutcome>>` consulted at
each transition), and that is a decision rather than an oversight. Every transition this problem
has is fixed at compile time and known by exactly one caller each — `confirm` only ever writes
`CONFIRMED`, `release` only ever writes `RELEASED`, the lazy reclaim only ever writes `EXPIRED`, the
curveball's `cancel` only ever writes `CANCELLED` — so a data-driven table would encode a
freedom (arbitrary, runtime-configurable transitions) that nothing in this problem asks for and
that a candidate could not exercise. Retrofitting one to claim the syllabus tag would have been the
exact "invented scope" the standard's D3 level 3 anchor penalises. B6 is owned by `vending-machine`
in this corpus, where a table genuinely earns its place; this problem's state machine is real,
switch-based, and correctly enforced by the compiler's exhaustiveness check on `confirm`'s switch
**expression** — which is what caught, at compile time, that `release`'s statement switch and
`confirm`'s expression switch needed different treatment the moment curveball 03 added a fifth
outcome (see that curveball's `PATCH.md`).

## Errors: `HoldResult` for the ordinary case, exceptions for the caller's mistake

Three signals, chosen rather than mixed, exactly matching the convention parking-lot and
rate-limiter both established:

- **`HoldResult` is a value, not an exception**, for "somebody already has that seat" — the single
  most common outcome of a hold, exactly as `RateLimiterApi.tryAcquire` returns a `Decision` rather
  than throwing for a rate-limited caller. `unavailable()` tells the caller which of *their*
  requested seats blocked them, without leaking anything about who holds them or why.
- **`IllegalArgumentException`** for a request that could never make sense: an unknown show, an
  unknown seat, an empty or repeated seat list, a `holdId` this instance never issued. The caller
  passed something wrong.
- **`IllegalStateException`** for a request that makes sense but cannot be honoured *right now*: a
  lapsed, already-confirmed, or already-released hold being confirmed; an already-confirmed hold
  being released. The distinction is the same one rate-limiter states outright: the first tells the
  caller to fix its code, the second tells it the world moved on.

**Why the system remembers every `holdId` forever.** Stated directly in the contract because it is
the thing that makes the exception split above possible at all: without a permanent record of what
became of a `holdId`, "this hold lapsed" and "this `holdId` never existed" would be indistinguishable,
and a candidate who conflates them fails the base suite's
`confirmingALapsedHoldIsRefused` test on the second assertion, not the first.

## Immutability at the boundary (A2, A5, A7, D3)

Every type crossing the API is a `record` that validates in its compact constructor: `Seat` refuses
a blank id, `Booking` refuses an empty seat list or a negative price, `HoldResult` refuses a result
that mixes a held shape with a denied one (a `holdId` alongside a non-empty `unavailable`, or the
reverse). An invalid instance of any of them cannot exist, so there is no `validate()` method
anywhere needing to be remembered at each call site. Every collection handed out —
`seatsOf`, `availableSeats`, `HoldResult.unavailable()`, `Booking.seatIds()` — is `List.copyOf`, so
a caller can read everything and corrupt nothing, which matters most for exactly the internal maps
a candidate is expected to build differently from this reference.

## What was deliberately not built

Named so the scope is explicit rather than forgotten:

- **No `Ticket` or `BookingId` distinct from `holdId`.** A booking is identified by the same
  `holdId` that produced it — one to one, and permanently — so inventing a second id would be
  ceremony with nothing to distinguish.
- **No `Screen` or `Theatre` class.** A show in this problem is a self-contained fixed layout; the
  contract does not ask for one show to share a physical screen with another, so a `Screen` object
  with nothing to route through it would be a `Show` with an extra name.
- **No custom exception hierarchy (A6).** The contract fixes `IllegalArgumentException` and
  `IllegalStateException` for every failure, so a `SeatUnavailableException` would add a file and
  say nothing the message does not already say. Had the contract left the choice open, the answer
  would differ — parking-lot's decision log makes the same point and it holds here unchanged.
- **No background sweeper for lapsed holds.** Stated in the contract as out of scope on purpose,
  for the same reason elevator's `step()` is the only clock it reads: a design that discovers
  lapses lazily needs no thread, no timer, and nothing for the concurrency suite to race against
  beyond the design itself.
- **No `Builder` for constructing a show or the system.** `MovieBookingSystem(Clock)` and
  `MovieBookingSystem(Clock, PricingPolicy)` are two required-or-nothing parameters, not a handful
  of optional ones that accumulate — the shape shopping-cart's decision log names as the case a
  builder would be pure ceremony for.
- **No repository abstraction over `holds` or `seats`.** Both are private to one `Show` and never
  reached from outside it; there is no stated requirement here (a second process, a shared store)
  that would make swapping the storage a real, nameable change, unlike notification-service's
  `DeliveryLog`, which exists precisely because that requirement is real there.
