Skip to content

18 — Problem: Inventory Management

Understanding the Problem

Inventory management looks trivial on the whiteboard: an integer counter per SKU, decrement on purchase, increment on restock. That framing is a trap. Production inventory systems fail in subtle, expensive ways — overselling during flash sales, stuck reservations that block real buyers, split-brain between warehouses during a network partition, phantom stock from a crash between "charge card" and "decrement counter." The data structure is not the problem. The lifecycle of a unit of stock — from "available" to "held for a cart" to "shipped" (or "returned to pool") — is.

The senior answer

The senior answer talks about reservation lifecycle before touching CRUD. Junior candidates design POST /inventory/decrement. Mid-level candidates remember to add a row lock. Senior candidates open with "let's separate available from reserved so two carts racing for the last unit don't oversell, and talk about what happens when a cart is abandoned." Everything else — events, reorder policies, multi-warehouse — hangs off that backbone.

We will design a system that (a) never oversells under concurrent order load, (b) releases stuck reservations gracefully, (c) reroutes orders across warehouses when the nearest one is empty, and (d) emits events so reorder, analytics, and ops dashboards can plug in without polling the stock table.


Clarifying Questions

You: Single warehouse or multi-warehouse fulfillment?

Interviewer: Design for multi-warehouse. A single order can pull from one warehouse, or be split across two if no single warehouse has full stock.

You: What's the oversell policy — hard (never oversell) or soft (allow with later resolution, like Amazon sometimes does for high-demand launches)?

Interviewer: Hard for the primary path. Mention how you'd do soft if asked.

You: Reservation lifecycle — how long does a cart hold stock before it's released?

Interviewer: Default 15 minutes. Configurable per SKU / per customer tier.

You: Returns and restocks — do returned items go back to available immediately, or through a "damaged-goods review" step?

Interviewer: Through a review step. Returns increment a separate inspection bucket, then either available or damaged after QC.

You: SKU variants — is a blue-large t-shirt a separate SKU from red-large, or is variant a separate axis?

Interviewer: Each variant is its own SKU. Kitting and bundling (one "kit SKU" = 3 component SKUs) is out of scope for v1 but mention the extension.

You: Reorder automation — do we auto-place purchase orders to suppliers when stock is low?

Interviewer: Emit a LowStockEvent. A downstream service decides whether to reorder. Don't hard-code reorder in the inventory service.

You: Concurrent order volume?

Interviewer: Peak ~10k orders/sec during flash sales, across millions of SKUs. Assume hot SKUs see ~500 concurrent reserve attempts.

You: Consistency model across warehouses — strong or eventual?

Interviewer: Strong within a warehouse (per-warehouse row owns truth). Eventual across warehouses for aggregate views; but a reserve against a specific warehouse must be strongly consistent against that warehouse's row.

You: Auth, billing, shipping — in scope?

Interviewer: Out of scope. Assume an order service calls us; we don't own money or shipping labels.


Functional Requirements

  1. Stock querygetStock(sku, warehouseId?) returns available / reserved / damaged / on-hand counts. If warehouseId is omitted, aggregate across warehouses.
  2. Reservereserve(sku, qty, warehouseId, orderId, ttlSeconds?) atomically holds stock. Returns a Reservation with an expiry timestamp. Never oversells.
  3. Commitcommit(reservationId) converts a reservation into a permanent decrement. Idempotent.
  4. Releaserelease(reservationId) returns reserved stock to available. Idempotent.
  5. Expire — background sweeper auto-releases reservations past their TTL and emits ReservationExpiredEvent.
  6. Restockrestock(sku, warehouseId, qty, reason) increments available stock (supplier delivery, return after QC, cycle-count correction).
  7. Adjustadjust(sku, warehouseId, delta, reason) signed adjustment for damage, recall, audit correction.
  8. Allocation for multi-warehouse ordersallocate(sku, qty, customerLocation) picks one or more warehouses based on a strategy and returns a reservation bundle.
  9. Event stream — publishes StockChangedEvent, LowStockEvent, ReservationCreatedEvent, ReservationCommittedEvent, ReservationReleasedEvent, ReservationExpiredEvent.
  10. Reorder policy hook — pluggable policy evaluated on every stock change; emits LowStockEvent when its threshold is crossed.

Out of scope: pricing, payments, shipping carriers, catalog/product metadata, physical warehouse operations (picking, packing, labels).


Non-Functional Requirements

ConcernTargetWhy
CorrectnessZero oversell under any concurrency pattern (primary path)An oversell costs money, support tickets, and trust. This is the one line we do not cross.
Latencyp99 reserve < 50 ms, commit/release < 30 msReserve is in the critical path of checkout. Every 100 ms on checkout costs conversion.
Availability99.99% for reserve/commit on a per-warehouse basisA warehouse going dark should not block orders for other warehouses — the allocator routes around it.
Throughput10k reserve/sec sustained, 50k/sec peakFlash-sale traffic. Per-warehouse-per-SKU hotspots are the real bottleneck, not aggregate.
Scale10M SKUs × 50 warehouses = 500M InventoryItem rowsSharded by SKU hash; hot SKUs get dedicated rows per warehouse.
DurabilityEvery stock change is event-sourced; last 90 days replayableAuditors want "why did stock go from 12 to 10 at 14:03?" You need the event log to answer.
ObservabilityEvery reserve/commit/release carries orderId, reservationId, sku, warehouseId, qty in logs and metricsDebugging oversells requires tracing a single unit of stock backwards.

The tension: strong consistency + multi-warehouse + 10k/sec is hard. We resolve it by making each warehouse row the unit of strong consistency, and making cross-warehouse operations sagas rather than distributed transactions.


Core Entities and Relationships

EntityResponsibility
SKUStable product identifier. Opaque to inventory — we don't care what the SKU is, only its stock levels.
WarehouseA physical fulfillment location with an ID, region, and capacity.
InventoryItemThe core row. Keyed by (sku, warehouseId). Owns available, reserved, damaged, inspection, onHand, and a version for optimistic locking.
ReservationA hold on stock. Keyed by reservationId. Has sku, warehouseId, qty, orderId, state, expiresAt.
OrderExternal — passed in as orderId. We track it only as the correlating key on reservations.
ReorderPolicyPer-SKU policy: "when available < reorderPoint, emit LowStockEvent." Pluggable.
InventoryEventThe append-only record: StockChanged, LowStock, ReservationCreated/Committed/Released/Expired.
AllocationStrategyPicks which warehouse(s) to reserve from for a multi-warehouse-capable order.

Relationships:

SKU ────< InventoryItem >──── Warehouse

              │ emits

       InventoryEvent ────► EventBus ────► ReorderPolicy, Analytics, UI

              │ creates/consumes

         Reservation ────► Order (external)

Why split available / reserved / damaged / inspection? A single counter can't tell "I have 10 units" apart from "I have 10 units but 7 are held by open carts." The split lets you answer "how many can I sell right now" (available) and "how many do I physically have on the shelf" (onHand) with different queries. Invariant: available + reserved + damaged + inspection = onHand.


Interfaces

typescript
// Stable identifiers — string aliases make signatures self-documenting.
type SKU = string;
type WarehouseId = string;
type OrderId = string;
type ReservationId = string;

enum ReservationState {
  ACTIVE = "ACTIVE",
  COMMITTED = "COMMITTED",
  RELEASED = "RELEASED",
  EXPIRED = "EXPIRED",
}

interface InventoryItem {
  sku: SKU;
  warehouseId: WarehouseId;
  available: number;
  reserved: number;
  damaged: number;
  inspection: number;
  onHand: number; // = available + reserved + damaged + inspection
  version: number; // optimistic-lock token
  reorderPoint: number;
  reorderQty: number;
}

interface Reservation {
  id: ReservationId;
  sku: SKU;
  warehouseId: WarehouseId;
  qty: number;
  orderId: OrderId;
  state: ReservationState;
  createdAt: number; // epoch ms
  expiresAt: number; // epoch ms
}

interface IInventoryRepository {
  getItem(sku: SKU, warehouseId: WarehouseId): Promise<InventoryItem | null>;
  // Compare-and-swap using the version token. Returns the new item on success, null on version mismatch.
  updateItem(item: InventoryItem): Promise<InventoryItem | null>;
  getReservation(id: ReservationId): Promise<Reservation | null>;
  saveReservation(r: Reservation): Promise<void>;
  // Returns reservations whose expiresAt < now and state === ACTIVE, limited by batch.
  findExpiredReservations(now: number, limit: number): Promise<Reservation[]>;
}

interface IReorderPolicy {
  shouldReorder(item: InventoryItem): boolean;
  reorderQty(item: InventoryItem): number;
}

interface IEventPublisher {
  publish(event: InventoryEvent): Promise<void>;
}

interface IAllocationStrategy {
  // Given a SKU, desired qty, and customer context, returns a list of (warehouseId, qty) proposals
  // that together sum to qty. Caller reserves against each.
  plan(sku: SKU, qty: number, ctx: AllocationContext): Promise<AllocationPlan[]>;
}

interface AllocationContext {
  customerRegion: string;
  preferSingleWarehouse: boolean;
  excludeWarehouses?: WarehouseId[];
}

interface AllocationPlan {
  warehouseId: WarehouseId;
  qty: number;
}

type InventoryEvent =
  | { type: "StockChanged"; sku: SKU; warehouseId: WarehouseId; delta: number; reason: string; at: number }
  | { type: "LowStock"; sku: SKU; warehouseId: WarehouseId; available: number; reorderPoint: number; at: number }
  | { type: "ReservationCreated"; reservationId: ReservationId; sku: SKU; warehouseId: WarehouseId; qty: number; at: number }
  | { type: "ReservationCommitted"; reservationId: ReservationId; at: number }
  | { type: "ReservationReleased"; reservationId: ReservationId; at: number }
  | { type: "ReservationExpired"; reservationId: ReservationId; at: number };

Class Diagram

                         ┌────────────────────────────────────┐
                         │        InventoryService            │
                         │  (facade / use-case orchestrator)  │
                         │                                    │
                         │  + reserve(...)                    │
                         │  + commit(id)                      │
                         │  + release(id)                     │
                         │  + restock(...)                    │
                         │  + adjust(...)                     │
                         │  + getStock(...)                   │
                         └──┬───────────┬───────────┬─────────┘
                            │           │           │
          ┌─────────────────┘           │           └──────────────────┐
          ▼                             ▼                              ▼
┌────────────────────┐     ┌──────────────────────────┐     ┌─────────────────────┐
│ IInventoryRepo     │     │  IAllocationStrategy     │     │  IEventPublisher    │
│                    │     │  (strategy pattern)      │     │  (observer / bus)   │
│ + getItem          │     │                          │     │                     │
│ + updateItem (CAS) │     │  NearestFirst            │     │  publish(event)     │
│ + saveReservation  │     │  CheapestShip            │     │                     │
│ + findExpired      │     │  SplitAcrossWarehouses   │     │                     │
└────────────────────┘     └──────────────────────────┘     └──────┬──────────────┘
                                                                   │ subscribes

                                                        ┌──────────────────────┐
                                                        │  IReorderPolicy      │
                                                        │  (strategy pattern)  │
                                                        │                      │
                                                        │  FixedThreshold      │
                                                        │  MovingAverage       │
                                                        └──────────────────────┘

┌────────────────────┐            ┌──────────────────────────┐
│  ReservationSweeper│───uses────▶│  IInventoryRepository    │
│  (background job)  │            └──────────────────────────┘
│                    │
│  + runOnce()       │
└────────────────────┘

           State machine on Reservation:
                                   commit()
                   ┌──────────────────────────────────┐
                   │                                  ▼
                ACTIVE ──release()──▶ RELEASED    COMMITTED

                   └──expire (sweeper)──▶ EXPIRED

Class Design

InventoryItem — the invariant

Every operation preserves: available + reserved + damaged + inspection === onHand. If a code path can violate this, it's a bug, not a design choice. We enforce it with assertions in every mutator.

  • available — sellable right now.
  • reserved — held by active reservations. Not sellable. Not yet removed from the warehouse.
  • damaged — physically present but unsellable (broken, expired).
  • inspection — returned items awaiting QC.
  • onHand — total physical count. Equals the sum above.
  • version — monotonically increasing integer. Every updateItem bumps it. The repository's CAS uses it to detect concurrent writes.
  • reorderPoint / reorderQty — policy inputs, copied onto the item so a reorder evaluation never needs a second lookup.

Reservation — the state machine

               ┌──────────────┐
               │   ACTIVE     │◀───── initial state (reserve() returns one of these)
               └──────┬───────┘

    ┌─────────────────┼─────────────────┐
    │                 │                 │
commit()          release()        (sweeper)
    │                 │                 │
    ▼                 ▼                 ▼
┌───────────┐   ┌───────────┐    ┌───────────┐
│ COMMITTED │   │ RELEASED  │    │ EXPIRED   │
└───────────┘   └───────────┘    └───────────┘
  terminal       terminal         terminal

Transitions are one-way; all three terminal states are equivalent from a stock-accounting perspective (RELEASED and EXPIRED both return stock to available; COMMITTED converts it to a permanent decrement of onHand). But we keep them distinct because analytics and fraud detection care about the reason a reservation ended.

Idempotency: commit and release must tolerate repeat calls. If a reservation is already COMMITTED and commit is called again, return success silently. If it's EXPIRED and release is called (a payment retry arriving after the sweep), the operation is a no-op — stock has already been returned. If it's EXPIRED and commit is called, we have a real problem: the user paid for stock we already released. That's the canonical "race against the sweeper" failure, handled in the Edge Cases section.

ReorderPolicy — strategy

typescript
class FixedThresholdPolicy implements IReorderPolicy {
  shouldReorder(item: InventoryItem): boolean {
    return item.available <= item.reorderPoint;
  }
  reorderQty(item: InventoryItem): number {
    return item.reorderQty;
  }
}

class MovingAveragePolicy implements IReorderPolicy {
  constructor(private readonly salesVelocity: Map<SKU, number>, private readonly leadTimeDays: number) {}
  shouldReorder(item: InventoryItem): boolean {
    const velocity = this.salesVelocity.get(item.sku) ?? 0;
    const safetyStock = velocity * this.leadTimeDays;
    return item.available <= safetyStock;
  }
  reorderQty(item: InventoryItem): number {
    const velocity = this.salesVelocity.get(item.sku) ?? 0;
    return Math.max(item.reorderQty, Math.ceil(velocity * this.leadTimeDays * 2));
  }
}

The policy is a pure function — no side effects, no DB access. The service evaluates it after every stock change and emits a LowStockEvent if it returns true.

AllocationStrategy — strategy

Picks which warehouse(s) to reserve from. Three concrete strategies:

  • NearestFirstStrategy — pick the warehouse closest to the customer. If it can't fulfill the full qty, stop and report partial. Used when single-warehouse preference is set.
  • SplitAcrossWarehousesStrategy — fill from nearest first, then next-nearest, etc., until qty is satisfied. Used for high-value orders where split shipment is acceptable.
  • CheapestShippingStrategy — pick the warehouse with the lowest outbound shipping cost to the customer's region.

Strategy choice is per-order, driven by product category, customer tier, and business rules.


Key Methods

typescript
class InventoryService {
  constructor(
    private readonly repo: IInventoryRepository,
    private readonly events: IEventPublisher,
    private readonly reorderPolicy: IReorderPolicy,
    private readonly allocator: IAllocationStrategy,
    private readonly clock: () => number = Date.now,
    private readonly idGen: () => string = () => crypto.randomUUID(),
  ) {}

  private static readonly DEFAULT_TTL_MS = 15 * 60 * 1000;
  private static readonly MAX_RESERVE_RETRIES = 5;

  /**
   * Reserve stock against a specific warehouse. Atomic: never oversells.
   * Uses optimistic concurrency; retries on version conflict.
   */
  async reserve(
    sku: SKU,
    qty: number,
    warehouseId: WarehouseId,
    orderId: OrderId,
    ttlMs: number = InventoryService.DEFAULT_TTL_MS,
  ): Promise<Reservation> {
    if (qty <= 0) throw new Error("qty must be positive");

    for (let attempt = 0; attempt < InventoryService.MAX_RESERVE_RETRIES; attempt++) {
      const item = await this.repo.getItem(sku, warehouseId);
      if (!item) throw new Error(`unknown sku/warehouse: ${sku}/${warehouseId}`);

      if (item.available < qty) {
        throw new InsufficientStockError(sku, warehouseId, qty, item.available);
      }

      // Propose the new state — move `qty` from available to reserved.
      const proposed: InventoryItem = {
        ...item,
        available: item.available - qty,
        reserved: item.reserved + qty,
        version: item.version + 1,
      };

      const saved = await this.repo.updateItem(proposed);
      if (!saved) {
        // Version mismatch — someone else wrote between our read and write. Retry.
        continue;
      }

      const now = this.clock();
      const reservation: Reservation = {
        id: this.idGen(),
        sku,
        warehouseId,
        qty,
        orderId,
        state: ReservationState.ACTIVE,
        createdAt: now,
        expiresAt: now + ttlMs,
      };
      await this.repo.saveReservation(reservation);

      // Post-commit: emit events and evaluate reorder. Best-effort; failures here don't
      // invalidate the reservation. In a production system these would go to an outbox.
      await this.emitStockChanged(sku, warehouseId, -qty, "reserve", now);
      await this.events.publish({
        type: "ReservationCreated",
        reservationId: reservation.id,
        sku,
        warehouseId,
        qty,
        at: now,
      });
      await this.maybeEmitLowStock(saved, now);

      return reservation;
    }

    throw new ConcurrencyExhaustedError(sku, warehouseId);
  }

  /**
   * Commit a reservation: convert the held `qty` into a permanent decrement of onHand.
   * Idempotent — re-calling on a COMMITTED reservation returns success.
   */
  async commit(reservationId: ReservationId): Promise<void> {
    const r = await this.repo.getReservation(reservationId);
    if (!r) throw new Error(`unknown reservation: ${reservationId}`);

    if (r.state === ReservationState.COMMITTED) return; // idempotent
    if (r.state === ReservationState.RELEASED) {
      throw new ReservationTerminalError(reservationId, r.state);
    }
    if (r.state === ReservationState.EXPIRED) {
      // Critical race: caller paid but we released stock. Let the caller decide recovery.
      throw new ReservationExpiredError(reservationId);
    }

    for (let attempt = 0; attempt < InventoryService.MAX_RESERVE_RETRIES; attempt++) {
      const item = await this.repo.getItem(r.sku, r.warehouseId);
      if (!item) throw new Error(`inventory row vanished for ${r.sku}/${r.warehouseId}`);

      // Commit: decrement reserved AND onHand. available is unchanged.
      const proposed: InventoryItem = {
        ...item,
        reserved: item.reserved - r.qty,
        onHand: item.onHand - r.qty,
        version: item.version + 1,
      };

      const saved = await this.repo.updateItem(proposed);
      if (!saved) continue;

      await this.repo.saveReservation({ ...r, state: ReservationState.COMMITTED });
      const now = this.clock();
      await this.emitStockChanged(r.sku, r.warehouseId, 0, "commit", now);
      await this.events.publish({ type: "ReservationCommitted", reservationId, at: now });
      return;
    }
    throw new ConcurrencyExhaustedError(r.sku, r.warehouseId);
  }

  /**
   * Release a reservation: return `qty` to available. Idempotent.
   */
  async release(reservationId: ReservationId): Promise<void> {
    const r = await this.repo.getReservation(reservationId);
    if (!r) throw new Error(`unknown reservation: ${reservationId}`);

    if (r.state === ReservationState.RELEASED || r.state === ReservationState.EXPIRED) return;
    if (r.state === ReservationState.COMMITTED) {
      throw new ReservationTerminalError(reservationId, r.state);
    }

    for (let attempt = 0; attempt < InventoryService.MAX_RESERVE_RETRIES; attempt++) {
      const item = await this.repo.getItem(r.sku, r.warehouseId);
      if (!item) throw new Error(`inventory row vanished for ${r.sku}/${r.warehouseId}`);

      const proposed: InventoryItem = {
        ...item,
        reserved: item.reserved - r.qty,
        available: item.available + r.qty,
        version: item.version + 1,
      };

      const saved = await this.repo.updateItem(proposed);
      if (!saved) continue;

      await this.repo.saveReservation({ ...r, state: ReservationState.RELEASED });
      const now = this.clock();
      await this.emitStockChanged(r.sku, r.warehouseId, +r.qty, "release", now);
      await this.events.publish({ type: "ReservationReleased", reservationId, at: now });
      return;
    }
    throw new ConcurrencyExhaustedError(r.sku, r.warehouseId);
  }

  /**
   * Background sweep: expire ACTIVE reservations past their TTL. Returns count expired.
   * Safe to run concurrently (each expiration is independently CAS-protected).
   */
  async expireStaleReservations(batchSize: number = 500): Promise<number> {
    const now = this.clock();
    const expired = await this.repo.findExpiredReservations(now, batchSize);
    let count = 0;

    for (const r of expired) {
      try {
        // Reuse release() semantics but mark as EXPIRED rather than RELEASED.
        // Conceptually the same stock movement; different terminal tag.
        await this.expireOne(r, now);
        count++;
      } catch (e) {
        // Log and continue; don't let one bad reservation block the sweep.
        console.warn(`expire failed for ${r.id}:`, e);
      }
    }
    return count;
  }

  private async expireOne(r: Reservation, now: number): Promise<void> {
    if (r.state !== ReservationState.ACTIVE) return;

    for (let attempt = 0; attempt < InventoryService.MAX_RESERVE_RETRIES; attempt++) {
      const item = await this.repo.getItem(r.sku, r.warehouseId);
      if (!item) return;

      const proposed: InventoryItem = {
        ...item,
        reserved: item.reserved - r.qty,
        available: item.available + r.qty,
        version: item.version + 1,
      };

      const saved = await this.repo.updateItem(proposed);
      if (!saved) continue;

      await this.repo.saveReservation({ ...r, state: ReservationState.EXPIRED });
      await this.emitStockChanged(r.sku, r.warehouseId, +r.qty, "expire", now);
      await this.events.publish({ type: "ReservationExpired", reservationId: r.id, at: now });
      return;
    }
  }

  /**
   * Restock from a supplier delivery, return-after-QC, or manual correction.
   */
  async restock(sku: SKU, warehouseId: WarehouseId, qty: number, reason: string): Promise<void> {
    if (qty <= 0) throw new Error("qty must be positive");
    for (let attempt = 0; attempt < InventoryService.MAX_RESERVE_RETRIES; attempt++) {
      const item = await this.repo.getItem(sku, warehouseId);
      if (!item) throw new Error(`unknown sku/warehouse: ${sku}/${warehouseId}`);
      const proposed: InventoryItem = {
        ...item,
        available: item.available + qty,
        onHand: item.onHand + qty,
        version: item.version + 1,
      };
      const saved = await this.repo.updateItem(proposed);
      if (!saved) continue;
      await this.emitStockChanged(sku, warehouseId, +qty, reason, this.clock());
      return;
    }
    throw new ConcurrencyExhaustedError(sku, warehouseId);
  }

  /**
   * Signed adjustment. Used for damage (delta negative on available, positive on damaged),
   * recalls (negative on available), cycle-count corrections. The caller specifies the bucket.
   */
  async adjust(
    sku: SKU,
    warehouseId: WarehouseId,
    delta: { available?: number; damaged?: number; inspection?: number; onHandDelta: number },
    reason: string,
  ): Promise<void> {
    for (let attempt = 0; attempt < InventoryService.MAX_RESERVE_RETRIES; attempt++) {
      const item = await this.repo.getItem(sku, warehouseId);
      if (!item) throw new Error(`unknown sku/warehouse: ${sku}/${warehouseId}`);
      const proposed: InventoryItem = {
        ...item,
        available: item.available + (delta.available ?? 0),
        damaged: item.damaged + (delta.damaged ?? 0),
        inspection: item.inspection + (delta.inspection ?? 0),
        onHand: item.onHand + delta.onHandDelta,
        version: item.version + 1,
      };
      this.assertInvariant(proposed);
      const saved = await this.repo.updateItem(proposed);
      if (!saved) continue;
      await this.emitStockChanged(sku, warehouseId, delta.onHandDelta, reason, this.clock());
      return;
    }
    throw new ConcurrencyExhaustedError(sku, warehouseId);
  }

  private assertInvariant(item: InventoryItem): void {
    if (item.available < 0 || item.reserved < 0 || item.damaged < 0 || item.inspection < 0) {
      throw new Error(`negative bucket on ${item.sku}/${item.warehouseId}`);
    }
    const sum = item.available + item.reserved + item.damaged + item.inspection;
    if (sum !== item.onHand) {
      throw new Error(`invariant violated on ${item.sku}/${item.warehouseId}: ${sum} != ${item.onHand}`);
    }
  }

  private async emitStockChanged(sku: SKU, warehouseId: WarehouseId, delta: number, reason: string, at: number) {
    await this.events.publish({ type: "StockChanged", sku, warehouseId, delta, reason, at });
  }

  private async maybeEmitLowStock(item: InventoryItem, at: number): Promise<void> {
    if (this.reorderPolicy.shouldReorder(item)) {
      await this.events.publish({
        type: "LowStock",
        sku: item.sku,
        warehouseId: item.warehouseId,
        available: item.available,
        reorderPoint: item.reorderPoint,
        at,
      });
    }
  }
}

class InsufficientStockError extends Error {
  constructor(public sku: SKU, public warehouseId: WarehouseId, public requested: number, public available: number) {
    super(`insufficient stock: ${sku}@${warehouseId} wanted ${requested}, had ${available}`);
  }
}

class ConcurrencyExhaustedError extends Error {
  constructor(public sku: SKU, public warehouseId: WarehouseId) {
    super(`concurrency retries exhausted on ${sku}@${warehouseId}`);
  }
}

class ReservationExpiredError extends Error {
  constructor(public reservationId: ReservationId) {
    super(`reservation ${reservationId} has expired — stock already released`);
  }
}

class ReservationTerminalError extends Error {
  constructor(public reservationId: ReservationId, public state: ReservationState) {
    super(`reservation ${reservationId} is already in terminal state ${state}`);
  }
}

Why retry on CAS failure inside the method rather than asking the caller to retry? Version conflicts are a storage implementation detail. Leaking them to the caller means every client implements the same retry loop — and some will do it wrong (infinite loop, no backoff, no jitter). Keep retries here with a bounded attempt count, and surface exhaustion as a distinct error.

Why is maybeEmitLowStock fire-and-forget from the caller's perspective? A failure to emit LowStockEvent must not undo a successful reservation. In production, the event goes through a transactional outbox: write to the same DB as updateItem, then a relay publishes to Kafka. That gives at-least-once delivery without coupling reservation latency to Kafka availability.


Design Decisions & Tradeoffs

Stock model: single counter vs split buckets

A single stock counter with "pending orders" tracked client-side is seductive — one integer, one write per operation. It fails the first time two checkouts race on the last unit: both read stock=1, both think they're fine, both commit, and you've oversold. You can fix this with a DB row lock on every read, but now every read blocks every write, and your throughput collapses.

The split model (available / reserved / damaged / inspection) moves state transitions inside a single row update. Reserve reads available, checks capacity, CAS-writes the new tuple. If another reserve beat us, the version has changed, our CAS fails, we retry with the new state. Correctness comes from the atomicity of the row update, not from a lock held across round trips.

Reservation TTL: how long before auto-release

Too short and real buyers lose their cart mid-checkout; too long and scalpers hold inventory indefinitely. Real-world defaults:

ContextTypical TTL
Grocery checkout5–10 minutes
Retail apparel15 minutes
Event ticketing8–10 minutes (aggressive — inventory is existentially scarce)
B2B bulk order24 hours (payment flows are slow)

The value is per-SKU, per-customer-tier configurable. A hot flash-sale SKU can run with a 3-minute TTL; a niche SKU where demand is sparse can run with an hour. The sweeper runs independently of TTL — typically every 30 seconds — so the worst-case actual hold is ttl + sweepInterval.

Allocation strategy: single-warehouse preference vs split shipment

Splitting an order across warehouses lowers stockout risk (you can fulfill orders that no single warehouse can), but raises shipping cost and customer friction (multiple packages, multiple tracking numbers). Rule of thumb:

  • Prefer single-warehouse by default: better unit economics, simpler packaging, one delivery event for the customer.
  • Split when the single-warehouse strategy reports partial, and the product category tolerates it (groceries: no; electronics: often yes), and the customer tier permits (Prime: yes with no extra charge; free tier: ask).

Either way, the allocator returns a list of (warehouseId, qty) proposals, and each one becomes a reservation. If any one fails, we release the others — a saga, not a distributed transaction.

Consistency model: strong per-warehouse, eventual across warehouses

We anchor strong consistency at the (sku, warehouseId) row. A reserve against warehouse W for SKU S is serially consistent with all other reserves against S@W. Cross-warehouse operations — "how much S do we have globally?" — are aggregate reads from a read-replica or a materialized view, with seconds of staleness acceptable.

Why not globally strong? Because the alternative — a distributed transaction across warehouses — would require 2PC or Spanner-class infrastructure, and the consistency benefit is negligible: no customer order is against "global stock," it's against a specific warehouse's stock after the allocator chose.

The only real danger of eventual cross-warehouse consistency is the allocator: it reads slightly stale counts when picking warehouses. Mitigation: the allocator's picks are proposals, not commitments; each picked warehouse must still pass its own strong-consistent reserve. If the allocator proposed W1 (seeing 10 units stale) but W1 now has 0, the reserve fails, and we fall back to W2.

Over-sell policy: hard vs soft

Hard (our default): reserve refuses when available < qty. Customer sees "out of stock" at checkout. Safest for the business but worst for conversion in flash sales.

Soft (amazon/scalped luxury): allow reservations to exceed current available by a configurable overdraft. Ship whichever orders restock/adjacent-warehouses can cover; proactively cancel the rest with a refund and apology credit. Higher conversion, higher cost of unmet promises.

Soft is never the default for physical goods. It's occasionally used for pre-orders, waitlists, and known-fast-restock SKUs. If asked, design soft as a policy flag on the SKU: overdraftPolicy: { maxOverdraft: number; cancellationSla: Duration }.


Patterns Used

PatternWhereWhy
StrategyIReorderPolicy, IAllocationStrategyPolicies change per SKU, per region, per season. Inject instead of switch.
Observer / Event busIEventPublisherDecouples inventory from reorder, analytics, UI. Inventory never imports "the thing that cares."
StateReservation lifecycle (ACTIVE → COMMITTED/RELEASED/EXPIRED)Makes valid transitions explicit. Bad transitions become impossible-to-compile (if modeled with discriminated unions in TS) or trivially caught.
RepositoryIInventoryRepositoryHides Postgres/Dynamo/Redis choice from the service. The CAS semantics are the contract; the storage is the implementation.
SagaMulti-warehouse allocationEach warehouse reservation is a local transaction. If any fails, compensate the others (release). No distributed 2PC.
OutboxEvent emissionEvent write and row update share a DB transaction; a relay drains the outbox to Kafka. Solves "event emitted but DB rollback" and "DB committed but event lost."
Command (implicit)reserve / commit / release are command methods with explicit request models (not shown inline for brevity)Enables queueing, retries, audit logging of intent.
FacadeInventoryServiceOne surface for callers; internally fans out to repo, events, policies.

Concurrency Considerations

This is the section interviewers lean on. Two orders hit "reserve 1 of SKU X" at warehouse W, where W has 1 unit left. One must succeed; the other must fail. The design must not rely on "just be careful."

Three strategies, compared

(a) Pessimistic: DB row lock (SELECT ... FOR UPDATE)

BEGIN;
SELECT * FROM inventory WHERE sku=? AND warehouseId=? FOR UPDATE;
-- check available
UPDATE inventory SET available = available - ?, reserved = reserved + ? WHERE ...;
INSERT INTO reservations ...;
COMMIT;

Simple. Correct. Low developer-confusion risk.

Cost: the lock is held for the duration of the transaction (including the reservation insert and any network round trip the service layer adds). Every other reserve against the same row waits. On a hot SKU with 500 concurrent reserve attempts, they serialize behind one another — your throughput on that SKU is 1 / tx_duration, typically 100–500/sec. Fine for 99% of SKUs; a bottleneck for flash-sale hotspots.

Also: lock timeouts, deadlocks (if a cross-warehouse saga ever takes locks in different orders), and the fact that long transactions hurt replication lag.

(b) Optimistic: version column + retry (our primary choice)

version = current.version
proposed = { ...current, available -= qty, reserved += qty, version: version + 1 }
UPDATE inventory SET ... WHERE sku=? AND warehouseId=? AND version = ?  -- version matters
if rowcount == 0: retry

No locks held across service logic. The DB's row-level write atomicity is our only synchronization primitive. On contention, the loser retries with fresh state — usually the retry sees lower available, sometimes the retry succeeds, sometimes it fails with InsufficientStockError.

Cost: retry amplification under high contention. In the pathological flash-sale case (500 concurrent attempts on 10 units), most will retry, retry, and eventually fail. Total DB work is O(n^2) for n contenders in the worst case. Mitigations: bounded retry count (our MAX_RESERVE_RETRIES = 5), exponential backoff with jitter between retries, and — for known hotspots — a queue in front of the SKU that serializes at the application layer before touching the DB.

(c) Append-only event log (CRDT-ish)

Model inventory as a log: +10 restock, -1 reserve, -1 reserve, +1 release, -1 commit... Current state is the fold. Append is lock-free (single-writer per SKU partition, or append-only CRDT if multi-writer).

Cost: derived state has to be materialized somewhere for the hot path (you can't fold 10M events to answer "is 1 unit available"). Snapshots, caching, and event-sourcing operational overhead. And "is 1 unit available" at partition boundaries is eventually consistent — two reserves against the last unit can both append, and you resolve by ordering and compensating the second.

Great for audit and for workloads that are write-heavy with flexible consistency; overkill for the default e-commerce case.

Comparison table

StrategyCorrectnessThroughput on hot SKUOperational complexityWhen to pick
Pessimistic row lockStrong, triviallyLow (serialized)LowLow-contention SKUs; B2B flows; simple systems
Optimistic CAS + retryStrong (if retries exhausted = failure, not oversell)High for low/med contention, degrades past ~100 concurrentMedium (retry tuning, backoff)Default choice for e-commerce
Append-only logEventual (needs reconciliation for concurrent writers)Very high (lock-free)High (snapshots, folds, reconciliation)Write-dominant systems (IoT, telemetry), audit-first

Senior take: start with optimistic CAS per warehouse row. For known hotspots (flash-sale SKUs), add an application-level queue in front: all reserve requests for SKU X go through a single-threaded actor that serializes at the app tier and issues one DB write at a time. Queue depth is observable; back-pressure is observable; DB contention disappears. This is the "90% optimistic, 10% queued" hybrid that real systems end up with.

Multi-warehouse: saga over 2PC

A single order against two warehouses is two independent transactions. Options:

  • 2PC across both warehouse DBs. Strict correctness, but requires XA / Spanner-class infra. Every participating DB pays coordinator coordination overhead. Not realistic for most stacks.
  • Saga with compensations. Reserve on W1 (local tx). Reserve on W2 (local tx). If W2 fails, compensate by releasing on W1. No global lock, no coordinator — just two local transactions and an application-level compensation rule.

The saga's weakness: if W1's reserve succeeds, W2's reserve fails, and then the compensating release on W1 also fails (network partition), we're stuck with a ghost reservation on W1. The sweeper catches it eventually (TTL expires, sweeper releases), which is why the TTL + sweeper is not optional — it's the ultimate backstop for every stuck-reservation failure mode.

Oversell risk by consistency model

Called out explicitly because interviewers grill on this:

  • Per-warehouse strong (our default): no oversell at a warehouse. Can oversell at the aggregate view if the allocator reads stale cross-warehouse totals, but only in the sense of "we thought we had more globally than we did"; each reserve still passes its local check. The user-visible outcome is at most "allocator proposed W1, W1 rejected, allocator tries W2." No customer gets a unit that doesn't exist.
  • Eventually consistent per warehouse (what you'd get with async replication and no CAS): two reserves can both succeed against stale local state and both think they held the last unit. Real oversell. Only acceptable if paired with a downstream reconciliation step that cancels the later reservation.
  • Append-only log with eventual consistency: same risk as above. Must add reconciliation. Write throughput is high, but every write needs a reconciliation plan for conflicts.

The phrase "no oversell" means something different in each model. Be precise in the interview.


Scale & Extensibility

Sharding

Shard by hash(sku) % N at the storage tier. All warehouse rows for a SKU land on the same shard, which matters because:

  • Cross-warehouse reads for the same SKU stay on one shard (good for the allocator).
  • Hot SKUs produce a hot shard. Mitigation: hash by (sku, warehouseId) if cross-warehouse co-location is less important than hot-shard avoidance. Choice is workload-dependent — measure.

Per-warehouse local cache

Each warehouse's inventory service reads its own rows from a warehouse-local replica with write-through to the authoritative primary. This protects reserve latency from inter-region network blips. The authoritative write still happens at the primary; the cache invalidation is piggy-backed on the event stream (every StockChanged is a cache-busting signal).

Event-sourced inventory

Every mutation is an InventoryEvent. Current state is a fold; snapshots every N events let the hot path stay O(1). Benefits:

  • Audit: "show me every change to SKU X at W on 2026-03-15" is a filter on the event log.
  • Replay: reconstruct historical state for reporting.
  • Corrections: a bad adjustment can be undone by appending a compensating event, preserving the audit trail.

Cost: non-trivial implementation. Start with a simpler model (current-state row + StockChangedEvent into Kafka) and evolve.

Predictive reorder (ML hook)

IReorderPolicy is already a seam. A PredictiveReorderPolicy implementation calls an ML service that forecasts demand from sales velocity, seasonality, and promotions, and emits LowStockEvent earlier than a fixed threshold would. The inventory service doesn't know or care — the policy is just a function.

Multi-tenancy (per-seller inventory)

Marketplace use case: Seller A and Seller B both stock SKU X at warehouse W, but their stocks are separate accounting units. Model: add sellerId to the InventoryItem primary key. All our logic scales cleanly — it's just an extra dimension on the key.

Kitting / bundling

A "kit SKU" is one that, when reserved, actually reserves N component SKUs. Implementation: a KittingPolicy that, on reserve of a kit SKU, expands to N reserves (as a saga). Commit on the kit expands to N commits. Release expands to N releases. The component SKUs remain normal inventory items.

SKU variants

Handled by making each variant its own SKU. No extra logic required. If variant aggregation matters (e.g., "how much t-shirt do we have across sizes?"), add a productId on the SKU and aggregate at the query layer.


Edge Cases

  1. Concurrent reserve for last unit. Two reserves race on available=1. The CAS path: both read version=v, both propose available=0 version=v+1, exactly one's updateItem returns non-null. The loser retries, reads version=v+1 with available=0, raises InsufficientStockError. Correct by construction.

  2. Reservation expires while payment is mid-flight. Customer clicked "pay" at t=14:59, reservation was set to expire at t=15:00, payment processor takes 3 seconds, our commit call arrives at t=15:02 — sweeper already ran. We throw ReservationExpiredError. The caller (order service) handles it: refund, apologize, ask the customer to retry. Mitigation at design time: extend the TTL on "payment initiated" (a extendReservation method) or set generous initial TTL for checkout flows.

  3. Partial warehouse network outage. Allocator proposes W1+W2, W1 succeeds, W2 request times out. Compensation (release on W1) also fails — partition is network-wide. What saves us: W1's reservation has a TTL. Even if we crash entirely, the sweeper on W1 will expire it. Customer sees "payment failed" at the order-service layer and retries.

  4. Damaged-goods increment without a sale. Warehouse picker drops a box. We call adjust with delta.available=-5, delta.damaged=+5, delta.onHandDelta=0. available drops; onHand unchanged (the goods are still physically in the warehouse, just unsellable). Emits StockChanged with reason="damage" for audit.

  5. Product recall. Legal says "stop selling SKU X everywhere, immediately." We call adjust with delta.available = -item.available (zero it), delta.onHandDelta = -item.available at each warehouse. Optionally a separate recall endpoint that fans out across all warehouses and also sets a "recalled" flag that fails future reserve calls with a distinct error.

  6. Oversell from eventual-consistency lag (cross-warehouse allocation). Allocator reads stale W1 count (thinks 5 available, actually 0). Proposes W1. The local reserve against W1 fails with InsufficientStockError. Allocator falls back to W2. No oversell; the local strong consistency at W1 caught it. The worst case is an extra round trip, not a wrong promise.

  7. Cancellation after commit. Customer cancels after we already decremented onHand. Handled by an explicit cancel(orderId) flow that calls restock with reason="cancellation". Distinct from release, because commit already moved stock out of reserved; we're putting it back from scratch. Produces a compensating StockChanged event; analytics differentiates cancellations from restocks via the reason tag.

  8. Returns flow. Customer returns item. Warehouse receives it into inspection (via adjust with delta.inspection=+1, delta.onHandDelta=+1). QC passes: adjust with delta.inspection=-1, delta.available=+1, delta.onHandDelta=0. QC fails: delta.inspection=-1, delta.damaged=+1, delta.onHandDelta=0.

  9. Cycle-count reconciliation (physical audit vs system count). Monthly, a human counts shelves. System says 100 onHand, human counts 97. Three units walked away (shrinkage). We call adjust with delta.available=-3, delta.onHandDelta=-3 and reason="cycle-count". The event log records it; finance amortizes the loss. Never silently reconcile — always emit events.

  10. Reserving across expired-but-not-yet-swept reservations. Sweeper runs every 30s. At second 28, an ACTIVE reservation technically expired at second 15 (TTL=15s). Incoming reserve sees low available because the expired reservation is still counted in reserved. Customer sees "out of stock" that's actually stock-available-in-13-seconds. Mitigation: run sweep more aggressively for hot SKUs, or let reserve opportunistically release expired reservations it encounters (sweeper-on-read pattern).

  11. Reservation ID collision. UUIDs collide at rates that don't matter in practice; still, the saveReservation repository call should use INSERT ... IF NOT EXISTS and fail loudly on collision rather than silently overwriting.

  12. Clock skew between sweeper and service hosts. Sweeper says "expired at now=1700000000", reservation says expiresAt=1700000005 due to clock skew. Sweeper misses it this round, catches it next. Harmless. Never rely on wall-clock synchronization for correctness; rely on the reservation state machine.


Follow-up Questions

  1. How would you prevent oversell in an eventually-consistent multi-region deployment? — Anchor strong consistency at the warehouse-row level in the warehouse's home region. Global reads are eventual. No cross-region reserve can succeed without a local strong-consistent CAS in the target warehouse's region.

  2. How would you evolve single-warehouse → multi-warehouse? — The InventoryItem key is already (sku, warehouseId) in v1 even with one warehouse; adding warehouses is additive. The allocator becomes non-trivial (it was a no-op before). Downstream consumers of StockChangedEvent get a warehouseId field they didn't use before.

  3. How would you add kitting / bundling? — A KittingPolicy consulted at reserve time expands a kit-SKU reserve into N component-SKU reserves as a saga. Commit and release fan out the same way. Needs a new kits table mapping kit SKU → (component SKU, qty)[].

  4. How would you reconcile with a physical audit? — Monthly cycle count produces a PhysicalCount record per (sku, warehouseId). A reconciliation job emits adjust events to bring the system count in line. Always with an audit trail; never silently.

  5. How would you trigger reorder without false positives during a flash sale? — A flash sale consumes stock quickly; fixed-threshold reorder would fire aggressively and flood procurement with POs. Use a time-decayed policy: require available < threshold and `velocity is not a spike* (velocity smoothed over a longer window than the flash duration). Or, simpler: per-SKU "promotional mode" flag that suspends reorder during the sale.

  6. How would you roll out a new reservation TTL without breaking in-flight reservations? — TTL is stored per-reservation at creation time, not read globally. Existing reservations keep their TTL; new ones pick up the new value. No migration needed.

  7. How would you detect a stuck reservation cluster (sweeper failing)? — Metric: "count of reservations with expiresAt < now - 60s AND state = ACTIVE" — alert if > threshold. Tells you the sweeper is down or backed up.

  8. How would you prevent a single malicious bot from reserving all stock and holding it for TTL minutes? — Rate-limit reserves per (customerId, sku) at the API gateway. Optionally require a small auth-bound deposit for reservations on high-demand SKUs.

  9. How would you support pre-orders (selling items before they exist)? — A PreorderInventoryItem variant with a soft-overdraft policy. Reservations against it succeed up to a pre-configured cap; commits block until a StockArrived event increments the real onHand.

  10. How would you expose inventory to a search/catalog service that wants to show "in stock / out of stock"? — Do not expose counters; that's an anti-pattern (competitors scrape it, and it changes every millisecond). Publish a boolean InStockStatusChanged(sku, inStock) event debounced — the catalog cares about the boolean only.


SDE2 vs SDE3 — How the Bar Rises

DimensionSDE2SDE3
Reservation lifecycleModels ACTIVE/COMMITTED/RELEASED, knows a TTL exists.Articulates the full state machine and its terminal-state idempotency semantics; explains why EXPIRED is distinct from RELEASED; knows the "race against the sweeper" failure mode and designs for it.
ConcurrencyProposes a DB row lock and leaves it there.Names three strategies (pessimistic, optimistic CAS, append-only log) with throughput and operational tradeoffs; defaults to optimistic with retry; knows when to add an app-tier serializer for hot SKUs.
Event-driven designMight add events as an afterthought (analytics dashboard).Events are the integration contract from day one — reorder, cache invalidation, cross-service choreography all subscribe to the same stream. Brings up the transactional outbox.
Saga awarenessTreats multi-warehouse as "just more rows."Recognizes multi-warehouse reserve is a saga; articulates compensation; knows TTL + sweeper is the ultimate backstop against compensation failure.
Reconciliation thinkingStops at "system count is correct."Knows the system count drifts from physical reality (shrinkage, miscount, damage) and designs adjust + cycle-count reconciliation as first-class flows, with events for audit.
Multi-warehouse extensibilitySingle warehouse, hard-codes warehouseId.Key is (sku, warehouseId) from v1 even with one warehouse; the allocator is a seam; cross-warehouse consistency is intentional (strong per row, eventual aggregate), not accidental.
Oversell vocabulary"We won't oversell because we lock the row.""We won't oversell at the warehouse level under strong per-row consistency; the allocator can read stale aggregate counts, but that produces at most an extra round-trip, not a wrong promise."
Backstop designAssumes the happy path.Every failure mode (compensation fails, sweeper lags, network partitions, clock skew) has an explicit recovery. The TTL is not just a UX feature — it's the last line of defense.

The senior answer is quieter about the data structure and louder about the lifecycle, the failure modes, and the blast radius. Anyone can design a counter. The bar is designing the thing that survives contact with production traffic, partial outages, and people who click "pay" exactly as the sweeper runs.

Frontend interview preparation reference.