2.5 KiB
ADR-0014: A denormalised price/stock projection on Product
- Status: Accepted
- Date: 2026-08-11
Context
Price lives on ProductVariant (ADR-0003), because a size M and a size L can
genuinely cost different amounts. A listing page, however, needs to:
- sort by price ("low to high") across products,
- filter by a price band,
- render a price-range facet,
- filter to "in stock only".
Every one of those needs MIN/MAX over a product's variants inside WHERE
and ORDER BY. Prisma cannot express "order by the minimum price of a related
collection" — no ORM comfortably can — and the same is true of "has at least one
variant with available stock".
Decision
Four derived columns on products: min_price_amount, max_price_amount,
is_on_sale, in_stock.
They are used only in WHERE and ORDER BY. Everything a page displays is
computed from the variant rows already loaded, so a stale projection can shift
result ordering but can never show a wrong price to a customer. That asymmetry is
the whole reason this is acceptable.
ProductsRepository.recomputePricing() is the single writer. Every variant,
price or stock mutation must call it; a direct UPDATE of these columns is a bug.
Consequences
Listing queries stay ordinary Prisma queries — no raw SQL in the hottest path in the catalog, and the query builder keeps its type safety.
The cost is a classic denormalisation risk: a write path that forgets to recompute leaves a product sorted or filtered wrongly. Mitigations are the ones that actually work — a single writer, an explicit contract in the schema comment, and the display/ordering split above so the blast radius is a mis-sort rather than a mis-priced order.
Alternatives considered
Raw SQL for the listing query. Correct and fast, but the listing query is the
one that grows the most filters over time; hand-maintaining a dynamic SQL builder
with a dozen optional predicates is where injection bugs and subtle
AND/OR precedence errors come from. Rejected for now — if the query grows
past what Prisma can express cleanly, this becomes the fallback.
A materialised view. PostgreSQL materialised views cannot be incrementally refreshed, so every variant edit would trigger a full rebuild. Rejected.
Push it into the search engine. This is genuinely the right long-term answer and is what ADR-0012 anticipates. Rejected for now because introducing OpenSearch to sort a few hundred products by price is exactly the premature infrastructure that ADR-0012 exists to avoid.