July 5, 2026

Inventory Reservation System — Design

An in-memory inventory reservation system for the Everest Engineering backend challenge: prevent overselling during a flash sale, manage a reservation lifecycle with 2-minute expiry, and stay correct when 500 requests hit the same product at once. Built with Bun + TypeScript + Hono; per-product mutex for concurrency; optional idempotency keys for safe retries.

Status: Approved
#inventory #reservations #concurrency #mutex #idempotency #bun #hono #tdd
View source markdown ↗ generated by claude

Overview

The challenge has three progressive levels, and this design answers all three:

  1. Level 1 — no overselling: a reserve request that exceeds available stock must fail.
  2. Level 2 — reservation lifecycle: a hold lasts 2 minutes, then the stock is released automatically.
  3. Level 3 — concurrency: 500 simultaneous reserves against stock 1 must yield exactly 1 success and 499 failures.

The deliverable is a fully tested domain library plus a thin Hono HTTP API used only for demoing. All business rules live in the service layer, not in routes.

Key Decisions

1. Concurrency: per-product mutex (pessimistic, in-process)

The repository interface is async to model a real database. That means reserve() does read → check → write with await points in between, so two requests can both see "1 available" and both write — the classic check-then-act race.

Strategy in one line: single-process pessimistic locking, scoped per product for throughput. In a distributed system this would become a DB row lock (SELECT … FOR UPDATE), an atomic conditional update, or a distributed lock — documented as a tradeoff, not implemented.

2. Expiry: lazy evaluation + injectable clock (no timers)

No background jobs, no setTimeout. Every operation compares expiresAt against an injected Clock (now(): Date).

Selected Lazy expiry against an injected clock. Deterministic tests, no timer leaks, correctness never depends on a background job running.
Rejected Timer / background sweeper. Flaky 2-minute tests, timer leaks, and a hidden dependency: correctness would rely on the sweeper firing on time.

Tradeoff: expired records stay in memory until touched — acceptable at this scope, noted in the README.

3. Idempotent reserve (deliberate extra beyond the PDF)

reserve accepts an optional client-supplied idempotencyKey, scoped per product. It exists so double-clicks and network retries never hold stock twice.

Scope note: idempotency is a narrow-scope enhancement beyond the challenge PDF. It rides the existing per-product lock — no new dependency or mechanism. If time gets tight, it is the first thing to remove.

4. Reserve is all-or-nothing

The system never partially fulfils a request with a smaller quantity. If quantity > available, the request fails with InsufficientStock — even when some stock remains. Reservation.quantity always equals the accepted request quantity. This also keeps replay simple: same key + same quantity is the exact same command; a different quantity is a conflict.

Architecture

src/
  domain/          Product, Reservation, ReservationStatus, domain errors
  concurrency/     Mutex, KeyedMutex
  time/            Clock interface, SystemClock
  repository/      InventoryRepository interface + InMemoryInventoryRepository (async)
  service/         InventoryService — all business rules
  api/             Hono routes + error→HTTP mapping
  index.ts         composition root, starts server
tests/             mirrors src/ (FakeClock lives here)

Dependency direction: api → service → repository/domain. The service depends on the InventoryRepository and Clock interfaces, not implementations (dependency inversion).

InventoryService interface

createProduct(productId, totalStock): Promise<Product>
getAvailableStock(productId): Promise<number>
reserve(productId, quantity = 1, idempotencyKey?): Promise<Reservation>
confirm(reservationId): Promise<Reservation>
cancel(reservationId): Promise<Reservation>

createProduct rejects a duplicate productId (ProductAlreadyExistsError) and requires totalStock to be a non-negative integer (InvalidQuantityError otherwise).

Data Model

State transitions

ACTIVE ──confirm──▶ CONFIRMED   (final; confirmed purchases cannot be reversed)
ACTIVE ──cancel───▶ CANCELLED   (final)
ACTIVE ──expiresAt passes──▶ EXPIRED (final; stock released)

Availability rule (from the challenge PDF)

available = totalStock
          − sum(quantity of CONFIRMED reservations)
          − sum(quantity of ACTIVE, not-yet-expired reservations)

Domain errors

ProductNotFoundError, ProductAlreadyExistsError, InsufficientStockError, ReservationNotFoundError, InvalidStateTransitionError, InvalidQuantityError, IdempotencyKeyConflictError. The service throws them; the HTTP layer maps them to status codes.

API Design

The HTTP layer is a thin demo. Routes hold no business logic.

EndpointBehavior
POST /productsCreate product { productId, totalStock } → 201
GET /products/:id{ productId, totalStock, availableStock } → 200
POST /reservations{ productId, quantity?, idempotencyKey? } → 201; 409 on insufficient stock; same-key replay returns the original reservation with 201
POST /reservations/:id/confirm→ 200; 409 if not confirmable
POST /reservations/:id/cancel→ 200; 409 if not cancellable

Error mapping: not found → 404; insufficient stock, invalid transition, duplicate product, idempotency key conflict → 409; invalid quantity or malformed body → 400.

A replay always returns 201 with the original reservation — the client learns the outcome from the status field (which may be EXPIRED, CANCELLED, or CONFIRMED), not from the status code. There is no GET /reservations/:id; reservation state is visible only through reserve/confirm/cancel responses.

Testing Strategy

TDD with bun:test — red-green-refactor, one challenge level at a time; commit history shows test-first order.

Workflow

Out of Scope (MVP)