PRD: Product options & variant generation
| Module | Products | PRD ID | PRD-OPT-002 |
| Status | Ready to dev | FEAT | OPT |
| Epic | — | Plane | BANA-1555 |
| Date | 2026-06-15 | Version | v1.0 |
| Packages | @nx/commerce · @nx/core | URD | OPT |
| Surface | Client · Owner/Mgr | ||
| Owner | Phát Nguyễn | ||
What product options & variant generation is
The option binding model defines what a variant's combination is and keeps it valid and unique - but not how a product and its full set of option-bearing variants come to exist in one owner action.
This PRD turns a product's option axes into the exact variants a merchant sells - in one atomic create. The owner declares the axes (size, ice, colour) and names each variant, choosing its value per axis; the system builds every option, value, variant, binding, identifier, and inventory attribute together, resolves one default, and makes each variant findable by its options in search.
Why a dedicated generation path is needed
Without a generation path, the owner builds the catalogue piecemeal: create a bare product, add each variant, attach options one at a time. Every round-trip is a chance to leave it half-built - axes with no variants, a variant with no default, a barcode clash found only on the third try.
| Trait | Meaning |
|---|---|
| Explicit | Only the declared variants exist, never a full cartesian matrix |
| Atomic | Options, values, variants, bindings, identifiers, inventory attributes, and one default are one unit - all changes land, or none do |
| Default | Whichever variant the owner flags, or the first declared if none |
One product, from declaring axes to ready-to-sell
An owner creates a milk tea with axes Size (S/M/L) and Ice (100/50/0), declares the five variants actually sold, and flags "M, 100% ice" as default. In one atomic step the system builds the axes, values, five variants, their bindings, identifiers, and inventory attributes; the flagged variant becomes default; each is immediately findable in search by its size and ice.
Declaring two defaults, or two variants with the same barcode, is refused before anything is saved.
Where this is easy to get wrong
The suggestion screen is not the system persisting the matrix
The Generate Variants screen on the Client app still suggests the full axis matrix as an editing convenience so the owner can prune/add before saving - that's a screen affordance to help the owner move faster, not the system persisting the whole matrix. Only the variants the owner keeps at save time are actually created; whatever is deleted on screen never reaches the system.
1. Goals & Non-Goals
Goals
- One atomic create that takes option axes + declared variants and builds the entire product.
- Explicit generation - exactly the declared variants, never the full option matrix.
- Always resolve exactly one default - the flagged variant, else the first declared.
- Carry each variant's own SKU or barcode, unique within the request and within the merchant.
- Generate inventory attributes only for stockable variant types; non-stockable types and combos are created untracked.
- Make each variant's choices findable in search, kept fresh as axes, values, or bindings change.
Non-Goals
- Automatic cartesian expansion on the system side - a deliberate non-goal in PRD-OPT-001; owners declare only what they sell. (The Generate Variants screen on the Client app still suggests the full axis matrix as an editing convenience so the owner can prune/add before saving - that's a screen affordance, not the system persisting the whole matrix.)
- The binding-model rules themselves (validity, one-per-axis, combination uniqueness, strict-options) - see PRD-OPT-001.
- Pricing and stock figures - prices live in fares (FAR), stock in Inventory.
- Bulk / spreadsheet import (URD-PRD-016) - a separate increment.
2. Success Metrics
| Metric | Target / signal |
|---|---|
| Atomicity | Any failed sub-step (bad option, duplicate barcode, two defaults) leaves nothing saved |
| No phantom variants | Variant count equals the number declared - never the size of the matrix |
| Default integrity | Every generated product has exactly one default |
| Barcode integrity | No two variants share a barcode in a merchant; in-request duplicates are refused |
| Searchability | A generated variant is findable and filterable by each of its option values |
3. 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 options in search |
Core scenario: an owner creates a milk tea with axes Size (S/M/L) and Ice (100/50/0), declares the five variants actually sold, and flags "M, 100% ice" as default. In one atomic step the system builds the axes, values, five variants, their bindings, identifiers, and inventory attributes; the flagged variant becomes default; each is immediately findable in search by its size and ice. Declaring two defaults, or two variants with the same barcode, is refused before anything is saved.
4. User Stories
| # | As a | I want | So that |
|---|---|---|---|
| 01 | owner | declare a product's axes and the exact variants I sell in one request | the catalogue is complete the moment it's created |
| 02 | owner | only the variants I name to exist | I never delete combinations I don't sell |
| 03 | owner | flag one variant as default (or let the first stand) | the POS always has a sensible pick |
| 04 | owner | a duplicate barcode caught before anything saves | I never ship a clashing code |
| 05 | owner | change the default later without rebuilding the product | the menu can evolve |
| 06 | cashier | find a generated variant by its size and ice | ordering is a few taps |
5. Functional Requirements
| # | Requirement | Status | URD ref |
|---|---|---|---|
FR-001 | Create takes both the option axes and the exact variants to generate. Each variant declares its choice per axis. | ✅ | URD-OPT-011 |
FR-002 | Only the declared variants are generated - no automatic full-matrix expansion. | ✅ | URD-OPT-012 |
FR-003 | Axes, values, and every declared variant are generated with the product in one atomic step. | ✅ | URD-OPT-013 |
FR-004 | Exactly one default is resolved for every create. • The explicitly flagged variant becomes the default. • If none is flagged, the first declared variant is promoted instead. | 🔶 | URD-OPT-014 |
FR-005 | More than one explicit default in the same request is refused. | 🔶 | URD-OPT-015 |
FR-006 | The default can be changed after creation via a dedicated promote action. | 🔶 | URD-OPT-016 |
FR-007 | Each variant may carry its own SKU or barcode. • One duplicated in-request is refused. • One already used in the merchant is also refused. | 🔶 | URD-OPT-017 · URD-PID-001 |
FR-008 | Inventory attributes are generated only for stockable variant types. Combos and non-stockable types stay untracked. | ✅ | URD-OPT-018 · URD-VAR-006 |
FR-009 | Generated variants and their option choices are made findable in search. Kept fresh as axes, values, or bindings change. | ✅ | URD-OPT-019 |
FR-010 | Option axes and values are normalized (case-insensitive) on the standalone option/value edit path so equivalent choices collapse to one. | 🔶 | URD-OPT-020 |
5.1 Acceptance Criteria
- Declare axes Size (S/M/L) and Ice (100/50/0) plus five specific variants when creating a product → exactly those five variants are saved, not the full nine-way matrix of the two axes.
- Within the same create request, a variant declares an option or value that doesn't belong to a declared axis → the whole request is refused, nothing is saved.
- Create a product with axes, values, and declared variants → everything saves together; any failed sub-step (bad option, duplicate code, two defaults) leaves nothing saved.
- Flag one variant as default when declaring it → that variant is saved as the default; flag none → the first declared variant becomes default automatically.
- Flag two variants as default in the same create request → refused before anything saves.
- After creation, promote a different variant to default → that variant becomes default and the previous one stops being default.
- Declare two variants with the same SKU or barcode in one create request → refused; declare a code already used by another variant in the same merchant → also refused.
- Generate variants for a stockable type → inventory attributes are created for each variant; generate combo or non-stockable variants → they're created untracked.
- Search by an option value of a freshly generated variant (e.g. size=M) → the variant is returned and filters correctly; change the axis, value, or binding afterward → the variant's option facets in search refresh accordingly.
- On the standalone option-management screen, type a value that differs only in case from an existing one on the same axis → the two equivalent values collapse into one, no duplicate is created.
6. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Atomicity | The whole product - options, values, variants, bindings, identifiers, inventory attributes, default - is one all-or-nothing operation |
| Tenancy & access | All operations confined to the user's own merchant and gated by product permissions |
| Normalization | Option keys and values are normalized (case-insensitive) on the standalone option/value edit path; the aggregate generation create path does not yet normalize (stores them verbatim) |
| Identifier integrity | Barcodes are checked for in-request duplicates and existing merchant conflicts before saving |
| Eventual search consistency | Searchable options are refreshed in the background after the create commits, not in its critical path |
| Languages | Axis names are multilingual (English and Vietnamese); a value's display name falls back to its raw value when unset |
7. UX & Flows
The create screen lets the owner define the axes and their ordered values, add each variant they sell with its choice per axis and an optional barcode, and mark one as default. Generated variants then appear in product search, filterable by their options.
8. Data & Domain
| Concept | Role in generation |
|---|---|
| Option | An axis the product varies by, generated from a declared axis (name, key, order) |
| Option value | One ordered choice on an axis, generated from the axis's declared values |
| Variant | A generated sellable unit; exactly one per product is the default |
| Variant binding | A variant tied to a single (axis, value) pair - a variant's set of bindings is its combination |
Conceptual only - full detail and invariants live in the commerce domain model. Links are soft references; integrity is enforced in the generation logic, not by database constraints.
9. Dependencies & Assumptions
Depends on
| # | Feature | What it depends on |
|---|---|---|
| 01 | Option binding model (PRD-OPT-001, URD-OPT-001..010) | generation produces the bindings this model validates. |
| 02 | Variants & all-in-one create (VAR, PRD-PRD-001) | variants and their prices / identifiers are created through the variant create. |
| 03 | Identifiers (PID) | system code and slug auto-generated; own SKU / barcode per variant. |
| 04 | Inventory (Inventory) | stockable variants seed inventory items downstream. |
| 05 | Search | makes each variant's options findable for lookup. |
Assumptions
| # | Assumption | If wrong |
|---|---|---|
| 01 | The owner declares the variants they actually sell; the system never infers the full matrix. | If the system inferred the full matrix, every added axis would multiply the variant count exponentially - a product with 3 axes of 4 values each would yield 64 variants even if the owner sells only a handful, forcing manual deletion of the rest instead of declaring only what sells. |
| 02 | A merchant's strict-options setting (from PRD-OPT-001) is read during generation and governs whether option-free variants are allowed. | If the generation path ignored this setting, a merchant that turned strict options on could still generate option-free variants through the product create path, breaking the policy they chose. |
10. 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 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 options in search |
| Monitoring | Create error rate by reason (duplicate default, barcode conflict), search freshness lag |
References
- URD: Product - OPT · VAR · PID
- Sibling PRDs: Product options & variant binding · Product catalogue, variants & identifiers · Fare system & pricing
- Module: Product - URD
- Developer: @nx/commerce · @nx/core · @nx/search
Risks & Open Questions
| # | Risk / question | Mitigation / status |
|---|---|---|
| 01 | Half-built product on failure (axes but no variants, no default) | The whole product is one atomic operation - any failure rolls back fully |
| 02 | Two variants flagged default | Refused before saving, with a clear and specific reason |
| 03 | Barcode clash discovered late | Two-stage check - in-request duplicates, then existing merchant conflicts - before anything saves |
| 04 | Search shows stale options | Refreshed in the background on axis, value, and binding changes |
| 05 | Owner expects an auto-generated matrix | Deliberate non-goal (PRD-OPT-001); only declared variants are generated |
Frequently Asked Questions
| # | Question | Answer |
|---|---|---|
| 01 | Does the system generate every size × ice combination for me? | Saving itself is explicit - only the variants you keep are created. The Generate Variants screen on the Client app does suggest the full axis matrix so you can prune it before saving, but that's just a screen suggestion; whatever you delete there never reaches the system. (See PRD-OPT-001 for the reasoning.) |
| 02 | What if I don't flag a default? | The first variant you declare becomes the default automatically, so a product always has one. |
| 03 | 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. |
| 04 | 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. |
| 05 | When can I search a new variant by its options? | Right after creation - each variant's options become searchable and stay current through a background refresh whenever an axis, value, or binding changes. |