Problem RestatementProblem
Amazon asked: design a system that tracks inventory and location of perishable goods (milk, produce, medicine) across a distributed retail network: warehouses, trucks and stores. Unlike normal inventory, each unit has an expiry date, so the system must know how many of each product we have, where, and when each batch expires, send the soonest-expiring stock first, and alert before things spoil.
RequirementsRequirements
- Receive stock in lots (batches) with an expiry date, quantity and location.
- Track movements: warehouse → truck → store shelf, sold, returned, discarded (expired or damaged).
- Real-time stock per product per location, with an expiry breakdown.
- Allocate orders and transfers FEFO (first-expiring, first-out).
- Alerts: "300 units at Store 12 expire within 2 days" (to discount or move them).
- Scanners at locations may be offline for a while.
Data ModelData model
products: sku, name, shelf_life_days, storage_temp_range
locations: location_id, type (warehouse|truck|store), region
lots: lot_id, sku, expiry_date, received_at, supplier
stock: (location_id, lot_id) → quantity, version -- current state
movements: movement_id (UUID from scanner), lot_id, from_location, to_location, quantity,
type (receive|transfer|sale|discard|adjust), ts, device_id -- append-only logThe movements log is the source of truth (every change is an event), and stock is the current state derived from it (updated in the same transaction, or by a consumer).
ArchitectureArchitecture
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
SC["Scanners / POS - offline capable"] -->|"movement events"| ING["Ingestion API"]
ING --> K[("Kafka - movements by location")]
K --> INV["Inventory service - update stock"]
INV --> DB[("Stock + lots DB")]
K --> EXP["Expiry monitor"]
EXP --> AL["Alerts - discount / transfer"]
OMS["Orders / replenishment"] --> ALLOC["FEFO allocator"]
ALLOC --> DB
DB --> DASH["Dashboards - stock by expiry"]Deep Dive — Choosing which units to shipDeep dive
There are 400 litres of milk at the store across several deliveries. Which ones go out today decides how much gets thrown away.
Take any available units
Treat the stock as a single quantity and decrement it.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
STOCK["Milk: 400 units - one number"] --> ORD["Order for 50"]
ORD --> DEC["quantity = 350"]
DEC --> BLIND["No idea which units left"]
BLIND --> OLD["The batch expiring tomorrow is still on the shelf"]
OLD --> WASTE["Written off, while fresher stock was shipped"]Quantity alone cannot express expiry, so the system has no way to prefer older stock. Waste is the default outcome, and it is invisible until the write-off report.
Track lots and ship the oldest first
Model stock as lots with a received date, and allocate first-in-first-out.
Much better: the oldest stock moves first and waste drops sharply. But receipt order is a proxy for expiry, not the same thing — a lot received later can easily expire sooner, depending on the supplier and the cold chain. FIFO gets this wrong precisely for the items that matter.
FEFO, against the delivery date
Allocate by first-expired-first-out, and check the expiry against when the customer will actually receive it:
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
ORD["Order at a location"] --> SORT["Lots sorted by expiry_date ascending"]
SORT --> FILT{"Expiry after the delivery date + minimum shelf life?"}
FILT -->|"no"| SKIP["Skip - it would arrive unsellable"]
FILT -->|"yes"| RES["Reserve with a conditional update: quantity >= requested"]
RES --> NEXT["Short? Take the next lot"]
SCAN["Scan event - client-generated UUID"] --> IDEM["Idempotent: safe to resend after an offline period"]
IDEM --> MOVE["Decrement source lot, increment destination - never below zero"]- Sort by expiry, not by receipt. It is the same shape of algorithm and it optimises the thing that actually causes waste.
- Minimum remaining shelf life is the part people miss. A lot expiring in two days should not be allocated to an order delivering in three, and a customer expects more than a day of usable life — so the filter is against the delivery date plus a threshold, not against today.
- Reserve with a conditional update (
quantity >= requested), so two concurrent orders cannot allocate the same units. - Scan events carry a client-generated UUID, which makes them idempotent — essential when handheld scanners go offline in a warehouse and resend their queue later.
An expiry-sorted index over lots makes both the allocation and the daily sweep for soon-to-expire stock the same cheap query — and that sweep is what drives markdowns and transfers, which is where the remaining waste is recovered.
Scale and ReliabilityScale
- Partition movements by location, so events for one location stay in order.
- Offline scanners queue events locally with timestamps and sync later. The service orders by event time within the location.
- Cold chain: attach temperature sensor readings to trucks and lots. If the temperature goes out of range, mark affected lots as at-risk.
- Stock views per region are cached for dashboards, while allocation reads the strongly consistent DB.
Wrap-UpWrap-up
Track stock by lot (with expiry) and location, with an append-only movements log as the source of truth and a current stock table updated idempotently from scanner events (UUIDs make offline resends safe). Allocate orders FEFO with conditional reservations, run expiry monitoring that triggers markdowns, transfers or discards before spoilage, reconcile with physical counts, and flag cold-chain temperature breaches on the affected lots.