ADR-0003. Product variant type discriminator + ProductBundler relations
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-04-10 |
| Deciders | commerce-team, inventory-team |
| Supersedes | — |
Context
- The catalog must express several behaviors: physical stocked goods, non-stocked consumables, services, kits/manufactured items (bill-of-materials), virtual combos, free addons, and "frequently bought together" suggestions.
- An earlier model overloaded
Category.type(COMBO/ADDON/FBT) to drive inventory and sale behavior. This was fragile: re-categorizing a product could flip it between physical and virtual, and category is fundamentally an FE-grouping concern. - Inventory and sale need a single, stable structural signal for "is this stockable?" and "does this explode into components at sale?".
Decision
Split the concern into a per-variant structural discriminator and a separate relation table:
ProductVariant.type(ProductVariantTypes,core/.../public/product-variant/constants.ts) is the sole structural discriminator:STORABLE(default) /MANUFACTURED→ stockable (STOCKABLE_SET).CONSUMABLE/SERVICE→ not stocked.KIT/COMBO/MANUFACTURED→ have a BOM (BOMABLE_SET).COMBOis virtual — noInventoryItem; it explodes at cart-add into component variants.
ProductBundleris the single relation table for COMBO / ADDON / FBT (ProductBundlerTypes), keyed by(type, leadVariantId, relatedVariantId). ADDON and FBT are relations, not variant types — an addon variant is an ordinary physical PV; the relation row makes it an addon.Category.typebecomes an FE-grouping label only — nothing in inventory/sale branches on it.
The combo explosion at cart-add is implemented in @nx/sale / @nx/inventory — see the cross-package ADR.
Consequences
| Pros | Cons |
|---|---|
| Stable structural signal independent of categorization | Two concepts (variant type + bundler type) — must not be conflated |
| Re-categorizing a product is safe (can't flip physical↔virtual) | Migration required to backfill type from legacy category-based data |
One relation table cleanly covers COMBO/ADDON/FBT with optional price basis | FBT directionality requires one row per direction |
Inventory seeds items only for STOCKABLE_SET variants | Consumers must read ProductVariantTypes helpers, not category |
Alternatives Considered
| Option | Pros | Cons | Why rejected |
|---|---|---|---|
Keep Category.type as the discriminator | No schema change | Fragile; re-categorization flips behavior; conflates grouping with structure | Root cause of the bug being fixed |
| Separate tables per relation (combo/addon/FBT) | Explicit | Three near-identical tables; duplicated logic | One ProductBundler with a type is simpler |
Boolean flags on ProductVariant (isCombo, isKit…) | Simple | Mutually-exclusive flags model a finite enum poorly | A single typed type enum is correct per code style |
References
packages/core/src/models/schemas/public/product-variant/constants.ts(ProductVariantTypes,STOCKABLE_SET,BOMABLE_SET)packages/core/src/models/schemas/public/product-bundler/{schema,constants}.ts- Cross-package:
developer/packages/inventory/decisions/0006-combo-explosion-at-cart-add