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
collectionplusrelation/manyToManyfields. - In the template:
food_allergen,food_diet,food_prep_level. - Use an
enumerationfield 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
componentfields containing a relation plusdecimal+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
componentfields combining a roleenumerationwith 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
componentfields with a ruleenumerationand a multi-target
polymorphicRelation; the platform's own visibleWhen / blockWhen condition trees cover form-level conditionality.
- In the template:
food_option_rulewithrequires,excludes,implies,at_least_one_of,
at_most_one_of, recommends, a hard flag (block vs. warn) and a localised guest message.
- Enforcement:
validateConfigurationinsrc/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:
dynamicZonewithmax: 1andblockWhen, 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.specallowsfood_pizza_spec,food_burger_spec,
food_steak_spec, food_drink_spec, food_combo_spec, each gated on item_type.
- The compiler validates
blockWhenagainst 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-optionmaxQuantity, per-dish overrides. - Money: price per size, price delta per size,
toppingFactorper size, half-and-half charged at
half the delta, freeIncluded covering the most expensive selections first, negative deltas for removals.
- Severity:
errorblocks the order,warningis advisory (recommends, or any rule with
hard: false), so upsell hints and hard constraints share one channel.
Coverage summary
| Pattern | Support | Expressed with |
|---|---|---|
| Semantic classification bridge | Full | collections + relations |
| Quantised classification bridge | Full | repeatable components + decimal/unit |
| Composite classification bridge | Full | repeatable components + role enum + polymorphic relation |
| Conditional classification bridge | Full | rule components + condition trees + configurator engine |
| Polymorphic choice | Full | dynamic 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_toppingsallows up to 6 selections withfreeIncluded: 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_cheesecarries a negative delta, so removing an ingredient credits the guest.- Rules: chili oil
requiresa cheese base, pestoimpliesmozzarella, 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.