Inventory Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for the Inventory module.
Inventory - Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | inventory.module.ts, inventory-admin-aggregate.module.ts, inventory-import-export.module.ts, inventory-worker.module.ts, inventory-shared.module.ts | Composition, shared services |
| Controllers | admin/stock/*.controller.ts, admin/job/*.controller.ts | Route ownership, guards, permissions |
| Services | inventory-write.service.ts, inventory-reservation.service.ts, inventory-availability.service.ts, inventory-ledger.service.ts, inventory-projection.service.ts, inventory-job-lifecycle.service.ts | Guarded UPDATEs, lifecycle, projections |
| DTOs | dto/*.ts under each leaf | Validation |
| Schema | packages/db/src/schema/inventory/*.ts | Four tables, GENERATED columns, CHECKs |
| Jobs | packages/jobs/src/index.ts, workers/* | InventoryJob names, lease claim, scheduler |
| Cache | cache-invalidation.tags.ts | inventory domain |
| Math | apps/api/src/utils/inventory/inventory-math.util.ts | Pure arithmetic, projections |
2. Backend Scope and Boundaries
Owns
- Stock counters, reservations, the movement ledger, per-product configuration.
- Admin surface (stock, configuration, movements, bulk adjust) and import/export jobs.
- The
INVENTORYqueue (one worker, five job names) and the maintenance scheduler. product.stock_statusas a synchronous projection of the inventory row.
Does Not Own
- Pricing, promotions, orders, payments — it has no opinion on what a reservation is for.
- The product domain — it consumes
productids, never the other way. - The outbox — generic infra; all enqueues go through it.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Runtime state | PostgreSQL (inventory, inventory_reservation, inventory_movement, inventory_job) | |
| Availability | GENERATED columns — cannot be written | |
| Movement history | inventory_movement (same-tx) | Mongo is analytics only |
| Job state | inventory_job row + lease claim |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
InventoryModule | Aggregate | inventory.module.ts | None | — | Leaves | Composes admin/worker/import-export |
InventoryAdminAggregateModule | Aggregate | admin/ | None | — | Leaves | Composes stock + job leaves |
InventoryStockAdminModule | Leaf | admin/stock/ | InventoryStockAdminController, InventoryStockAdminBulkController | Services | — | Stock surface |
InventoryJobAdminModule | Leaf | admin/job/ | InventoryJobAdminController | Service | — | Jobs surface |
InventoryImportExportModule | Leaf | import-export/ | None | Services | — | Import/export orchestration |
InventoryWorkerModule | Leaf | workers/ | None | Processor/handlers | — | Queue + scheduler |
InventorySharedModule | Leaf | shared/ | None | Write/Reservation/Ledger/Availability/Projection | Services | Shared domain services |
4. File and Directory Map
apps/api/src/modules/inventory/
inventory.module.ts
admin/
stock/ inventory-stock-admin.{controller,service}.ts
inventory-stock-admin-bulk.{controller,service}.ts dto/
inventory-stock-movements-admin.service.ts # the ledger read
inventory-admin-cursor.builder.ts # keyset on variant_id
job/ inventory-job-admin.{controller,service}.ts dto/
import-export/
inventory-import-parser.ts inventory-import-row.ts
inventory-import.service.ts inventory-export.service.ts
inventory-job-lifecycle.service.ts
shared/
inventory-write.service.ts # the ONLY permitted writer
inventory-reservation.service.ts
inventory-ledger.service.ts
inventory-availability.service.ts
inventory-projection.service.ts
inventory-filter.builder.ts inventory-response.builder.ts
inventory-mutation.types.ts inventory.constants.ts
workers/
inventory-queue.processor.ts # THE one @Processor
inventory-{reservation-sweep,reconcile,projection,job}.processor.ts
inventory-maintenance.scheduler.ts
packages/db/src/schema/inventory/
inventory.ts inventory-reservation.ts inventory-movement.ts
inventory-job.ts enums.ts
apps/api/src/utils/inventory/inventory-math.util.ts # pure arithmeticKey files:
| File | Purpose | Key Exports | Notes |
|---|---|---|---|
shared/inventory-write.service.ts | The only writer of counter/config columns | InventoryWriteService | Guarded UPDATEs, variant_id ordering |
shared/inventory-variant-resolver.service.ts | The product → variant bridge | resolveStockVariant, resolveDefaultVariantId(s) | For callers that hold only a product. Returns null rather than throwing — "every product has one live default variant" is enforced by application layers, not by a constraint, so its absence is representable |
shared/inventory-reservation.service.ts | Reservation lifecycle | InventoryReservationService | Global key, guarded transitions |
workers/inventory-queue.processor.ts | The one @Processor on INVENTORY | InventoryQueueProcessor | Routes via Record<InventoryJob, handler> |
import-export/inventory-job-lifecycle.service.ts | Job row transitions | claim, completeExport, failExport | Zero-row guard |
utils/inventory/inventory-math.util.ts | Pure stock arithmetic | deriveStockStatus, canReserve, canDisableOversell | No NestJS imports |
5. Data Model
5.1 Schema Source
packages/db/src/schema/inventory/
inventory.ts inventory-reservation.ts inventory-movement.ts
inventory-job.ts enums.ts5.2 Tables
inventory
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
variant_id | integer | No | — | PK | product_variant.id | One row per variant — a second row for one variant is unrepresentable. This was product_id until migration 0021: once a product sells in several configurations, stock is a property of the configuration, and a product-keyed row cannot represent it at all |
product_id | integer | No | — | FK, index, not unique | products.id | Denormalised from product_variant.product_id and RETAINED after the PK move, because the storefront's hottest read is WHERE product_id IN (...) and joining through product_variant per card would be a wasted hop. A product-keyed query now matches N rows |
public_id | uuid | No | uuid7 | unique | — | The stock row's own identity, distinct from both ids above |
total_quantity | integer | No | 0 | CHECKs | — | Negative legal under allow_oversell (units owed) |
reserved_quantity | integer | No | 0 | CHECK >= 0 | — | |
available_quantity | integer | No | generated | — | — | GENERATED ALWAYS AS (total - reserved) — write attempt raises 428C9 |
stock_status | enum | No | generated | partial index WHERE stock_status = 'low_stock' | — | GENERATED CASE over counters |
track_inventory | boolean | No | true | — | — | Counters are authoritative unless turned off |
allow_oversell | boolean | No | false | — | — | |
low_stock_threshold | integer | No | 5 | CHECK >= 0 | — | Zero means "never warn" |
version | integer | No | 1 | CHECK >= 1 | — | Optimistic lock, configuration fields only |
Every sort index is (sort_column, variant_id), not (sort_column, product_id). The admin list is keyset-paginated, and a keyset tiebreaker must be unique; product_id stopped being unique at the PK move, and a non-unique tiebreaker makes a page silently skip or repeat rows.
CHECKs: chk_*_total_quantity_* and chk_*_reserved_le_total gated on allow_oversell OR NOT track_inventory.
Every application-side guard that mirrors those CHECKs must carry BOTH escapes. The
adjustStock WHERE clause and its util twin validateAdjustment carried only
allow_oversell, which made them stricter than the database they mirror — and the gap was
reachable, not theoretical.
createForVariant creates every row untracked, and the reserve guard short-circuits on
untracked ("tracking disabled means unlimited purchasing, stock ignored"), so an untracked row
legitimately reaches reserved > total. Adding stock to such a row was then refused as
INVENTORY_INSUFFICIENT_STOCK — an operator receiving 500 units told there was not enough
stock, to add stock — and the only delta the guard would accept was larger than the quantity
that had actually arrived. The database would have taken the write.
The tell that it was an oversight rather than a decision: the same NOT track_inventory escape
had been deliberately added to canDisableOversell and canEnableTracking, with a long comment
explaining why. It was applied to two of the three sibling predicates and missed on the third
plus its SQL twin.
inventory_reservation
reservation_key varchar globally unique, NOT partial; status enum (active/released/finalized/expired); expires_at business timestamp; quantity positive.
inventory_movement
Append-only ledger, keyed on variant_id (FK to inventory.variant_id) with product_id carried alongside so a product-level audit needs no join: kind enum (6 values incl. correction), reason, source, the signed total_delta / reserved_delta, the post-write total_after / reserved_after snapshots, note, actor_*, correlation_id, timestamps. Written in the same transaction as the counter change.
inventory_job
Mirrors catalog_job: kind (import/export), entity (inventory), status enum, counters, retained errors (cap 500), result_file_url required on completed export.
5.3 Relationship Diagram
6. Services and Responsibilities
6.1 InventoryWriteService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
createForProduct() | product create/import | — | inventory row (untracked) | — | — |
adjust() | admin adjust, import | row | counters + movement | projection (outbox), cache | INVENTORY_ADJUSTMENT_WOULD_GO_NEGATIVE, INVENTORY_INSUFFICIENT_STOCK |
updateConfiguration() | admin config | row + version | config fields (+ movement if counters change) | projection, cache | INVENTORY_VERSION_CONFLICT, INVENTORY_OVERSELL_DISABLE_BLOCKED |
bulkAdjust() | admin bulk | rows | counters + movements | projection, cache | per-item failures |
Every mutation is a guarded UPDATE, never a read-then-write — rowCount = 0 means "refused" and is the only trustworthy way to learn there is not enough stock. Multi-row operations sort by variant_id ascending (unordered writes deadlock — reproduced 40P01). The ordering key follows the primary key: ordering by product_id leaves the lock order between two variants of one product unconstrained, which is all a deadlock needs.
6.2 InventoryReservationService
Reserve (idempotent insert under the global key, TTL default 15 min), release (idempotent, twice OK), finalize (409 on second), expire (sweep claims batches). Every transition writes a movement in the same transaction.
6.3 InventoryLedgerService / InventoryAvailabilityService
Ledger: movement writes + reads with _before/_after snapshots. Availability: batched reads for product responses (one query per page).
6.4 InventoryProjectionService
Writes product.stock_status as a synchronous projection: only when tracking is on and the current value is not pre_order (a pre-order product's status is admin truth and is never overwritten). Documented reset on the true → false tracking edge. Mongo activity projection happens asynchronously via the outbox (PROJECT_ACTIVITY).
The status written is the product's AGGREGATE across its live variants, not the status of the one variant that moved. The projection re-reads every inventory row belonging to an active, non-deleted variant of the product and collapses them with aggregateAvailability, the same function and the same PRODUCT_STOCK_STATUS_PRECEDENCE the read path uses — a product is as buyable as its most buyable variant.
This is the correction to a real defect, not a description of a design choice.
The projection used to write the moved variant's own status straight onto the product. Once a product could have more than one variant that was wrong in the most visible way possible: reserving the last unit of a one-unit configuration marked the whole product out_of_stock while a sibling still had ten sellable units, and nothing re-projected until an unrelated movement happened to land on that sibling. The storefront filters on this column, so the product simply disappeared from sale.
The rule already existed — inventory-math.util.ts had written aggregateAvailability precisely because "every caller that still builds a Map keyed on product_id silently keeps whichever row the planner returned LAST". The read path used it. Only this write did not, so the denormalized column and the live-computed answer disagreed for every multi-variant product.
It survived because every fixture in inventory-write.service.spec.ts built exactly one variant per product, which makes "this variant's status == the product's status" true trivially. inventory-stock-admin.int.spec.ts states that lesson in its own header — "a single-variant fixture reproduces none of them" — and it had been applied there and not here.
The trackingTurnedOff edge now also asks the aggregate: it resets to in_stock only when no live variant tracks any more, so "tracking disabled means unlimited purchasing" is true of the product as a whole rather than of one configuration.
Soft-deleted and inactive variants are excluded, matching InventoryAvailabilityService. A retired configuration must not decide whether the product can be bought.
7. Runtime Flows
7.1 Guarded adjustment
7.2 Reservation reserve
8. Cache
| Domain | Revalidation tags | Redis patterns |
|---|---|---|
inventory | inventory:* tags + page tags | inventory:*, product:* where stock is embedded |
Every write invalidates the inventory domain after commit; product responses carrying the inventory group are refreshed the same way.
9. Jobs and Workers
| Queue | Dispatcher | Job names |
|---|---|---|
INVENTORY | inventory-queue.processor.ts (the only @Processor) | IMPORT_STOCK, EXPORT_STOCK, SWEEP_EXPIRED_RESERVATIONS, RECONCILE, PROJECT_ACTIVITY |
- Import/export: enqueued through the outbox; lease-based claim guarded on
status = 'queued'; import owns its terminal write (accumulates per-row errors); export's terminal write carriestotal_rows+result_file_urltogether;failExportnever advertises a partial file. - Sweep: every minute via
@Cron— settles expired reservations in batches (default TTL 15 min). - Reconcile: every hour — asserts
reserved = SUM(active)and the newest_aftermatches counters; repairs withcorrectionmovements, never silent rewrites; heals products with no row.
9.1 How the reconciler picks its batch, and why that is load-bearing
It reads a bounded batch per pass (INVENTORY_RECONCILE_BATCH_SIZE), ordered
last_reconciled_at ASC NULLS FIRST, variant_id. Every row it examines is stamped —
clean ones too — so a row just audited sorts last and cannot come up again until every
other row has had a turn. Never-audited rows go first, which is why the column is nullable
with no backfill.
This job is the only drift detection the module has, and its coverage was a fixed prefix of the table.
The claim was ORDER BY product_id LIMIT n with no cursor, offset or watermark, so every
hourly pass examined the same first n rows. Past n inventory rows the remainder was never
audited — not "within a day" as the constant's comment claimed, but never. product_id also
stopped being unique at migration 0021, so the prefix was not even deterministic across
passes.
Separately, the per-row loop had no error isolation. applyCorrection writes
reserved_quantity as an absolute and is deliberately unguarded, so a row whose active holds
sum past total_quantity — exactly the drift this job exists to find — makes the write violate
chk_inventory_reserved_within_total and throw. Unhandled, that failed the whole job, and
every BullMQ retry hit the same row first: nothing ordered after it was ever reconciled again.
Both are fixed. Each row now runs in its own try, matching the reservation sweeper, and the
watermark is stamped outside it so a row that threw still advances — otherwise a poisoned
row monopolises the front of the queue and reintroduces the same defect one row at a time.
9.2 A crashed import is recorded as failed, not left running
claim() flips the job row to processing before the import service runs, and everything
before the write transaction — resolving the requester, reading the file, parsing, duplicate
detection, product resolution — used to be unprotected. A throw there exited with no database
write at all and the row stuck at processing.
That state could not be retried out of: the next attempt's claim() requires status = 'queued', finds processing, and returns false — and the processor returns rather than
throwing, so BullMQ recorded a clean run and stopped. The job then sat at processing with no
errors and no finished_at, indistinguishable from one still in flight. An upload removed
between submit and pickup was enough.
run() now wraps the whole flow and records the failure before rethrowing, and the terminal
status is forced to failed rather than derived from failedRows — on a crash no row was
reached, so failedRows is 0, and deriving from it would have recorded a job that died reading
its own file as completed.
- Projection: via outbox after movements.
- Both cron jobs enqueue directly (no DB write to be atomic with) — the documented exemption.
- One worker per queue is the repo-wide rule — see BullMQ Worker Wiring.
10. Security and Authorization
- Admin:
JwtAuthGuard+RoleGuard; permissionsInventory_READ/Inventory_UPDATE;superadminbypasses. - Import submit requires
Inventory_UPDATE; exportInventory_READ; cancelInventory_UPDATE. - Rate limits:
ADMIN_READ30/min,ADMIN_WRITE10/min,ADMIN_BULK_WRITE5/min,ADMIN_ASYNC_JOB_SUBMIT10/hour. InventoryWriteServiceis the only class permitted to UPDATE any columnstock_statusis generated from.- No guest surface exists; stock reaches customers through the product response only.