LLD case
Inventory / order system
Two checkouts both see stock 1 for the last mug. If each thread reads available, then writes stock--, both succeed and you sold the same unit twice. That is the invariant this design protects.
A holds the unit, payment commits it, and a expiry returns it if payment never finishes. Only one may win. The same race shows up as occupy on movie seats and parking spots.
Safe checkout path
Atomic hold: available goes down, reserved goes up, or the call fails.
Pay
Confirm moves reserved to sold. available was already reduced at reserve.
If payment never finishes, expire moves reserved back to available.
Ship
Only from PAID. Order commands reject every illegal arrow.
What actually happens when two checkouts race
SKU (stock-keeping unit, the product identity) and Stock are inventory. Order is an entity with a state machine: CREATED → RESERVED → PAID → SHIPPED, or CREATED → CANCELLED, or RESERVED → EXPIRED. You must not SHIP from CREATED. You must not reserve below zero. See state machines.
is one step. If decrementing available, incrementing reserved, and recording the order are three unlocked field writes, the last mug sells twice. A use-case controller coordinates reserve, pay, and ship; Stock protects counts; Order protects its arrows. Validation of "qty > 0" belongs at the command edge, and the stock object still protects the count. See validation.
Payment confirmation is on order id. A has a from Clock; a worker or the next command expires stale holds. Do not mix warehouse bins, courier APIs, and invoices into OrderService.
How to choose a locking story
Once you see that unlocked writes lose the mug, you still have to say how Stock serialises .
1. In process? synchronized on Stock (one thread at a time inside the method) is the honest in-process answer.
2. Behind a database? (version column, retry) or a on the sku row are both valid. Pick one and say why. See optimistic vs pessimistic and concurrent transitions.
3. What is not a story? Silent stock-- is neither optimistic nor pessimistic locking. It is a bug.
Simple rule of thumb
Never write silent stock--. Pick or synchronized Stock out loud, then let Order commands own the arrows (see the type table next).
From problem to types
The locking story only works if each type owns one job. Stock protects counts. Order protects its state machine. CheckoutService coordinates. Clock owns hold time.
| Type | Invariant |
|---|---|
| Stock | available + reserved never lie; never negative; atomic. |
| Order | Expose commands only. setStatus(SHIPPED) from CREATED is a bug. |
| CheckoutService | Coordinates reserve, pay, and expire. Courier APIs stay outside. |
| Clock | holds have a . Tests freeze Clock and expire holds. |
Illegal arrows are the design
Those jobs show up as legal arrows. The note on CREATED is the whole point: you cannot SHIP from there.
Read the diagram definition
stateDiagram-v2
[*] --> CREATED
CREATED --> RESERVED: tryReserve stock
CREATED --> CANCELLED: cancel
RESERVED --> PAID: confirm payment
RESERVED --> EXPIRED: TTL
RESERVED --> CANCELLED: cancel + release
PAID --> SHIPPED: ship
EXPIRED --> [*]
CANCELLED --> [*]
SHIPPED --> [*]
note right of CREATED: cannot SHIPJava API sketch
The sketch is the critical section, not a compiler. Fix the holdUntil line in your head if you type this: Instant, not a missing paren.
class Stock {
private int available;
private int reserved;
synchronized boolean tryReserve(int qty) {
if (qty <= 0) throw new InvalidQty();
if (available < qty) return false;
available -= qty;
reserved += qty;
return true;
}
synchronized void confirm(int qty) {
if (qty <= 0) throw new InvalidQty();
if (reserved < qty) throw new OverConfirm();
reserved -= qty; // sold; available already reduced at reserve
}
synchronized void release(int qty) {
if (qty <= 0) throw new InvalidQty();
if (reserved < qty) throw new OverRelease();
reserved -= qty;
available += qty;
}
}
class Order {
private OrderStatus status = OrderStatus.CREATED;
void reserve() { /* CREATED → RESERVED */ }
void pay() { /* RESERVED → PAID */ }
void ship() { /* PAID → SHIPPED only */ }
void expire() { /* RESERVED → EXPIRED */ }
void cancel() { /* from CREATED or RESERVED */ }
}
class CheckoutService {
private final Clock clock;
Order place(Sku sku, int qty) {
if (qty <= 0) throw new InvalidQty();
Order o = new Order();
if (!stock.tryReserve(qty)) throw new OutOfStock();
try {
o.reserve();
o.holdUntil(clock.instant().plus(Duration.ofMinutes(10)));
return o;
} catch (RuntimeException ex) {
stock.release(qty); // compensate if order creation fails after reserve
throw ex;
}
}
}Last mug, two checkouts
Here is the race from the hero, slowed down so you can see each step. Stock available is 1. Thread A and B both call (1). Exactly one returns true. The winner's Order is RESERVED with a .
FakeClock (a test clock you can advance) moves past that TTL; expireDue releases reserved back to available. The loser's checkout failed with OutOfStock; it must not have created a SHIPPED order. Confirm payment on the reserved order is : the second confirm does not decrement reserved twice. Confirm and release reject qty that would make reserved negative. If order creation fails after tryReserve, release compensates so stock is not stranded.
Numbers to know
- Last unit: Stock(1); exactly one tryReserve wins across two threads.
- Hold length: ten minutes from Clock, then expireDue may release.
- qty must be greater than 0, or InvalidQty fires at the edge.
Tests that prove the seams
@Test
void lastUnitHasOneWinner() throws Exception {
Stock stock = new Stock(1);
AtomicInteger wins = new AtomicInteger();
Runnable r = () -> { if (stock.tryReserve(1)) wins.incrementAndGet(); };
Thread a = new Thread(r), b = new Thread(r);
a.start(); b.start(); a.join(); b.join();
assertEquals(1, wins.get());
}What still fails, and what a follow-up changes
The types above still fail if you skip a seam ( as one step, an confirm, or a release) or if the interviewer grows the model. Keep these cases next to the happy path.
Invalid cases
if (stock > 0) stock--
Two threads both see 1. Reservation must be one step.
setStatus(SHIPPED) from anywhere
SHIPPED from CREATED is a bug, not a retry.
Confirm payment twice creating two decrements
Idempotent on order id. Second confirm is a no-op.
Expire that does not release reserved
Expire that skips release leaks stock, so available never returns.
qty 0 or negative
Reject qty 0 or negative at the edge; Stock still guards.
Still fails when…
- A multi-product cart holds the filter after the mug reserve fails.
- Partial hold strands reserved units; roll back both, like multi-seat booking.
- Invoices and courier APIs land in OrderService instead of staying outside.
What the follow-up changes
- Multi-SKU (several products) carts: reserve all or roll back together.
- Warehouse bins are another collaborator; Stock still protects counts.
- Partial ship adds new order states, never setStatus from a servlet.
Wrap-up
You have seen the race, the types, and the seams. Use this forty-five minute plan in a round, then follow the related notes.
How to explain this in an interview
- 0–8Prove with two threads racing one remaining unit.
- 8–20Code Order command arrows so shipping from CREATED throws.
- 20–32Show a expire that releases reserved back to available.
- 32–40Make payment confirm on the same order id.
- 40–45If they ask, roll back a multi-product cart together.
Related
Key takeaway
You are done when the Last mug seams hold: one winner, legal arrows, and a release if payment never completes. A bare stock-- is not a (see How to choose above).