Engineering Atlas › Low-Level Design

Code a stock inventory tracker

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

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

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 ..|> PickPolicy

Key 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 list

Design patterns used and why

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

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

  1. 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).
  2. 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.
  3. Shelves and databases drift apart through theft, damage and mis-scans, so changes are recorded as reasoned movements that can be audited and reconciled.
  4. 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.
  5. Purchasing teams have limited attention, so alerts fire on the crossing, not on every low reading.

Extensibility

Leadership lens

Check yourself

  1. 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.

  2. 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.

  3. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →