Inventory management

Design inventory management

Multi-warehouse inventory LLD: add/remove/transfer with no-negative stock, availability queries, per-warehouse low-stock Observer alerts, coarse locks + ordered multi-lock transfers, then reservations and in-transit Transfer holders.

What you’re tracking

01Warehouses

Fixed at startup

02Stock

Add / remove

03Transfer

Atomic move

04Alerts

Per WH threshold

An inventory system tracks product quantities across warehouses: receive shipments, fulfill removals, transfer between sites, and notify when stock runs low. Product catalogs and orders live upstream — here you own counts, invariants, and coordination.
Inventory flow
Manager routes to warehouses; each warehouse owns qty + alert configs.

Analogy: two warehouses, one shared ledger rule

Inventory lock ordering
Agree key order so transfers don’t deadlock.

Transferring stock A→B while someone else does B→A is two people each holding one warehouse key and waiting for the other — deadlock. Agree to always pick up keys in warehouse-id order first.

Clarifying questions → locked requirements

  • Warehouses? → Fixed set at init (no dynamic warehouse CRUD).
  • Low-stock? → Per product per warehouse; pluggable callback, not email wiring.
  • Negative stock? → Reject — remove/transfer must fail cleanly.
  • Concurrency? → Thread-safe from the start (receive + fulfill race).
  • Out of scope? → Catalog, orders/payments, persistence.

Entities

Inventory entities
Manager orchestrates; Warehouse owns stock + alerts; Product is a string key.
  • InventoryManager — public API; map warehouseId → Warehouse.
  • Warehouse — inventory map, alert configs, no-negative rule.
  • AlertConfig — threshold + listener value object.
  • AlertListener — Observer interface onLowStock(warehouseId, productId, qty).
  • Not entities: Product (external key), Order, Payment.

Class design

class InventoryManager:
    - warehouses: dict[str, Warehouse]
    + InventoryManager(warehouse_ids)
    + add_stock(wh, product, qty) -> None
    + remove_stock(wh, product, qty) -> bool
    + transfer(product, from_wh, to_wh, qty) -> bool
    + warehouses_with_availability(product, qty) -> list[str]
    + set_low_stock_alert(wh, product, threshold, listener) -> None


class Warehouse:
    - id: str
    - inventory: dict[str, int]
    - alert_configs: dict[str, list[AlertConfig]]
    + add_stock / remove_stock / get_stock / check_availability
    + set_low_stock_alert
    - _alerts_to_fire(product, prev, new) -> list  # collect under lock


class AlertConfig:
    - threshold: int
    - listener: AlertListener


class AlertListener(Protocol):
    def on_low_stock(self, warehouse_id, product_id, current_qty) -> None: ...
class InventoryManager {
    Map<String, Warehouse> warehouses;
    InventoryManager(List<String> warehouseIds) { /* ... */ }
    void addStock(String wh, String product, int qty) { /* ... */ }
    boolean removeStock(String wh, String product, int qty) { /* ... */ return false; }
    boolean transfer(String product, String fromWh, String toWh, int qty) { /* ... */ return false; }
    List<String> warehousesWithAvailability(String product, int qty) { /* ... */ return List.of(); }
    void setLowStockAlert(String wh, String product, int threshold, AlertListener listener) { /* ... */ }
}

class Warehouse {
    String id;
    Map<String, Integer> inventory;
    Map<String, List<AlertConfig>> alertConfigs;
    void addStock(String product, int qty) { /* ... */ }
    boolean removeStock(String product, int qty) { /* ... */ return false; }
    int getStock(String product) { /* ... */ return 0; }
    boolean checkAvailability(String product, int qty) { /* ... */ return false; }
    void setLowStockAlert(String product, int threshold, AlertListener listener) { /* ... */ }
    // collect under lock
    List<Object[]> alertsToFire(String product, int prev, int neu) { /* ... */ return List.of(); }
}

class AlertConfig {
    int threshold;
    AlertListener listener;
}

interface AlertListener {
    void onLowStock(String warehouseId, String productId, int currentQty);
}

InventoryManager — especially transfer

add/remove/setAlert are lookup + delegate. Availability walks all warehouses. Transfer needs atomicity across two warehouses.
def transfer(self, product: str, from_id: str, to_id: str, qty: int) -> bool:
    if qty <= 0 or from_id == to_id:
        return False
    src, dst = self.warehouses.get(from_id), self.warehouses.get(to_id)
    if src is None or dst is None:
        return False

    # Consistent lock order → no deadlock A↔B
    first, second = (src, dst) if from_id < to_id else (dst, src)
    with first._lock:
        with second._lock:
            if not src.remove_stock(product, qty):  # reentrant lock assumed
                return False
            dst.add_stock(product, qty)
            return True
boolean transfer(String product, String fromId, String toId, int qty) {
    if (qty <= 0 || fromId.equals(toId)) return false;
    Warehouse src = warehouses.get(fromId), dst = warehouses.get(toId);
    if (src == null || dst == null) return false;

    // Consistent lock order → no deadlock A↔B
    Warehouse first = fromId.compareTo(toId) < 0 ? src : dst;
    Warehouse second = fromId.compareTo(toId) < 0 ? dst : src;
    first.lock.lock();
    try {
        second.lock.lock();
        try {
            if (!src.removeStock(product, qty)) return false; // reentrant lock assumed
            dst.addStock(product, qty);
            return true;
        } finally {
            second.lock.unlock();
        }
    } finally {
        first.lock.unlock();
    }
}

Warehouse — locks, alerts outside critical section

Default: one lock per warehouse (coarse). Short map ops — fine for interviews. Per-product locks only if pushed on throughput (then ConcurrentHashMap + lock ordering on multi-product ops).
Alert firing rule: fire only on downward threshold crossing (prev >= threshold and new < threshold) — no spam while already low; recovers when stock climbs above then drops again. No hasFired flag needed.
def add_stock(self, product: str, qty: int) -> None:
    if qty <= 0:
        raise ValueError("qty")
    to_fire = []
    with self._lock:
        prev = self.inventory.get(product, 0)
        new = prev + qty
        self.inventory[product] = new
        to_fire = self._alerts_to_fire(product, prev, new)
    for listener, p, q in to_fire:  # OUTSIDE lock — no I/O under mutex
        listener.on_low_stock(self.id, p, q)


def remove_stock(self, product: str, qty: int) -> bool:
    if qty <= 0:
        return False
    to_fire = []
    with self._lock:
        prev = self.inventory.get(product, 0)
        if prev < qty:
            return False
        new = prev - qty
        self.inventory[product] = new
        to_fire = self._alerts_to_fire(product, prev, new)
    for listener, p, q in to_fire:
        listener.on_low_stock(self.id, p, q)
    return True


def _alerts_to_fire(self, product, prev, new):
    out = []
    for cfg in self.alert_configs.get(product, []):
        if prev >= cfg.threshold and new < cfg.threshold:
            out.append((cfg.listener, product, new))
    return out
void addStock(String product, int qty) {
    if (qty <= 0) throw new IllegalArgumentException("qty");
    List<Object[]> toFire = List.of();
    lock.lock();
    try {
        int prev = inventory.getOrDefault(product, 0);
        int neu = prev + qty;
        inventory.put(product, neu);
        toFire = alertsToFire(product, prev, neu);
    } finally {
        lock.unlock();
    }
    for (Object[] t : toFire) // OUTSIDE lock — no I/O under mutex
        ((AlertListener) t[0]).onLowStock(id, (String) t[1], (int) t[2]);
}

boolean removeStock(String product, int qty) {
    if (qty <= 0) return false;
    List<Object[]> toFire = List.of();
    lock.lock();
    try {
        int prev = inventory.getOrDefault(product, 0);
        if (prev < qty) return false;
        int neu = prev - qty;
        inventory.put(product, neu);
        toFire = alertsToFire(product, prev, neu);
    } finally {
        lock.unlock();
    }
    for (Object[] t : toFire)
        ((AlertListener) t[0]).onLowStock(id, (String) t[1], (int) t[2]);
    return true;
}

List<Object[]> alertsToFire(String product, int prev, int neu) {
    List<Object[]> out = new ArrayList<>();
    for (AlertConfig cfg : alertConfigs.getOrDefault(product, List.of())) {
        if (prev >= cfg.threshold && neu < cfg.threshold)
            out.add(new Object[]{ cfg.listener, product, neu });
    }
    return out;
}

Verification

  • Alert crossing — 15→9 fires (threshold 10); 9→7 silent; 7→22 silent; 22→9 fires again.
  • Atomic transfer — EAST 7 → WEST 0, transfer 5 → EAST 2 / WEST 5; both locked for the whole move.
  • No negative — transfer 10 from EAST(2) fails; state unchanged; availability query returns only warehouses with enough stock.
  • Happy: Warehouse A has 10; transfer 4 to B → A=6,B+=4; total constant.
  • Failure: transfer 50 when A has 6 → reject; allocate more than available → reject.
  • Concurrency: allocate and transfer on same SKU — ordered locks prevent deadlock and negative stock.

Extensibility

Checkout reservations. Track reserved separately; available = on_hand - reserved. reserve → confirm → release with TTL + background expiry — same shape as movie seat holds. Prevents “filled the form then OOS.”
In-transit transfers. Atomic teleport is interview-simple. Real shipments: introduce Transfer as an InventoryHolder (same add/remove/get interface as Warehouse). initiate removes from source into Transfer; complete moves Transfer → destination. Stock always accounted for; in-transit not sellable.

Common interview pitfalls

These mistakes show up constantly on this prompt. Name the trap, then show the fix in your design — don’t wait for the interviewer to catch you.

  • Single integer stock with no warehouse dimension — transfers become hand-waves.
  • Negative stock possible because decrement isn’t atomic with the check.
  • Observer/low-stock alerts bolted on before the transfer invariant works.
  • Transfer that decrements source after incrementing dest (or vice versa) without a transaction narrative.
  • SKU vs WarehouseStock confusion — who owns quantity?
  • Ignoring idempotency of restock/allocate under retries.

Interview script (say this)

Read this once out loud before a mock. It’s the spine of a strong answer — not a script to recite robotically.

  1. Clarify SKUs, multi-warehouse, allocate/reserve vs commit, transfer, low-stock threshold.
  2. v1: InventoryManager + Warehouse; quantities per (warehouse, sku); atomic transfer.
  3. APIs: add_stock, allocate, transfer, get_availability.
  4. Invariants: never negative; transfer conserves total unless damage/shrink write-off.
  5. Optional Observer for low-stock — after correctness.
  6. Trace: transfer 5 units A→B; concurrent allocate on source.
  7. Lock strategy: lock manager or ordered warehouse locks to avoid deadlock.

Extra verification traces

Walk these three traces on the board. If you can narrate them cleanly, your implementation section usually follows.

def transfer(self, sku, src, dst, qty):
    for wh_id in sorted([src, dst]):
        self.warehouses[wh_id].lock.acquire()
    try:
        self.warehouses[src].remove(sku, qty)  # raises if insufficient
        self.warehouses[dst].add(sku, qty)
    finally:
        for wh_id in sorted([src, dst], reverse=True):
            self.warehouses[wh_id].lock.release()
void transfer(String sku, String src, String dst, int qty) {
    List<String> order = Arrays.asList(src, dst);
    Collections.sort(order);
    for (String whId : order) warehouses.get(whId).lock.lock();
    try {
        warehouses.get(src).remove(sku, qty); // throws if insufficient
        warehouses.get(dst).add(sku, qty);
    } finally {
        Collections.reverse(order);
        for (String whId : order) warehouses.get(whId).lock.unlock();
    }
}

Staff-level follow-ups

At staff+, they twist the prompt. Answer in one sentence that names the seam — don’t redesign the whole board.

  • Reservations for orders? — RESERVED vs ON_HAND buckets; expire reservations like seat holds.
  • Multi-region? — Shard by warehouse; transfers become async jobs with outbox.
  • Lot/expiry tracking? — StockLayer under WarehouseStock; allocate FEFO strategy.
  • Audit? — Append-only StockLedger entries for every mutation.

Complete solution: stock + transfer

from threading import Lock
from typing import Callable, Optional

class Warehouse:
    def __init__(self, wid: str):
        self.id = wid
        self.stock: dict[str, int] = {}
        self.lock = Lock()
        self.alerts: dict[str, tuple[int, Callable]] = {}

    def add(self, sku: str, n: int) -> None:
        with self.lock:
            self.stock[sku] = self.stock.get(sku, 0) + n
            self._maybe_alert(sku)

    def remove(self, sku: str, n: int) -> bool:
        with self.lock:
            if self.stock.get(sku, 0) < n:
                return False
            self.stock[sku] -= n
            self._maybe_alert(sku)
            return True

    def _maybe_alert(self, sku: str) -> None:
        if sku in self.alerts:
            thr, cb = self.alerts[sku]
            if self.stock.get(sku, 0) <= thr:
                cb(self.id, sku, self.stock.get(sku, 0))

class InventoryManager:
    def __init__(self, warehouses: list[Warehouse]):
        self.wh = {w.id: w for w in warehouses}

    def transfer(self, src: str, dst: str, sku: str, n: int) -> bool:
        a, b = self.wh[src], self.wh[dst]
        first, second = (a, b) if a.id <= b.id else (b, a)
        with first.lock:
            with second.lock:
                if a.stock.get(sku, 0) < n:
                    return False
                a.stock[sku] -= n
                b.stock[sku] = b.stock.get(sku, 0) + n
                return True
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;

@FunctionalInterface
interface LowStockCallback {
    void onLowStock(String warehouseId, String sku, int qty);
}

class Warehouse {
    String id;
    Map<String, Integer> stock = new HashMap<>();
    final ReentrantLock lock = new ReentrantLock();
    Map<String, Object[]> alerts = new HashMap<>(); // sku → {threshold, callback}

    Warehouse(String wid) { this.id = wid; }

    void add(String sku, int n) {
        lock.lock();
        try {
            stock.put(sku, stock.getOrDefault(sku, 0) + n);
            maybeAlert(sku);
        } finally {
            lock.unlock();
        }
    }

    boolean remove(String sku, int n) {
        lock.lock();
        try {
            if (stock.getOrDefault(sku, 0) < n) return false;
            stock.put(sku, stock.get(sku) - n);
            maybeAlert(sku);
            return true;
        } finally {
            lock.unlock();
        }
    }

    void maybeAlert(String sku) {
        if (!alerts.containsKey(sku)) return;
        Object[] a = alerts.get(sku);
        int thr = (int) a[0];
        LowStockCallback cb = (LowStockCallback) a[1];
        int qty = stock.getOrDefault(sku, 0);
        if (qty <= thr) cb.onLowStock(id, sku, qty);
    }
}

class InventoryManager {
    Map<String, Warehouse> wh = new HashMap<>();

    InventoryManager(List<Warehouse> warehouses) {
        for (Warehouse w : warehouses) wh.put(w.id, w);
    }

    boolean transfer(String src, String dst, String sku, int n) {
        Warehouse a = wh.get(src), b = wh.get(dst);
        Warehouse first = a.id.compareTo(b.id) <= 0 ? a : b;
        Warehouse second = a.id.compareTo(b.id) <= 0 ? b : a;
        first.lock.lock();
        try {
            second.lock.lock();
            try {
                if (a.stock.getOrDefault(sku, 0) < n) return false;
                a.stock.put(sku, a.stock.get(sku) - n);
                b.stock.put(sku, b.stock.getOrDefault(sku, 0) + n);
                return true;
            } finally {
                second.lock.unlock();
            }
        } finally {
            first.lock.unlock();
        }
    }
}

Concurrency cases

  • Oversell — two remove(1) on qty=1 without lock → both succeed.
  • Transfer deadlock — A→B and B→A; fix with ordered locks.
  • Alert callback — invoke outside lock if callback is slow/re-entrant into Inventory.

← Lattice