Skip to content

ADR-0003. Product variant type discriminator + ProductBundler relations

FieldValue
StatusAccepted
Date2026-04-10
Deciderscommerce-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:

  1. 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).
    • COMBO is virtual — no InventoryItem; it explodes at cart-add into component variants.
  2. ProductBundler is 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.
  3. Category.type becomes 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

ProsCons
Stable structural signal independent of categorizationTwo 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 basisFBT directionality requires one row per direction
Inventory seeds items only for STOCKABLE_SET variantsConsumers must read ProductVariantTypes helpers, not category

Alternatives Considered

OptionProsConsWhy rejected
Keep Category.type as the discriminatorNo schema changeFragile; re-categorization flips behavior; conflates grouping with structureRoot cause of the bug being fixed
Separate tables per relation (combo/addon/FBT)ExplicitThree near-identical tables; duplicated logicOne ProductBundler with a type is simpler
Boolean flags on ProductVariant (isCombo, isKit…)SimpleMutually-exclusive flags model a finite enum poorlyA 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

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.