1.8 KiB
ADR-0003: Product and ProductVariant as separate entities
- Status: Accepted
- Date: 2026-08-11
Context
A "Running Shirt" in black, size M is a different physical good from the same shirt in
white, size L: different barcode, different stock, potentially different price. Modelling
sizes: string[] and colors: string[] on a product makes every one of those facts
unrepresentable.
Decision
Product is the marketing entity — it has a name, a slug and a page, and deliberately
has no SKU, no price and no stock. It owns an ordered list of ProductOptions (Colour, Size),
each owning ordered ProductOptionValues. Every purchasable combination is a
ProductVariant with its own SKU, price, sale price, barcode, weight and stock.
ProductVariantOptionValue resolves a variant to exactly one value per option, with
@@id([variantId, optionId]) enforcing at the database level that a variant cannot have two
colours. Stock lives in StockLevel keyed by (variant, location), never on the variant row.
Consequences
Cart lines, order lines, stock movements and marketplace listings all reference a variant id — the same granularity Shopee, Lazada, TikTok Shop, ERP and POS systems use, so integrations map 1:1 instead of needing a translation layer. Adding a third option (width, fit) is data, not a migration.
The cost is real: the PDP must resolve option selections to a variant, and the admin needs a variant-matrix editor rather than two text inputs. That cost is paid once and is the reason the model survives contact with a warehouse.
Alternatives considered
Size/colour as columns on Product — rejected: cannot express per-combination stock or price, which is the entire job. A single flat SKU table with no product grouping — rejected: there would be nothing to hang a product page, gallery or description on.