Code a stock inventory tracker
Open in the interactive app: practice, quiz, mock interview →In one minute
- An inventory system answers one question under pressure: "can I promise this unit to this customer?" Everything else (receiving, counting, reordering) exists to keep that answer honest.
- Keep three numbers apart for every item: on hand (physically in the building), reserved (promised to orders not yet shipped) and free (on hand minus reserved). Most inventory bugs are one of these being computed from the wrong source.
- Stock sits in lots with their own expiry or cost, and the order you consume them in (FIFO, FEFO) is a per-product-line policy. Put it behind a strategy.
- Every change is a movement with a reason (receipt, pick, write-off, count adjustment), so a stock discrepancy is explainable instead of a mystery.
- The problem shape is contention on a counter that many callers want to decrement, plus trust but verify because the physical world drifts away from the database. You meet the same shape in seat maps, cloud quota and API credits.
Requirements
Functional:
- Track stock per SKU, split into lots. A lot has an id, a quantity and an optional expiry date.
- Receive stock into a lot at a warehouse.
- Reserve stock for an order without choosing physical units yet; release the reservation if the order is cancelled.
- Ship a reserved order: pick concrete lots according to the consumption policy and decrement them.
- Write off expired lots on a daily run and apply cycle-count adjustments when a count disagrees with the system.
- Raise a reorder alert when free stock falls below a per-SKU reorder point.
Non-functional:
- Never promise more units than exist, even with many checkout servers reserving at once.
- Every balance change is auditable: who, when, why, which lot.
- Reserve in a few milliseconds; checkout waits on it.
Out of scope: purchase-order workflow with suppliers, demand forecasting, multi-warehouse allocation (covered in Design Amazon), and valuation for the general ledger. Name them, then park them in Extensibility.
Entities and relationships
- Product (SKU): a catalogue entry with a unit of measure. It holds no quantities, so catalogue edits and stock changes never contend for one row.
- Lot: a batch of one SKU received together. It has an id, an expiry (or none), a received date and a remaining quantity. It is the unit you consume and the unit a recall targets.
- StockLedger: the per-SKU aggregate that owns its lots, its reserved total and its reorder state. It is where invariants live, so every mutation goes through it.
- Reservation: an order's claim on a quantity of one SKU, with a status (active, shipped, released). It does not point at lots; lots are chosen at pick time.
- Movement: an immutable record of a change: SKU, lot, signed quantity, reason, reference (order or count id) and timestamp.
- PickPolicy: given a SKU's lots, returns the order to consume them in. ReorderListener receives "below reorder point" events.
- Inventory: the façade that callers use. It maps SKUs to ledgers and order ids to reservations.
Relationships: a ledger is composed of its lots (a lot has no meaning outside its SKU). A reservation references a SKU by id; it does not own stock. Movements are appended by the ledger and never edited. Inventory aggregates ledgers and holds the policy and listeners by reference.
The decision that matters most is reserve does not pick lots. A lot chosen at checkout can expire or go missing before packing and strand the order. Reserving a quantity and choosing lots at pick time lets the warehouse substitute freely.
Class diagram
classDiagram
class Inventory {
-ledgers: Map~Sku, StockLedger~
-reservations: Map~OrderId, Reservation~
+receive(sku, lot, qty, expiry)
+reserve(orderId, sku, qty) bool
+release(orderId) bool
+ship(orderId) Pick[]
+expire(today) WriteOff[]
}
class StockLedger {
+sku: Sku
-lots: Map~LotId, Lot~
-reserved: int
-reorderPoint: int
-armed: bool
+onHand() int
+free() int
}
class Lot {
+id: LotId
+expiry: int
+qty: int
}
class Reservation {
+orderId: OrderId
+sku: Sku
+qty: int
+status: ReservationStatus
}
class Movement {
+sku: Sku
+lot: LotId
+delta: int
+reason: Reason
+ref: string
+at: int
}
class PickPolicy {
<<interface>>
+order(lots) Lot[]
}
class ReorderListener {
<<interface>>
+belowReorderPoint(sku, free)
}
Inventory "1" *-- "many" StockLedger
StockLedger "1" *-- "many" Lot
Inventory "1" o-- "many" Reservation
StockLedger ..> Movement : appends
Inventory --> PickPolicy
Inventory --> ReorderListener
Fefo ..|> PickPolicy
Fifo ..|> PickPolicyKey flows
Reserve is the checkout hot path and ship the warehouse one. Both change the same ledger, so both take its lock.
sequenceDiagram
participant C as Checkout
participant I as Inventory
participant L as StockLedger
participant W as WarehouseScanner
participant P as PickPolicy
participant R as ReorderListener
C->>I: reserve(o42, TEA, 3)
I->>L: lock, free() = 7
L->>L: reserved += 3, append Movement
L->>L: free 4 below point 5, disarm
L-->>I: ok, unlock
I-->>C: true
I->>R: belowReorderPoint(TEA, 4)
W->>I: ship(o42)
I->>L: lock
L->>P: order(lots)
P-->>L: [B (exp 20), A (exp 30)]
L->>L: take 3 from B, reserved -= 3
L-->>I: picks [(B, 3)], unlock
I-->>W: pick listDesign patterns used and why
- Strategy for the pick policy. Groceries and medicine use FEFO (first expiry, first out). Hardware uses FIFO by received date. A fashion retailer may pick from the lot nearest the packing bench. These rules differ per product line and change over time, so they belong behind one interface, not in an
if category ==chain insideship. - Observer for reorder alerts, with an important detail: the alert is edge-triggered. It fires once when free stock crosses below the point and re-arms only when stock climbs back to or above it. A level-triggered alert fires on every reservation while stock stays low, and the purchasing team learns to ignore it. See Observer for the mechanics; here the subtle part is the armed flag.
- Façade:
Inventoryis the only entry point. Callers never touch a lot directly, which keeps every invariant in one place and lets you swap the in-memory ledger for a database-backed repository later. - Event log, kept light: each change appends a
Movement. This is not full Event Sourcing (you still store current balances for speed), but it gives you the audit trail and the ability to rebuild a balance when someone asks "where did 40 units go?". - Value objects for
Movement,Pickand quantities with units, so "3 cases" and "3 each" cannot be added by accident.
What not to use: a Singleton inventory, for the same reason as in Design Parking Lot; and a class per product type. Perishable and non-perishable goods differ in data (an expiry) and in policy (a strategy), not in behaviour that needs dispatch.
Concurrency and edge cases
- Last units, many checkouts. Two servers both read free = 1 and both reserve. The fix is to make check-and-decrement one atomic step: a mutex per ledger in memory, or a conditional update in SQL (
UPDATE stock SET reserved = reserved + $1 WHERE sku = $2 AND on_hand - reserved >= $1) and checking the row count. This is the same pattern as in Design Flash Sale, where the counter is much hotter. - Lock granularity. One lock per SKU, not one for the whole store. A promotion on tea should not block a reservation for coffee. If an order needs several SKUs, acquire their locks in a fixed order (sorted by SKU) or reserve one SKU at a time with compensation on failure; otherwise two orders with the same two SKUs in opposite order deadlock.
- Expiry after reservation. A lot can expire between checkout and packing. After the nightly write-off, on hand may drop below reserved, so free goes negative. That is a true fact about the world, not a bug to hide. Ship must then refuse an order it cannot fill and leave the reservation active, so customer service sees a short order instead of a crashed packing station.
- Duplicate requests. Checkout retries on timeout. Key reservations by order id and reject a second reservation for the same id; see Idempotency. The same applies to
ship: a scanner that sends the event twice must not pick twice. - Cycle counts. A count that finds 47 when the system says 50 becomes an adjustment movement of minus 3 referencing the count, never an overwrite to 47, which would hide the loss.
- Lot identity. The same lot id arriving with a different expiry is a supplier data error. Reject it rather than merging, or you may ship expired food from a lot the system thinks is fresh.
Down the stack
Layer 1The contract
reserve(order, sku, q) either increases reserved by exactly q and returns true, or returns false and changes nothing; it succeeds only if free is at least q at the moment the ledger is locked. ship(order) either decrements lots by a total of exactly the reserved quantity in policy order and marks the reservation shipped, or changes nothing. Free stock is on_hand - reserved and may be negative only after a write-off. Every successful mutation appends one movement per lot touched, and summing a SKU's movements reproduces its on-hand count.
Layer 2The mechanism
In memory, a ledger holds a hash map from lot id to (expiry, qty), a reserved integer and an armed flag. FEFO ordering is a sort by (expiry, lot id) at pick time, O(L log L) for L lots, which is fine because a SKU rarely has more than a few dozen live lots. A min-heap keyed by expiry makes it O(log L) per pick if you have thousands. In a relational database the shape is two tables: lot(sku, lot_id, expiry, qty) with an index on (sku, expiry, lot_id), and stock(sku, on_hand, reserved, reorder_point) as the contended row. Reservation is a single conditional UPDATE on stock. Shipping runs SELECT ... FROM lot WHERE sku = $1 AND qty > 0 ORDER BY expiry, lot_id FOR UPDATE, decrements rows in a loop, and inserts movements, all in one transaction. The append-only movement table is usually partitioned by month.
Layer 3The machine
The contended object is one row, which in Postgres means one heap tuple and one row lock. Every successful reservation writes a new tuple version and a WAL record, and the commit waits for fsync of the WAL, roughly 0.1 to 1 ms on NVMe. Group commit lets many transactions share one flush, which is why a single hot SKU can still take a few thousand reservations per second. Beyond that, row-lock waits dominate: each waiter sleeps on a lock and is woken in turn, adding queueing delay that grows with the number of waiters. In memory, the same operation is an uncontended sync.Mutex at about 20 ns plus a map lookup, so a single process can reserve millions of units per second. Pickers walk for minutes, so contention only matters for checkout spikes, not the warehouse floor.
Why it's built this way
- Customers are promised stock at checkout, long before anyone walks to a shelf, so the system needs a promise (reserved) separate from physical stock (on hand).
- Physical stock is consumed in batches with different expiry and cost, so on hand is a set of lots, not one number, and the choice of lot belongs at pick time when the picker is standing there.
- Shelves and databases drift apart through theft, damage and mis-scans, so changes are recorded as reasoned movements that can be audited and reconciled.
- Many checkouts hit the same popular SKU at once, so check-and-decrement must be atomic on one row or one lock, and that lock must be per SKU so unrelated products do not queue behind each other.
- Purchasing teams have limited attention, so alerts fire on the crossing, not on every low reading.
Extensibility
- Multiple warehouses: key ledgers by
(warehouse, sku)and add an allocation step before reservation that chooses the warehouse. That allocator is the core of Design Amazon; the ledger does not change. - Reservation expiry: carts that never check out should release stock. Add an expiry time to the reservation and treat expired ones as released lazily on the next read, the pattern from Preventing Double Booking.
- Costing: finance wants cost of goods sold. Store unit cost on each lot; FIFO costing then falls out of the pick list. Changing to weighted average cost is a new valuation strategy, not a change to picking.
- Recalls: "find every order that received lot B7" is a query on movements by lot. This is why the movement row carries the lot id even for picks.
Leadership lens
- Build first: the ledger with atomic reserve and ship, the movement log and a reconciliation report (system count versus last cycle count). A wrong reorder threshold costs a delayed shipment; an untraceable stock loss costs an audit finding.
- Ask in design review: what makes reserve atomic; can free go negative and who sees it; how does a duplicate checkout request behave; which consumption policy each product line uses, and who owns that decision.
- The biggest operational cost is reconciliation, not code: budget for count tooling and an adjustment screen with mandatory reason codes.
- Ownership: checkout owns reservations, the warehouse owns picking and counts, purchasing owns reorder points. One service with three entry points beats three services sharing a table.
Check yourself
Why does reservation decrement free stock without choosing a lot?
Show answer
The lot that is best to ship (earliest expiry, nearest shelf) may change before packing, and a reserved lot could expire or turn out to be missing. Reserving a quantity keeps the promise while leaving lot choice to pick time.
A cycle count finds 47 units where the system shows 50. Why record a movement of minus 3 rather than setting the balance to 47?
Show answer
The movement preserves the fact and size of the loss with a reason and a reference, so balances still equal the sum of movements and loss prevention can see the trend. Overwriting hides the discrepancy.
Free stock for a SKU is minus 2 after the nightly expiry run. Is that a bug?
Show answer
No. Units promised to customers expired before shipping, so the system now owes more than it holds. Showing a negative number surfaces short orders; clamping it to zero would hide them.
Why fire the reorder alert only on the crossing, not whenever free stock is below the point?
Show answer
Level-triggered alerts repeat on every reservation while stock is low and train people to ignore them. An edge trigger produces one actionable event and re-arms after replenishment.
Watch
- Strategy Pattern, Design Patterns ep 1 (Christopher Okhravi): the pattern behind pluggable pick policies.
- Observer Pattern, Design Patterns ep 2 (Christopher Okhravi): subscriptions and notifications, the basis for reorder alerts.
Free resources
- FIFO and LIFO accounting (Wikipedia): how the order of consuming lots changes reported cost, which is why pick policy matters to finance.
- Reorder point (Wikipedia): the formula behind the threshold the alert watches.
- Event Sourcing (Martin Fowler): the full version of the movement log idea, and when it is worth it.
- Explicit locking (PostgreSQL docs): row locks and
FOR UPDATE, used when picking lots inside a transaction. - Strategy pattern (Refactoring.Guru): examples of swapping algorithms behind one interface.