5.6 KiB
Architect — F-016 Inventory module
Feature
F-016 adds an isolated inventory module that owns stock truth, exposes availability through a public service interface, and performs atomic reserve/release/confirm operations without catalog table coupling.
Design
Module boundaries
Create project/src/modules/inventory/ with the same screaming architecture used by existing modules:
domain/stock.tsfor stock states, stock item shape, movement/reservation concepts and command inputs.domain/ports.tsforInventoryRepositoryandInventoryServicecontracts.domain/errors.tsfor unavailable stock and illegal stock operation errors.application/inventory-service.tsimplementingInventoryServiceuse cases.infrastructure/pg-inventory-repository.tsfor PostgreSQL persistence and transactional atomic operations.api/inventory.routes.tsfor minimal admin/testable HTTP endpoints.index.tsexports only the module public API.
Catalog must not import inventory internals. Variant/product ids are stored as opaque UUID references; inventory does not join catalog tables. The composition root is the only place that wires the module.
Data model
Add migration 011_inventory.js:
inventory_stockid uuid primary key default gen_random_uuid()variant_id uuid not null uniqueavailable integer not null default 0reserved integer not null default 0sold integer not null default 0incoming integer not null default 0- timestamps
- CHECK constraints: every quantity
>= 0
- Optional append-only audit table
inventory_movementsid,variant_id,operation,quantity,created_at- operation in
reserve,release,confirm,adjust_available,adjust_incoming
This maps the requested stock states to explicit counters. Negative stock is blocked twice: domain validation rejects invalid commands and PostgreSQL CHECK constraints prevent persistence corruption.
Atomic stock operations
Implement repository operations as single SQL statements guarded by predicates:
- Reserve:
UPDATE inventory_stock SET available = available - $qty, reserved = reserved + $qty WHERE variant_id = $variantId AND available >= $qty RETURNING *. - Release:
UPDATE inventory_stock SET reserved = reserved - $qty, available = available + $qty WHERE variant_id = $variantId AND reserved >= $qty RETURNING *. - Confirm:
UPDATE inventory_stock SET reserved = reserved - $qty, sold = sold + $qty WHERE variant_id = $variantId AND reserved >= $qty RETURNING *.
The predicate is the concurrency control. Do not read-then-write in application code; that creates a race. For multi-step operations with movement logging, use one DB transaction in the repository.
Public service interface
Expose InventoryService from the module index:
checkAvailability(variantId, quantity): Promise<{ available: boolean; availableQuantity: number }>reserve(input): Promise<StockItem>release(input): Promise<StockItem>confirm(input): Promise<StockItem>setAvailable(input): Promise<StockItem>for admin seeding/adjustment only
Checkout later must receive this interface by dependency injection. For this feature, no checkout module exists yet, so acceptance is satisfied by exporting the interface and documenting that consumers must depend only on InventoryService.
API slice
Add inventory routes behind admin authentication where mutation is business-sensitive:
GET /inventory/:variantId/availability?quantity=1public or authenticated-safe read.PUT /inventory/:variantId/stockadmin-only stock adjustment for setup.POST /inventory/:variantId/reservationsadmin/testable reserve endpoint.POST /inventory/:variantId/reservations/releaseadmin/testable release endpoint.POST /inventory/:variantId/reservations/confirmadmin/testable confirm endpoint.
Use zod + parseJson for params/query/body, and map unavailable/invalid operations to stable HTTP errors (409 for unavailable stock, 422 for invalid stock operation when relevant).
Tests
Add focused tests before/with implementation:
- Unit/application tests for rejecting non-positive quantities and mapping unavailable stock.
- Integration test with PostgreSQL for 10 concurrent reservations against one available unit: exactly one succeeds, nine fail with unavailable stock, final stock is
available=0,reserved=1. - Integration test for zero stock reservation: rejected and counters remain non-negative.
- Boundary test that scans catalog source and fails if it references
inventory_tables or imports frommodules/inventoryinternals. - Migration test remains covered by the existing migration suite.
Acceptance trace
- Concurrent last-unit reservation: predicate update plus integration test proves one success and nine conflicts.
- Zero stock: domain validation + SQL predicate + DB CHECK constraints keep stock non-negative.
- Catalog isolation: inventory owns
inventory_*tables; catalog has no inventory imports or table references; tested by source scan. - Checkout dependency: public
InventoryServiceinterface is exported for future checkout injection; no checkout implementation is in scope yet. verify.sh: must pass after gates.
Risks / constraints
- Do not add new runtime dependencies.
- Do not model stock as one row per unit; counters with atomic predicate updates are simpler and sufficient for this slice.
- Do not use application-level locks or read-before-write availability checks for reservation correctness.
- Product/variant existence validation is intentionally outside inventory; variant ids are opaque references to preserve module isolation.