PIM design patterns and how this platform supports them

The five modelling patterns published by Crystallize are a good conformance test for a schema-driven platform: they are the shapes a real catalogue needs once "a product has fields" stops being enough. This page states, per pattern, what it is, how it is expressed here and where the runtime logic lives. The restaurant template (restaurant-menu) implements all five at once and is the reference example.

Semantic classification bridge

A classification is a document, not an enum: allergens, diet labels and doneness levels have their own name, icon, legal wording, localisation and history. Everything else points at them by relation, so renaming "gluten" once fixes every dish.

  • Supported by: any collection plus relation / manyToMany fields.
  • In the template: food_allergen, food_diet, food_prep_level.
  • Use an enumeration field instead only when the values are structural (item_type, status) and

never need translation or extra attributes.

Quantised classification bridge

A relation that carries how much: a component row holding a relation plus quantity and unit. It keeps the target reusable while letting each parent state its own amount, which is what makes cost, nutrition and allergen roll-ups possible.

  • Supported by: repeatable component fields containing a relation plus decimal + enumeration.
  • In the template: food_recipe_line (ingredient + quantity + unit + waste %), food_combo_line

(menu item + quantity + upgrade price).

Composite classification bridge

A relation that carries meaning: the same target can be a base, a sauce, a side or packaging depending on the role stated on the bridge row. Ordering and swappability live on the bridge too.

  • Supported by: repeatable component fields combining a role enumeration with a relation, and

polymorphicRelation when the target may be of several models.

  • In the template: food_component_line (role + polymorphic target: ingredient or another dish),

food_menu_item_group (dish ↔ modifier group with per-dish selection bounds).

Conditional classification bridge

A relation plus the rule that governs it: this option requires that one, excludes another, implies a third. Rules stay data, so a new combination is an edit, never a deployment.

  • Supported by: repeatable component fields with a rule enumeration and a multi-target

polymorphicRelation; the platform's own visibleWhen / blockWhen condition trees cover form-level conditionality.

  • In the template: food_option_rule with requires, excludes, implies, at_least_one_of,

at_most_one_of, recommends, a hard flag (block vs. warn) and a localised guest message.

  • Enforcement: validateConfiguration in src/platform/commerce/configurator.ts — the same pure

function runs in the browser and on the server, so the storefront and the API can never disagree.

Polymorphic choice

One field, several possible shapes, exactly one chosen: a pizza carries a pizza spec, a steak a steak spec, and no entry ever carries both.

  • Supported by: dynamicZone with max: 1 and blockWhen, which restricts each allowed block to a

condition on the entry's own fields; polymorphicRelation covers the by-reference variant.

  • In the template: food_menu_item.spec allows food_pizza_spec, food_burger_spec,

food_steak_spec, food_drink_spec, food_combo_spec, each gated on item_type.

  • The compiler validates blockWhen against the entry fields, so a mis-typed condition fails at

schema-save time rather than in the editor.

Guest configurator semantics

Schemas describe the offer; the maths lives in one pure module (src/platform/commerce/configurator.ts), reachable from the storefront, the API and the tests:

buildConfigurableItem(rows)      → ids resolved to codes, groups + options + rules flattened
configureItem(item, config)
  ├─ applyImpliedSelections      → `implies` rules auto-select companions
  ├─ validateConfiguration       → issues[{ code, severity, group, option, message }]
  └─ priceConfiguration          → size price × topping factor, free allowance, removal credits
  • Selection modes: single, multiple, quantised (quantities count towards the bounds).
  • Bounds: required, minSelect, maxSelect, per-option maxQuantity, per-dish overrides.
  • Money: price per size, price delta per size, toppingFactor per size, half-and-half charged at

half the delta, freeIncluded covering the most expensive selections first, negative deltas for removals.

  • Severity: error blocks the order, warning is advisory (recommends, or any rule with

hard: false), so upsell hints and hard constraints share one channel.

Coverage summary

PatternSupportExpressed with
Semantic classification bridgeFullcollections + relations
Quantised classification bridgeFullrepeatable components + decimal/unit
Composite classification bridgeFullrepeatable components + role enum + polymorphic relation
Conditional classification bridgeFullrule components + condition trees + configurator engine
Polymorphic choiceFulldynamic zone (max: 1, blockWhen) or polymorphic relation

Worked example: Pizza Margherita

src/platform/commerce/demo-configurations.ts ships the full offer used by /admin/configurator and by src/platform/__tests__/demo-pizza.test.ts, so the numbers in the UI are the tested numbers:

  • Sizes 26/32/38/45 cm, each with its own base price and toppingFactor (×0.8 … ×1.6), so a topping

costs proportionally more on a bigger pizza.

  • grp_toppings allows up to 6 selections with freeIncluded: 2 — the allowance is spent on the two

most expensive selections, and the rest is charged.

  • Half placement (half_left / half_right) charges 50% of the size-scaled delta.
  • opt_topping_no_cheese carries a negative delta, so removing an ingredient credits the guest.
  • Rules: chili oil requires a cheese base, pesto implies mozzarella, and two cheeses are

at_most_one_of with hard: false (a warning, not a block).

Reserved column names

A field compiles to a physical column, so field names must avoid the platform's own columns (id, account_id, locale, status, created_at, …). RESERVED_SYSTEM_COLUMNS in src/platform/schema/compiler.ts is the source of truth and validateDefinition rejects the collision at save time — note that a relation named account compiles to account_id, which is why the shipped templates call that field company.