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.
Overview
The challenge has three progressive levels, and this design answers all three:
- Level 1 — no overselling: a reserve request that exceeds available stock must fail.
- Level 2 — reservation lifecycle: a hold lasts 2 minutes, then the stock is released automatically.
- 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.
Mutex—runExclusive(fn)queues async functions and runs them one at a time (FIFO).KeyedMutex— lazily mapsproductId → Mutex. Product A's requests never block product B's.- All service operations (
reserve,confirm,cancel,getAvailableStock) run inside the product's lock. Reads take the lock too, for a consistent snapshot. try/finallyguarantees the lock is released even when the operation throws.- Lock routing for confirm/cancel: these take a
reservationId, so the service first reads the reservation (outside any lock) only to learn itsproductId, then takes that product's lock and re-reads + re-validates inside the lock. All decisions happen inside the lock. KeyedMutexentries are never removed (one mutex per product, unbounded). Acceptable because products cannot be deleted.
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).
SystemClockin production;FakeClockin tests — tests advance time instantly instead of waiting 2 real minutes.- A reservation is expired when
now >= expiresAt.advance(120_000)on FakeClock lands exactly on expiry. - Reads never mutate.
getAvailableStock(and the check insidereserve) treats ACTIVE records past expiry as released for the calculation only — no write-back, no sweeping. - Write-back is targeted. Only an operation aimed at a specific reservation — confirm, cancel, or an idempotent replay — flips an ACTIVE-but-expired record to EXPIRED.
- Hold time (2 minutes) is a service constructor parameter, default 120 000 ms.
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.
- First request with a key creates the reservation and stores the key on it.
- A repeat with the same key + same quantity returns the original reservation (Stripe-style replay) instead of creating a new one.
- Replay is status-independent: the original is returned whether it is ACTIVE, CONFIRMED, CANCELLED, or EXPIRED. A replay means "show me the result of my original request", never a new attempt. A client that wants a fresh hold after expiry must send a new key.
- Same key + different quantity →
IdempotencyKeyConflictError. - Key lookup runs inside the product lock, so two concurrent requests with the same key still produce exactly one reservation.
- The same key on a different product is an independent request (documented simplification).
- Requests without a key always create a new reservation.
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
- Product —
{ productId, totalStock }. Total stock is fixed at creation; restocking is out of scope. - Reservation —
{ id, productId, quantity, status, createdAt, expiresAt, idempotencyKey? }.quantityis a positive integer, default 1. - ReservationStatus —
ACTIVE | CONFIRMED | CANCELLED | EXPIRED.
State transitions
ACTIVE ──confirm──▶ CONFIRMED (final; confirmed purchases cannot be reversed)
ACTIVE ──cancel───▶ CANCELLED (final)
ACTIVE ──expiresAt passes──▶ EXPIRED (final; stock released)
- Confirm/cancel on a non-ACTIVE reservation →
InvalidStateTransition. - Expiry boundary: expired when
now >= expiresAt. - Confirm on an ACTIVE-but-expired reservation → lazily marked EXPIRED, confirm rejected.
- Cancel on an ACTIVE-but-expired reservation → lazily marked EXPIRED, cancel rejected (no longer cancellable).
- Replay of an ACTIVE-but-expired original → lazily marked EXPIRED, returned with status EXPIRED.
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.
| Endpoint | Behavior |
|---|---|
POST /products | Create 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.
- Level 1: reserve succeeds when stock is available; fails when exhausted; stock=1 → user A succeeds, user B fails; invalid quantity rejected; unknown product rejected; same idempotency key replays the original without double-holding; same key with different quantity rejected.
- Level 2: confirm/cancel transitions; double confirm/cancel rejected; confirm after expiry rejected; expiry releases stock (via FakeClock); cancelled/expired stock is reservable again; confirmed stock is not.
- Level 3: 500 simultaneous
reserve()on stock=1 → exactly 1 success, 499InsufficientStock; 500 simultaneous requests with the same idempotency key → exactly 1 reservation created; stock=N with mixed quantities → no oversell; per-product independence (a lock on A doesn't serialize B). - API: happy path + error-status mapping via Hono's
app.request().
Workflow
git initininventory-reservation-system/; small commits per level with clear messages.- README covers: how to run/test, approach summary, locking strategy explanation, tradeoffs & assumptions, AI-usage disclosure.
- This design doc is committed as part of workflow disclosure.
Out of Scope (MVP)
- Persistence — single process, in-memory storage, per the challenge.
- Restocking — total stock is fixed at product creation; no restock endpoint.
- User identity/auth — reservations are anonymous; "one user reserves the last item" means "one request wins the last unit".
- Configurable hold time via API — fixed at 2 minutes, configurable only in code.
- Idempotency key expiry — keys live in memory indefinitely; a real system would expire them.
- Distributed locking — the per-product mutex is single-process; a real deployment would use a DB row lock or distributed lock (documented, not built).
- Eager cleanup of expired records — they stay in storage as
ACTIVEuntil confirm/cancel/replay touches them; availability math is unaffected.