# 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.