Happy House - Ecommerce Docs
Developer ResourcesCart

Cart Backend Documentation

Backend architecture, data model, services, cache, and operational behavior for the Cart module.

Cart - Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/cart/ module filesLeaf composition, mobile registration, IdempotencyModule
Controllerscustomer/cart-customer.controller.ts, admin/cart-admin.controller.tsRoutes, guards, status codes, interceptor wiring
Servicescart-write.service.ts, cart-bulk.service.ts, cart-query.service.ts, cart-response.builder.tsOne-statement UPDATE, line validation, pricing
Schemapackages/db/src/schema/cart/{cart,cart-item,enums}.tsPartial unique, caps, status enum
Inventory seamInventoryAvailabilityService.checkQuantitiesThe stock question's single home
Error registryapps/api/src/common/types/error-codes.ts (// CART)CART_* codes

2. Backend Scope and Boundaries

Owns

  • The cart and cart_item tables 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_lockedconverted / active return edges — those belong to checkout.

Source of Truth

ConcernSource of TruthNotes
Intentcart + cart_item rows
Product factsLive product rowsNever snapshotted
AvailabilitycheckQuantities on the inventory seamavailable is not always a limit
Price signallast_known_unit_price (change signal only)Never used for totals

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
CartCustomerModuleLeafcustomer/CartCustomerControllerWrite/Bulk/Query servicesThe eight customer routes
CartAdminModuleLeafadmin/CartAdminControllerAdmin serviceThe two read-only routes
Mobile compositionmobile.module.tsCustomer 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.sql

Key files:

FilePurposeKey ExportsNotes
customer/cart-customer.service.tsMutation orchestrationCartCustomerServiceadd / setQuantity / remove / clear
customer/cart-write.service.tsLow-level transaction helpersCartWriteServicerunMutation, runRemoval, upsertLine, deleteLineByVariantId, clearLines — the one-statement UPDATE lives here
customer/cart-bulk.service.tsBatch mutationsCartBulkServiceOne version bump; product resolution in one query
customer/cart-query.service.tsReads + validationCartQueryServiceStrict re-read pass
schema/cart/cart.tsThe cart tablecartsPartial unique live-cart

5. Data Model

5.1 Schema Source

packages/db/src/schema/cart/
  cart.ts  cart-item.ts  enums.ts

5.2 Tables

cart

ColumnTypeNullableIndex/ConstraintRelationNotes
id / public_idserial / uuid v7NoPK / UNIQUE
customer_iduuidNOT NULLFK CASCADE + partial uniquecustomers.idGuest carts are structurally impossible — a decision, not a missing feature
statuscart_status enumNoactive (only one the module writes), checkout_locked, converted
versionintegerNoOptimistic concurrency; bumped by the one-statement UPDATE
last_activity_attimestamptzNoindexCarries the abandoned-cart signal; no expired state
created_at / updated_attimestamptzNo

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)

ColumnTypeNullableIndex/ConstraintRelationNotes
id / public_idserial / uuid v7NoPK / UNIQUEitem.id is emitted for list keying; no route accepts it
cart_idintegerNoFK CASCADEcart.id
variant_idintegerNoFK CASCADE + UNIQUE (cart_id, variant_id)product_variant.idThe line's real key — one line per configuration
product_idintegerNoFK CASCADEproducts.idDenormalised copy of the variant's product, for product-scoped reads
quantitysmallintNoCHECK 1..99
last_known_unit_pricebigintNoNot a price snapshot — change signal only
created_at / updated_attimestamptzNo

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)

MethodCalled ByReadsWritesSide EffectsErrors
add()POST /itemscart, product, variantcart_item (+ cart bump)summary cache clearCART_PRODUCT_NOT_FOUND, CART_ITEM_LIMIT_REACHED, CART_QUANTITY_LIMIT_EXCEEDED
setQuantity()PUT /items/:variantPublicIdcart, variantupsert cart_itemsummary cache clearsame
remove()DELETE /items/:variantPublicIdcartcart_item delete (if it existed)summary cache clear only on an actual change
clear()DELETE /itemscartcart_item deletessummary 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

MethodCalled ByReadsWritesSide EffectsErrors
apply()POST /items/bulkcart, all referenced products in one querymany cart_item rowssummary cache clearCART_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

CacheTTLKeyNotes
Badge counters (/summary)CACHE_TTL.VOLATILE = 30sper-customerThe 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 to req.user.id in 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_READ 60/min (reads), CUSTOMER_CART_MUTATION 60/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.
  • @IdempotentCreate on POST /items requires @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.ts guards it now, and the same defect is live in address-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.