Inventory Management LLD: The Oversell Bug and the Reserve That Kills It
A low-level design walkthrough of an inventory management system: on-hand vs reserved vs available, two-phase reservations with commit and release, and the race that double-sells the last unit.
"Design an inventory management system." Sounds like a warehouse spreadsheet — until you ask the only question that matters: two customers, one unit left, both pressing Buy. Who gets it? Answer "whoever's request lands first" and you've described a race. Answer "both — we'll apologize later" and you've described most rookie implementations.
This article is about the one idea that fixes it: a single subtraction (available = onHand − reserved) and a two-phase handshake (reserve, then commit or release). It's the smallest design in the whole queue, and it guards more real-world money than any of the games did.
Let's start nowhere near a computer
A bakery takes phone orders for its famous last cake of the day. The clerk doesn't hand over the cake on the phone — she writes "HOLD — Asha, till 6 pm" on a sticky note and puts it on the box. The cake is still in the shop (on hand), but it is no longer for sale (not available). If Asha shows up and pays, the note and the cake leave together (commit). If 6 pm passes, the note comes off and the cake's back in the window (release).
The sticky note is the entire design. Inventory has three numbers, not one — what's physically there, what's promised, and what you may still promise — and the middle one is what keeps two phone callers from buying one cake.
Where the sticky note runs the world
- Every e-commerce checkout — "reserved for 10 minutes" in your cart is the note.
- The ATM's plan → debit → commit, and the vending machine's change-before-dispense — the same promise-before-movement discipline, applied to stock.
- Seat selection — the airline article, next in the queue, is this design where every unit has a name.
Step 1 — Functional requirements (sentences first)
What the system must do, as plain sentences — the functional requirements.
- The warehouse can receive stock for a SKU.
- A checkout can reserve quantity — only from what's available.
- A reservation is later committed (shipped: stock leaves) or released (back on sale).
- Available is always on-hand minus reserved; it can never go below zero.
- Two simultaneous reservations must never promise the same unit.
That last sentence is the whole interview. It reads like a footnote and it is the design: everything else is arithmetic, but "never promise the same unit twice" is where check-then-act goes to die. Notice too that we reserve against a count, not a named unit — the last cake isn't cake #7, it's "one of the seven." Stock that's interchangeable (fungible) lets us track quantities, not objects, and that single decision is what makes reserve and commit O(1) instead of a hunt through inventory.
Step 2 — Non-functional requirements
At class level the non-functional requirements are different words for the same promise — how well, not just what — and for an inventory ledger they are the design:
- Correctness. Never oversell; reserved and available must always reconcile against on-hand (
0 ≤ reserved ≤ onHand,available = onHand − reserved). The invariant survives every interleaving, or the money is wrong. - Thread-safety. Concurrent reservations must not double-allocate the last unit — two threads both reading "1 available" and both promising it is the central race, the reason this design exists.
- Performance. Reserve, commit, and release are O(1)-ish map operations — no scan of units, no walk of a reservation list. Availability is one subtraction, not a count.
- Extensibility. New item kinds (lots with expiry, per-location stock) and new reservation policies (timeouts, backorders) plug in without rewriting the core handshake.
Listing them is the easy half; the design only earns them if it fulfills them. Here's the contract — each requirement and the mechanism that keeps it:
| Requirement | How this design fulfills it |
|---|---|
| Correctness | available is derived (onHand − reserved), never stored; reserve refuses when qty > available — Steps 3, 4 |
| Thread-safety | synchronized welds the availability check to the reserved-bump into one critical section — Step 4 |
| Performance | counts in HashMaps, not per-unit objects; every operation is a merge or get — Steps 3, 4 |
| Extensibility | the Reservation is a seam — expiry, location, backorder ride on it without touching the math — Step 5 |
Every trade-off below is chosen to keep one of these.
Step 3 — Three numbers, one subtraction
public synchronized int available(String sku) {
return onHand.getOrDefault(sku, 0) - reserved.getOrDefault(sku, 0);
}The rookie design tracks only onHand and decrements it at checkout — which means a cancelled payment needs an increment (often forgotten), and an in-flight checkout blocks nothing (the oversell). With three numbers, every business event maps to clean arithmetic: receiving raises on-hand; reserving raises reserved; committing lowers both; releasing lowers reserved. Available is never stored — it's derived. Stored copies of derivable numbers drift; subtractions don't.
Which structure — and why. Three load-bearing choices, each tied to an NFR. First, counts in maps, not per-unit objects — because stock is fungible, we reserve against a number, not against cake #7; Map<String, Integer> keyed by SKU makes reserve and commit O(1) merge calls instead of scanning a list of units (performance). Second, the two-phase plan → commit ordering: reserve plans the allocation (bumps reserved, moves nothing physical), and only commit moves stock — so an abandoned checkout costs a release, not an oversell, and the promise is always reversible before it's final (correctness). Third, the single critical section: the availability check and the reserved bump live inside one synchronized method, welded so no thread can squeeze between them (thread-safety). This is class-level — three maps and a lock, no datastore, no replicas; the same shape survives behind a database row later, but the logic is here.
Step 4 — The handshake: reserve, then commit or release
public synchronized Reservation reserve(String sku, int qty) {
requirePositive(qty);
if (qty > available(sku)) {
throw new IllegalStateException(
"only " + available(sku) + " of " + sku + " available");
}
reserved.merge(sku, qty, Integer::sum);
Reservation r = new Reservation("R-" + nextId++, sku, qty);
open.put(r.id(), r);
return r;
}
public synchronized void commit(String reservationId) {
Reservation r = openOrThrow(reservationId);
onHand.merge(r.sku(), -r.qty(), Integer::sum); // stock leaves the building…
reserved.merge(r.sku(), -r.qty(), Integer::sum); // …and the promise is fulfilled
open.remove(reservationId);
}
public synchronized void release(String reservationId) {
Reservation r = openOrThrow(reservationId);
reserved.merge(r.sku(), -r.qty(), Integer::sum); // the sticky note comes off
open.remove(reservationId);
}The Reservation is — say it with me — the hidden noun: a customer-stock relationship with its own id and lifecycle, exactly like the parking ticket and the PNR. The id matters: commit and release take the reservation, not "sku and qty", so a double-commit is detectable (openOrThrow) instead of silently shipping twice.
And the race? reserve is check-then-act — read available, then promise —
which is the token bucket's bug
wearing a warehouse coat. The synchronized makes the check and the promise
one step; without it, two threads both see "1 available" and the bakery sells
one cake twice. We test exactly that, with twenty threads.
Step 5 — What this design refuses to do
Worth saying in the room: this ledger never goes negative and never guesses. Reserve more than available (the overcommit attempt)? Refused, with the number — available is rechecked under the lock, so the rejection is honest even when twenty threads race for the last unit. Commit an unknown or already-settled reservation? Thrown. Release twice? Thrown. The arithmetic invariant (0 ≤ reserved ≤ onHand) survives every interleaving of refusals — and that sentence, defended, is the grade.
The one open edge is the reservation that's never settled — Asha books the cake and never shows. Left alone, reserved only ever climbs and availability bleeds to zero on stock that's physically there. The fix is the sticky note's "till 6 pm": stamp each reservation with an expiry and release it on timeout — checked lazily on the next read, no sweeper thread (the corner's ladder walks the code). The point for the design is that an abandoned plan has a defined end; a promise nobody keeps must eventually expire, or the ledger slowly lies.
Step 6 — Trade-offs (each one keeping an NFR)
The last column is the discipline: every choice keeps one of the promises from Step 2 — that's what designing to the non-functional requirements looks like.
| Decision | The tempting alternative | Why ours wins | Keeps |
|---|---|---|---|
| counts in maps | a per-unit object per item | reserve/commit are O(1) merges; fungible stock needs a number, not a hunt | performance |
| plan (reserve) → commit | decrement on-hand directly at checkout | an abandoned checkout costs a release, not an oversell; the move is reversible | correctness |
one synchronized critical section | check available, then bump (check-then-act) | the check and the promise are welded — two threads can't both claim the last unit | thread-safety |
Reservation with an id | settle by (sku, qty) | double-commit is detectable, and expiry/location/backorder ride on the seam | extensibility |
The complete implementation
package dev.fiveyear.inventory;
import java.util.HashMap;
import java.util.Map;
/** Three numbers and a handshake: onHand, reserved, and derived availability. */
public final class Inventory {
public record Reservation(String id, String sku, int qty) {}
private final Map<String, Integer> onHand = new HashMap<>();
private final Map<String, Integer> reserved = new HashMap<>();
private final Map<String, Reservation> open = new HashMap<>();
private int nextId = 1;
public synchronized void receive(String sku, int qty) {
requirePositive(qty);
onHand.merge(sku, qty, Integer::sum);
}
public synchronized int available(String sku) {
return onHand.getOrDefault(sku, 0) - reserved.getOrDefault(sku, 0);
}
public synchronized Reservation reserve(String sku, int qty) {
requirePositive(qty);
if (qty > available(sku)) {
throw new IllegalStateException(
"only " + available(sku) + " of " + sku + " available");
}
reserved.merge(sku, qty, Integer::sum);
Reservation r = new Reservation("R-" + nextId++, sku, qty);
open.put(r.id(), r);
return r;
}
public synchronized void commit(String reservationId) {
Reservation r = openOrThrow(reservationId);
onHand.merge(r.sku(), -r.qty(), Integer::sum);
reserved.merge(r.sku(), -r.qty(), Integer::sum);
open.remove(reservationId);
}
public synchronized void release(String reservationId) {
Reservation r = openOrThrow(reservationId);
reserved.merge(r.sku(), -r.qty(), Integer::sum);
open.remove(reservationId);
}
public synchronized int onHand(String sku) {
return onHand.getOrDefault(sku, 0);
}
private Reservation openOrThrow(String id) {
Reservation r = open.get(id);
if (r == null) {
throw new IllegalArgumentException("no open reservation: " + id);
}
return r;
}
private static void requirePositive(int qty) {
if (qty <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
}
}A day at the bakery:
Inventory inv = new Inventory();
inv.receive("cake", 10);
Reservation asha = inv.reserve("cake", 3); // sticky note on three boxes
inv.available("cake"); // 7 — promised stock is off the shelf
inv.reserve("cake", 8); // throws: only 7 available
inv.commit(asha.id()); // shipped: onHand 7, reserved 0
inv.commit(asha.id()); // throws: that note is already gone
Reservation dev = inv.reserve("cake", 2);
inv.release(dev.id()); // changed his mind — back in the window
inv.available("cake"); // 7 again. the ledger never guessed.The interview corner
Clarify before you code: One warehouse or many? Are backorders a feature or an error? Does stock expire (lots and batches)?
The follow-up ladder:
- "Reservations should expire." The bakery's "till 6 pm": stamp an expiry, check it lazily on read — the airline hold's clock pattern, no sweeper thread.
- "Two warehouses." Buckets become per-(SKU, location), and reserve must pick a location — congratulations, fulfilment routing is now a Strategy.
- "Allow backorders." Never let the numbers go negative — model the IOU explicitly: a backorder queue that fulfills on the next receive. Honesty as a data structure.
- "Stock expires." Lots with dates; committing picks first-expired-first-out — an ordering policy on commit, not a redesign.
- "The supplier's webhook delivers twice." Idempotent receiving: a processed-receipt-id set, or every retry doubles your stock — the idempotency conversation, warehouse edition.
Mistakes that fail the round: one onHand number doing three jobs; storing available instead of deriving it; decrementing at add-to-cart with no expiry story.
Where to go from here
Pocket version: three numbers, available is derived, reservations are hidden nouns with ids, commit/release settle them exactly once — and synchronized welds the check to the promise.
- Add reservation expiry — the bakery's "till 6 pm" — with the rate limiter's lazy-clock trick: no sweeper thread, just check the timestamp on touch.
- Add an audit log — every arithmetic event appended to a list, and you've discovered event sourcing from first principles.
- Next in the queue: hotel management, where the inventory is rooms and the new villain is the calendar.
Two customers, one cake, both pressing Buy: now one gets a confirmation, one gets an honest "sold out" — and nobody gets an apology email. That's the whole system.