Files
web_sport/docs/adr/0014-denormalised-price-projection-on-product.md
2026-08-13 23:20:22 +07:00

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.