Why this matters: the implementation act here is graded on whether the invariants survive contact with code — one writer for seat state, all-or-nothing holds, idempotent confirmation. The walkthrough builds the vocabulary types first, then the show's seat map (where the race is decided), then the service that faces payment.
Vocabulary types
enum SeatStatus { AVAILABLE, HELD, BOOKED }
record SeatId(String row, int number) { } // "F", 7
class Hold {
final String id;
final Set<SeatId> seats; // all-or-nothing unit
final String userId;
final Instant expiresAt; // TTL: now + 5 minutes
boolean confirmed = false;
boolean isExpired(Instant now) {
return !confirmed && now.isAfter(expiresAt);
}
}
record Booking(String id, String showId, Set<SeatId> seats, String userId) { }Narrate the Hold fields: identity, contents, owner, deadline — the four things lesson 02 said a claim needs. Note isExpired never expires a confirmed hold: confirmation freezes the claim, which is half of retry-safety already.
The show: one owner, one writer
class Show {
final String id;
final ScreenLayout layout; // immutable, shared
private final Map<SeatId, SeatStatus> seats; // THIS show's truth
private final Map<SeatId, String> heldBy; // seat -> holdId
private final Map<String, Hold> holds = new HashMap<>();
// The race is decided here: one indivisible check-then-claim.
synchronized Optional<Hold> placeHold(Set<SeatId> wanted,
String userId, Instant now) {
expireLapsed(now); // lazy expiry first
for (SeatId s : wanted)
if (seats.get(s) != SeatStatus.AVAILABLE)
return Optional.empty(); // ANY taken -> claim nothing
Hold hold = new Hold(newId(), wanted, userId, now.plus(TTL));
wanted.forEach(s -> { seats.put(s, SeatStatus.HELD);
heldBy.put(s, hold.id); });
holds.put(hold.id, hold);
return Optional.of(hold);
}
private void expireLapsed(Instant now) {
for (Hold h : List.copyOf(holds.values()))
if (h.isExpired(now)) releaseSeatsOf(h);
}
}Three sentences to say while writing this. First: synchronized on the one method that mutates makes the check-then-claim indivisible — two users tapping F7 serialize here, and the second sees HELD. Second: the loop checks every wanted seat before touching any — the all-or-nothing rule as control flow, not commentary. Third: expireLapsed runs at the top of every mutating call — lazy expiry means an expired hold is indistinguishable from a released one by the time any decision is made.
Confirm and release: payment's three outcomes
class Show { // continued
synchronized Optional<Booking> confirm(String holdId, Instant now) {
Hold h = holds.get(holdId);
if (h == null) return Optional.empty();
if (h.confirmed) // duplicate callback:
return Optional.of(bookings.get(holdId)); // same result, no effect
if (h.isExpired(now)) { releaseSeatsOf(h); return Optional.empty(); }
h.confirmed = true;
h.seats.forEach(s -> seats.put(s, SeatStatus.BOOKED));
Booking b = new Booking(newId(), id, h.seats, h.userId);
bookings.put(holdId, b); // keyed BY HOLD -> idempotent
return Optional.of(b);
}
synchronized void release(String holdId) { // payment failed / user cancelled
Hold h = holds.get(holdId);
if (h != null && !h.confirmed) releaseSeatsOf(h);
}
}confirm is the method to walk through slowest, because its branch order is the requirements: duplicate success callbacks return the existing booking (idempotency, keyed by hold id); a hold that expired before confirmation fails cleanly — the honest outcome when payment succeeded too late; only a live, unconfirmed hold books seats. And the timeout case from lesson 01 appears nowhere — which is the point. Unknown-outcome payments require no code path: the caller simply never calls, and the TTL retires the hold.
The service: the flow reads like the lesson
class BookingService {
private final Map<String, Show> shows;
private final PaymentGateway payments; // external; can fail/timeout
private final Clock clock; // injectable time
Optional<Booking> book(String showId, Set<SeatId> wanted, String userId) {
Show show = shows.get(showId);
Optional<Hold> hold = show.placeHold(wanted, userId, clock.instant());
if (hold.isEmpty()) return Optional.empty(); // lost the race
PaymentResult r = payments.charge(userId, price(show, wanted));
if (r.succeeded())
return show.confirm(hold.get().id, clock.instant());
show.release(hold.get().id); // known failure
return Optional.empty(); // timeout? TTL handles it
}
}Two injections to point at: PaymentGateway as an interface (tests script success, failure, and the duplicate callback), and Clock rather than direct time calls — because expiry is untestable without controlling the clock. A test that advances a fake clock past the TTL and watches F7 come back is the proof that holds actually die.
One honest note to volunteer: in a real service the provider's success can also arrive as an asynchronous callback, hitting confirm(holdId) directly — which is exactly why confirm is keyed by hold id and idempotent, so the synchronous path and a retried callback land on the same safe method.
Key takeaway
The code keeps the design's promises structurally: every seat mutation lives inside the show behind one lock, placeHold checks all seats before claiming any, lazy expiry at the top of each mutating call makes expired and released holds indistinguishable, and confirm's branch order — duplicate returns the same booking, expired fails cleanly, live hold books — is the requirements list executable. The strongest lines in the file are the ones for cases that need no code: timeouts handled entirely by the TTL.