Cart Backend Documentation
Backend architecture, data model, services, cache, and operational behavior for the Cart module.
Cart - Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/cart/ module files | Leaf composition, mobile registration, IdempotencyModule |
| Controllers | customer/cart-customer.controller.ts, admin/cart-admin.controller.ts | Routes, guards, status codes, interceptor wiring |
| Services | cart-write.service.ts, cart-bulk.service.ts, cart-query.service.ts, cart-response.builder.ts | One-statement UPDATE, line validation, pricing |
| Schema | packages/db/src/schema/cart/{cart,cart-item,enums}.ts | Partial unique, caps, status enum |
| Inventory seam | InventoryAvailabilityService.checkQuantities | The stock question's single home |
| Error registry | apps/api/src/common/types/error-codes.ts (// CART) | CART_* codes |
2. Backend Scope and Boundaries
Owns
- The
cartandcart_itemtables and the ten routes. - The one-statement mutation pattern (lock + status guard + version + bump).
- Line validation with per-line reasons and the checkout-validation re-read pass.
Does Not Own
- Stock logic. Cart builds none of its own. It consumes
InventoryAvailabilityService.checkQuantities— a method added to the existing seam, making cart its third consumer after the product assembler and wishlist. - Inventory reservations, stock deduction, price snapshots for charging, shipping, promotions. It stores intent; every figure it reports is derived from live rows in tables other modules own, read at request time.
- The
checkout_locked→converted/activereturn edges — those belong to checkout.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Intent | cart + cart_item rows | |
| Product facts | Live product rows | Never snapshotted |
| Availability | checkQuantities on the inventory seam | available is not always a limit |
| Price signal | last_known_unit_price (change signal only) | Never used for totals |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
CartCustomerModule | Leaf | customer/ | CartCustomerController | Write/Bulk/Query services | — | The eight customer routes |
CartAdminModule | Leaf | admin/ | CartAdminController | Admin service | — | The two read-only routes |
| Mobile composition | — | mobile.module.ts | — | — | — | Customer cart under /api/mobile/cart |
CartCustomerModule imports IdempotencyModule — the interceptor is not global, so without both the module import and the @UseInterceptors line, the @IdempotentCreate decorator is inert metadata and a retried add would double the quantity. idempotency-wiring.spec.ts guards all three.
4. File and Directory Map
apps/api/src/modules/cart/
customer/
cart-customer.controller.ts
cart-write.service.ts # single-line mutations
cart-bulk.service.ts # batch mutations
cart-query.service.ts # reads + checkout validation
cart-response.builder.ts # shape + validation reasons
dto/
admin/
cart-admin.controller.ts
cart-admin.service.ts
dto/
packages/db/src/schema/cart/
cart.ts cart-item.ts enums.ts
packages/db/src/migrations/0008_cart.sqlKey files:
| File | Purpose | Key Exports | Notes |
|---|---|---|---|
customer/cart-customer.service.ts | Mutation orchestration | CartCustomerService | add / setQuantity / remove / clear |
customer/cart-write.service.ts | Low-level transaction helpers | CartWriteService | runMutation, runRemoval, upsertLine, deleteLineByVariantId, clearLines — the one-statement UPDATE lives here |
customer/cart-bulk.service.ts | Batch mutations | CartBulkService | One version bump; product resolution in one query |
customer/cart-query.service.ts | Reads + validation | CartQueryService | Strict re-read pass |
schema/cart/cart.ts | The cart table | carts | Partial unique live-cart |
5. Data Model
5.1 Schema Source
packages/db/src/schema/cart/
cart.ts cart-item.ts enums.ts5.2 Tables
cart
| Column | Type | Nullable | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|
id / public_id | serial / uuid v7 | No | PK / UNIQUE | — | |
customer_id | uuid | NOT NULL | FK CASCADE + partial unique | customers.id | Guest carts are structurally impossible — a decision, not a missing feature |
status | cart_status enum | No | — | — | active (only one the module writes), checkout_locked, converted |
version | integer | No | — | — | Optimistic concurrency; bumped by the one-statement UPDATE |
last_activity_at | timestamptz | No | index | — | Carries the abandoned-cart signal; no expired state |
created_at / updated_at | timestamptz | No | — | — |
The partial unique index on (customer_id) WHERE status <> 'converted' gives exactly one live cart per customer — a locked cart holds the slot; get-or-create cannot open a second one.
cart_item
A cart line is (cart, variant), never (cart, product) — the 256GB and the 512GB of one phone
are two independent lines, each charged from its own variant's price. variant_id is the real
key; product_id is retained beside it, denormalised, so product-scoped reads stay a single index
scan. (packages/db/src/schema/cart/cart-item.ts:44-58)
| Column | Type | Nullable | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|
id / public_id | serial / uuid v7 | No | PK / UNIQUE | — | item.id is emitted for list keying; no route accepts it |
cart_id | integer | No | FK CASCADE | cart.id | |
variant_id | integer | No | FK CASCADE + UNIQUE (cart_id, variant_id) | product_variant.id | The line's real key — one line per configuration |
product_id | integer | No | FK CASCADE | products.id | Denormalised copy of the variant's product, for product-scoped reads |
quantity | smallint | No | CHECK 1..99 | — | |
last_known_unit_price | bigint | No | — | — | Not a price snapshot — change signal only |
created_at / updated_at | timestamptz | No | — | — |
Caps: 50 distinct lines per cart (CART_ITEM_LIMIT_REACHED — two variants of one product count
as two), 99 per line (CART_QUANTITY_LIMIT_EXCEEDED — by accumulation; >99 in one request is a
400 validation error, a different shape).
5.3 Relationship Diagram
6. Services and Responsibilities
6.1 CartCustomerService (with CartWriteService)
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
add() | POST /items | cart, product, variant | cart_item (+ cart bump) | summary cache clear | CART_PRODUCT_NOT_FOUND, CART_ITEM_LIMIT_REACHED, CART_QUANTITY_LIMIT_EXCEEDED |
setQuantity() | PUT /items/:variantPublicId | cart, variant | upsert cart_item | summary cache clear | same |
remove() | DELETE /items/:variantPublicId | cart | cart_item delete (if it existed) | summary cache clear only on an actual change | — |
clear() | DELETE /items | cart | cart_item deletes | summary cache clear | — |
The four orchestrating methods live on CartCustomerService; each delegates the transaction to
CartWriteService (runMutation, upsertLine, runRemoval, clearLines) — the low-level
helpers that must never be called without the guard below.
A removal that removes nothing must not bump the version. runRemoval splits the cart's row
lock and version-assert (lockWithoutBump) from the bump itself, and calls bumpVersion only when
the delete actually changed a row. This closed a gap the variant rename opened: DELETE /cart/items/:variantPublicId used to take a PRODUCT public id, and both id kinds are uuid7, so
@IsUUID("7") accepts either — a client still sending the old id resolved to no variant, removed
nothing, and used to still invalidate every other device's optimistic token for it.
(apps/api/src/modules/cart/customer/cart-write.service.ts:86-118)
The one statement does four jobs. Every mutation opens with:
UPDATE cart
SET version = version + 1, updated_at = now(), last_activity_at = now()
WHERE id = $1
AND status = 'active'
AND ($2 IS NULL OR version = $2)It takes the row lock, enforces the status guard, checks the optional client-supplied version and bumps it — so no new endpoint can forget any of them. Because the line-count check runs inside that lock, the 50-product cap is exact: twelve concurrent adds to a 49-line cart admit exactly one. The wishlist cap is a check-then-insert with no lock and can overshoot — the two modules look alike and the reasoning differs.
version is optional on every single-line mutation (omitted → last-write-wins; the product-card add has no loaded cart) and required on bulk (the only client that sends a batch has loaded the cart, and bulk has no idempotency path).
6.2 CartBulkService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
apply() | POST /items/bulk | cart, all referenced products in one query | many cart_item rows | summary cache clear | CART_BULK_DUPLICATE_PRODUCT, CART_VERSION_CONFLICT, caps |
Operations (set absolute / add delta / remove) apply in order, in one transaction, with one version bump; any failure rolls the batch back and the version does not advance. Max 50 operations; the same product twice is a 400. The 50-line cap is evaluated once, after all operations land. Products resolve in one query — fifty sequential round-trips inside a transaction would hold the cart row lock and a pool connection for the whole batch.
6.3 CartQueryService / CartResponseBuilder
Reads build the whole-cart response: summary (totalItems distinct lines, totalQuantity over every line — the badge must not drop when a product goes out of stock), pricing (subtotal valid lines only + savings + excludedItemCount), validation.blockingReasons, and per-line validation with reasons. checkout-validation re-reads everything and trusts nothing previously loaded.
CartQueryService.loadState returns { cart, items, productSnapshots }. The third field is Map<productPublicId, { name, sku }>, built from the same cart⋈product statement that produced the line prices. It is not serialized — it exists because checkout freezes name and sku onto the order line, and the product embedded in the response is the storefront card shape, which carries no sku. It is not a second query, deliberately: the checkout transaction is READ COMMITTED and locks the cart row, not the product rows, so a second statement would take a fresh snapshot — a product renamed and repriced between the two reads would produce a line naming the new product at the old price. One statement cannot disagree with itself.
Line validation reasons (top-down precedence): removed > archived > draft > out_of_stock > insufficient_stock > null. Invalid lines stay in the cart — it never silently removes anything. availableQuantity is null for untracked products, never 0; untracked and backorder products are always valid.
The oversell trap (nearly shipped): an oversell product is TRACKED, so available is 0 and not null, and it is 0 precisely because the oversell branch was entered. A naive available < quantity is 0 < 1 — always true — and would mark every oversell line invalid, blocking checkout forever on the one configuration whose purpose is selling without stock. The canReserve predicate is applied on the inventory side, where the column exists.
Why the stock question goes to inventory rather than the flag coming out: available is not always a limit, because allow_oversell and untracked products both sell past it, and the predicate that decides (canReserve) needs a column the product contract does not carry. InventoryAvailabilityService.checkQuantities is the extension; cart is the third consumer of the seam.
6.4 CartAdminService
Read-only list (always paginated — the one list endpoint in the API that refuses pagination=false, because cart grows one row per customer plus one per order and the list aggregates over the full join) and detail. Totals are computed at live prices over every line, including unbuyable ones — an operator looks at what is in the cart, not at what would be charged. Lines carry a three-field product reference and the customer a public id + name — deliberately not the storefront contract or the customer record.
7. Runtime Flows
7.1 Set quantity
7.2 Checkout validation
8. Cache
| Cache | TTL | Key | Notes |
|---|---|---|---|
Badge counters (/summary) | CACHE_TTL.VOLATILE = 30s | per-customer | The only cached read |
- The counters deliberately carry no
version— a cached version used for optimistic concurrency would conflict against the client's own writes. - The cart itself is never cached — every line resolves live product, price and stock, so a stale cart cannot be served.
- The customer's own writes clear the summary key immediately; another device can be up to 30s stale.
9. Jobs and Workers
None. The cart is synchronous — no queue, no outbox involvement.
10. Security and Authorization
- Customer routes:
JwtAuthGuard— no permission; every query scoped toreq.user.idin the service WHERE clauses. - Admin routes:
JwtAuthGuard+RoleGuard,Cart_READ— read-only by design; there is no admin mutation of a customer's cart and none is planned. - Rate limits:
CUSTOMER_READ60/min (reads),CUSTOMER_CART_MUTATION60/min account-keyed (mutations — three times the ordinary customer write limit, because a quantity stepper on several lines produces a legitimate burst, and being rate-limited out of your own cart at the moment you are trying to buy is an outage, not a rate limit); admin 30/min IP. @IdempotentCreateonPOST /itemsrequires@UseInterceptors(IdempotencyInterceptor)beside it — the interceptor is not global, so the decorator alone is inert metadata (the header would be accepted and ignored, and a retried add would double the quantity). It shipped here once;idempotency-wiring.spec.tsguards it now, and the same defect is live inaddress-customer.controller.ts.
11. The obligation checkout inherits
cart.status has three values; nothing in the cart module writes anything but active. The column exists because it is the contract checkout needs frozen. A checkout_locked cart:
- holds the customer's only live-cart slot (the partial unique index excludes only
converted); - refuses every mutation with
CART_LOCKED_FOR_CHECKOUT.
Whichever module first writes checkout_locked MUST also own the return edge: unlock on payment failure, cancellation and reservation expiry. Without it, the state is absorbing with no recovery — the customer cannot edit the cart, cannot start a new one, and the admin surface (read-only by design) cannot help. Not reachable today; reachable the moment checkout exists.