40 lines
1.8 KiB
Markdown
40 lines
1.8 KiB
Markdown
# 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 `ProductOption`s (Colour, Size),
|
|
each owning ordered `ProductOptionValue`s. 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.
|