Skip to main content

Composite Products: grouped, bundle, virtual

The Catalog supports three product types beyond simple / configurable. Each carries a type-discriminated payload on the PDP and renders a dedicated component in the storefront's action zone.

Types​

Grouped​

A parent product with a fixed list of child products at fixed quantities. The buyer cannot configure which children — they buy the whole set. Use case: "Starter kit" SKU that bundles two simple products at quantities 2 and 1.

Bundle​

A parent product with named slots, each with min / max quantity ranges and one or more option products to choose between. Use case: configurable workstation where the buyer picks a CPU (slot, min=1 max=1) and add-on accessories (slot, min=0 max=3).

Virtual​

A digital product with downloadAssetId (server-hosted file) or downloadUrl (external link) — exactly one of the two MUST be set (Zod refine + service-layer guard). Use case: e-book PDF, license key fulfilment, link to a third-party download portal.

No nested composites​

A grouped product cannot include another grouped or bundle product as a child. A bundle's slot options cannot be grouped or bundle products either. The rule is enforced at the service layer because PostgreSQL CHECK constraints cannot JOIN products to inspect the foreign row's type. Surfaces as NESTED_COMPOSITE_NOT_ALLOWED (400).

Public surface​

Admin​

Verb + PathPurpose
GET / POST / PATCH / DELETE /products/:id/grouped-items[/:itemId]Grouped CRUD
GET / POST / PATCH / DELETE /products/:id/bundle-slots[/:slotId]Bundle slot CRUD
POST / DELETE /products/:id/bundle-slots/:slotId/options[/:optionId]Slot option CRUD

Storefront​

Verb + PathPurpose
POST /api/v1/catalog/products/:idOrSlug/bundle-configuration/validatePure compute: validate a buyer's bundle configuration

The validation endpoint is the only POST on the public catalog surface. It returns {valid, errors[], resolvedSelections} rather than non-2xx — the storefront wants the structured error list so it can highlight every offending slot.

Validation errors (inside the response envelope)​

CodeWhen
MIN_NOT_METTotal selected qty for a slot < minQuantity
MAX_EXCEEDEDTotal selected qty for a slot > maxQuantity
UNKNOWN_OPTIONoptionId not part of the slot

HTTP-level rejections (400 PRODUCT_TYPE_MISMATCH when the product is not a bundle, 404 PRODUCT_NOT_FOUND) still apply.

CRUD-level errors​

CodeStatusWhen
PRODUCT_TYPE_MISMATCH400Calling grouped/bundle endpoints against the wrong parent type
NESTED_COMPOSITE_NOT_ALLOWED400Child / option product is itself grouped or bundle
INVALID_QUANTITY_RANGE400Slot minQuantity > maxQuantity
OPTION_ALREADY_EXISTS409Same option product reused in a slot
GROUPED_ITEM_NOT_FOUND404:itemId missing
BUNDLE_SLOT_NOT_FOUND404:slotId missing
BUNDLE_SLOT_OPTION_NOT_FOUND404:optionId missing

Storefront integration​

productDetail carries one of three branches based on type:

  • groupedItems[] when type='grouped' — {id, position, quantity, product{...}}
  • bundleSlots[] when type='bundle' — {id, name, minQuantity, maxQuantity, position, options: [{id, defaultQuantity, position, product{...}}]}
  • virtual when type='virtual' — {downloadAssetId, downloadUrl}

The PDP type-switches the action zone:

product.typeComponentUX
simple, configurablelegacy Add-to-cart + RFQ + VariantPickerunchanged
grouped<GroupedSummary>Read-only list + "Add bundle to cart" CTA
bundle<BundleConfigurator>Per-slot select + qty input; CTA disabled when any slot is required
virtual<VirtualCta>"Buy and download" CTA + delivery copy

Storage​

  • grouped_items (id, parent_product_id FK CASCADE, child_product_id FK RESTRICT, quantity, position; UNIQUE (parent, child); CHECK quantity > 0 + parent <> child)
  • bundle_slots (id, parent_product_id FK CASCADE, name jsonb, minQuantity / maxQuantity int; CHECK min_quantity <= max_quantity + min_quantity >= 0 + max_quantity > 0)
  • bundle_slot_options (id, slot_id FK CASCADE, option_product_id FK RESTRICT, default_quantity, position; UNIQUE (slot, option_product))

Virtual download fields live on the products table itself: download_asset_id FK nullable + download_url varchar nullable.