PRD: Product options & variant generation
| Module | Product (CORE-05) | PRD ID | PRD-OPT-002 |
| Status | Shipped | Owner | Product squad |
| Date | 2026-06-15 | Version | v1.0 |
| Packages | @nx/commerce · @nx/core · @nx/search | URD | OPT · VAR |
TL;DR
Turns a product's option axes into the variants a merchant actually sells, in one atomic create. The owner declares the axes (size, ice level, colour) and the exact variants to generate - each naming its own choice per axis - and the system creates the options, their values, every variant, its bindings, its identifiers, and its inventory attributes together. Generation is explicit, never a full cartesian blow-up: only the declared variants exist. One variant is always the default - the one the owner flags, or the first declared otherwise - and each variant's choices are pushed into search so it can be found and filtered by its options.
1. Context & Problem
The option binding model (PRD-OPT-001) defines what a variant's combination is and keeps it valid and unique. It does not define how a product and its full set of option-bearing variants come into existence in a single owner action.
Without a generation path, an owner would have to create a bare product, then add each variant, then attach options one binding at a time - several round-trips, each a chance to leave the catalogue half-built: a product with axes but no variants, a variant with no default, a barcode collision discovered only on the third call. Merchants also expect to type the variants they sell, not have the system multiply every axis into a matrix of phantom rows they must then prune.
This increment closes that gap: a single create request carries the axes and the declared variants, and the system materializes the whole product - options, values, variants, bindings, identifiers, inventory attributes, and one default - atomically, then makes every variant findable by its options in search.
2. Goals & Non-Goals
Goals
- One atomic create that takes option axes + declared variants and generates the entire product graph.
- Explicit generation only - the system creates exactly the declared variants, never the full option matrix.
- Always resolve exactly one default variant - the flagged one, otherwise the first declared.
- Carry each variant's SKU / barcode, with in-request and per-merchant barcode uniqueness.
- Generate inventory attributes only for stockable variant types; non-stockable types and combos are created untracked.
- Denormalize each variant's option choices into search so variants are findable and filterable by their options, kept fresh as axes/values/bindings change.
Non-Goals
- Automatic cartesian expansion of all option combinations - covered as a deliberate non-goal in PRD-OPT-001; owners declare only what they sell.
- The binding-model rules themselves (validity, one-per-axis, combination uniqueness, strict-options) - specified in PRD-OPT-001.
- Pricing and stock figures - prices live in fares (FAR), stock lives in Inventory.
- Bulk / CSV catalogue import (URD-PRD-019) - a separate increment.
3. Success Metrics
| Metric | Target / signal |
|---|---|
| Atomicity | A failed sub-step (bad option, duplicate barcode, two defaults) leaves nothing persisted |
| No phantom variants | Variant count equals the number declared - never the size of the option matrix |
| Default integrity | Every generated product has exactly one default variant |
| Barcode integrity | No two variants share a barcode within a merchant; in-request duplicates are refused |
| Searchability | A generated variant is findable and filterable by each of its option values |
4. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Owner / Manager | Create a product and all its real variants in one step, choosing the default |
| Cashier | Find and pick a generated variant by its options at the point of sale |
| Channel integration | Locate the right variant by filtering on its option facets in search |
Core scenario: an owner creates a milk tea with axes Size (S/M/L) and Ice (100/50/0) and declares the five variants actually sold, flagging "M, 100% ice" as default. The system generates the axes, values, five variants, their bindings, identifiers, and inventory attributes in one atomic step; the flagged variant becomes default; each variant is immediately findable in search by size and ice. Declaring two defaults, or two variants with the same barcode, is refused before anything is saved.
5. User Stories
- As an owner, I declare a product's axes and the exact variants I sell in one request, so the catalogue is complete the moment it is created.
- As an owner, I want only the variants I name to exist, so I never have to delete combinations I don't sell.
- As an owner, I flag one variant as default (or let the first stand), so the POS always has a sensible pick.
- As an owner, I want a duplicated barcode caught before anything saves, so I never ship a clashing code.
- As an owner, I change the default later without rebuilding the product, so the menu can evolve.
- As a cashier, I find a generated variant by its size and ice, so ordering is a few taps.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | Create takes both the option axes and the exact variants to generate, each declaring its own choice per axis | URD-OPT-011 |
| FR-2 | Only the declared variants are generated - no automatic full-matrix expansion | URD-OPT-012 |
| FR-3 | Axes, values, and every declared variant are generated together with the product in one atomic step | URD-OPT-013 |
| FR-4 | Exactly one variant is default: the explicitly flagged one, else the first declared is promoted | URD-OPT-014 |
| FR-5 | More than one explicit default in the same request is refused | URD-OPT-015 |
| FR-6 | The default can be changed after creation via a dedicated promote action | URD-OPT-016 |
| FR-7 | Each generated variant may carry its own SKU / barcode; a barcode duplicated in-request or already used in the merchant is refused | URD-OPT-017 · URD-PID-001 |
| FR-8 | Inventory attributes are generated only for stockable variant types; combos and non-stockable types are untracked | URD-OPT-018 · URD-VAR-006 |
| FR-9 | Generated variants and their option choices are denormalized into search and kept fresh as axes/values/bindings change | URD-OPT-019 |
| FR-10 | Option axes and values are normalized so the same choice written differently maps to one value | URD-OPT-006 |
Full requirement text and acceptance criteria live in the Product URD - OPT. This PRD references them rather than restating them. The binding-model requirements (URD-OPT-001..010) are specified in PRD-OPT-001.
7. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Atomicity | The whole product graph - options, values, variants, bindings, identifiers, inventory attributes, default - is one all-or-nothing transaction |
| Tenancy & authz | All operations scoped per merchant (x-merchant-id) and gated by product permissions |
| Normalization | Option keys and values are normalized (case-insensitive) so equivalent choices collapse to one |
| Identifier integrity | Barcodes are checked for in-request duplicates and existing merchant conflicts before persistence |
| Eventual search consistency | Denormalized option facets are refreshed by a change-data cascade after commit, not in the create's critical path |
| i18n | Axis names are multilingual ({ en, vi }); a value's display name falls back to its raw value when unset |
8. UX & Flows
The create surface lets the owner define the axes and their ordered values, add each variant they sell with its choice per axis and an optional SKU / barcode, and mark one as default. Generated variants then appear in product search filterable by their option facets.
9. Data & Domain
| Entity | Role in generation |
|---|---|
ProductOption | An option axis the product varies by, generated from a declared axis (name, key, order) |
ProductOptionValue | One ordered choice on an axis, generated from the axis's declared values |
ProductVariant | A generated sellable unit; exactly one per product is the default |
ProductVariantOption | One binding of a variant to a single (axis, value) pair - a variant's set of bindings is its combination |
Conceptual only - full schema and invariants live in the commerce domain model. Relations are soft references; integrity is enforced in the generation service, not by database constraints.
10. Dependencies & Assumptions
Depends on
- Option binding model (PRD-OPT-001, URD-OPT-001..010) - generation produces the bindings this model validates.
- Variants & aggregate create (VAR, PRD-PRD-001) - variants and their fare set / identifiers are created through the variant aggregate.
- Identifiers (PID) - SYSTEM / SLUG auto-generated; SKU / BARCODE per variant.
- Inventory (Inventory) - stockable variants seed inventory items downstream.
@nx/search- denormalizes each variant's options and facets for lookup.
Assumptions
- The owner declares the variants they actually sell; the system does not infer the full matrix.
- A merchant's strict-options setting (from PRD-OPT-001) is read during generation and governs whether option-free variants are allowed.
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| Partial product graph on failure (axes but no variants, no default) | Whole graph is one atomic transaction - any failure rolls back fully |
| Two variants flagged default | Refused before persistence with a clear, specific reason |
| Barcode clash discovered late | Two-stage check - in-request duplicates, then existing merchant conflicts - before anything saves |
| Search shows stale option facets | Facets refreshed by a change-data cascade on axis, value, and binding changes |
| Owner expects auto-generated matrix | Deliberate non-goal (PRD-OPT-001); only declared variants are generated |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P2 - OPT in the URD feature catalog, alongside VAR |
| Rollout | All merchants; no feature flag |
| Migration | None - generation runs on the existing product aggregate-create path |
| Launch criteria | Create generates exactly the declared variants atomically; one default is always resolved; two defaults and duplicate barcodes are refused; inventory attributes appear only for stockable types; generated variants are filterable by their option facets in search |
| Monitoring | Create error rate by reason (duplicate default, barcode conflict), search-facet freshness lag |
13. FAQ
Does the system generate every size × ice combination for me? No - generation is explicit. You declare the variants you sell and exactly those are created; there are no phantom rows to prune. (See PRD-OPT-001 for the reasoning.)
What if I don't flag a default? The first variant you declare is promoted to default automatically, so a product always has one.
Can I flag two defaults? No - a request with more than one explicit default is refused before anything is saved. Change the default later with the dedicated promote action.
Do all variant types get inventory tracking? No - only stockable types generate inventory attributes. Combos and non-stockable types (e.g. services) are created untracked.
When can I search a new variant by its options? Each variant's option facets are denormalized into search after creation and kept current by a cascade whenever an axis, value, or binding changes.
References
- URD: Product - OPT · VAR · PID
- Sibling PRDs: Product options & variant binding · Product catalogue, variants & identifiers · Fare system & pricing
- Module: Product - overview + capabilities
- Developer: @nx/commerce · @nx/core · @nx/search