Definitions deep dive
What it looks like when the domain is configuration. Entities, workflows, access rules and extensions, in code rather than prose.
Illustrative excerpts. These snippets show the shape of the configuration and contracts. They are written for this page and simplified; the product code itself stays private.
A whole application is a folder
Entities, state machines, processes, access rules, templates and tests. Versioned in Git, validated in CI, loaded by the core at start.
- No code for a new entity or field
- Changes go through review like code
- Agents can read, write and check it
app/
app.yaml # name, version, enabled traits and extensions
entities/
product.yaml customer.yaml supplier.yaml
order.yaml supplierOrder.yaml quote.yaml
machines/
orderStatus.yaml
processes/
orderRequestToQuote.yaml
access/
groups.yaml roles.yaml
templates/
quote.hbs quoteMail.md
tests/
orderRequestToQuote.test.yaml
extensions/
dropshipping/ order-fulfillment/ credit-check/
Entities
Base types like string, decimal, date, ref and lines come with the core; money, percent and quantity are type extensions that ship as standard. Traits add behavior. Products and prices are modeled in more detail further down, the full reference model is on the data model sheet.
entity: product
label: Product
traits: [auditable, stockable]
fields:
sku: { type: string, required: true, unique: true }
name: { type: string, required: true, index: true }
unit: { type: enum, values: [pcs, box, pallet], default: pcs }
taxRate: { type: enum, values: [standard, reduced], default: standard }
manufacturer: { type: ref, to: manufacturer, required: true }
manufacturerSku: { type: string }
dropship: { type: boolean, default: false } # ships directly from the supplier
# suppliers, purchase and sales prices are separate records, see below
entity: customer
label: Customer
traits: [auditable, numbered: { range: C, pattern: "C{seq:6}" }]
fields:
party: { type: ref, to: party, required: true } # name, addresses, contacts live on the party
customerGroup: { type: ref, to: customerGroup, required: true }
currency: { type: string, default: EUR }
paymentTerms: { type: enum, values: [net14, net30, prepay], default: net14 }
creditLimit: { type: money }
apiPartner: { type: ref, to: partnerAccount } # links a system account for B2B ordering
entity: order
label: Sales order
traits:
- auditable
- numbered: { range: SO, pattern: "SO-{yyyy}-{seq:5}" }
- document:
lines: { item: { type: ref, to: product, snapshot: [sku, name, unit] },
qty: quantity, price: money, delivered: quantity }
tax: perLine
- reservable
- stateful: { machine: orderStatus }
fields:
customer: { type: ref, to: customer, required: true }
channel: { type: enum, values: [api, ui, agent], default: ui }
shipTo: { type: object, fields: { name: string, street: string, zip: string, city: string } }
views:
full: { expand: [customer, lines.item.manufacturer] } # GET ...?view=full
Products and prices over time
A product always has one manufacturer and can have many suppliers. Purchase prices are net per quantity, sales prices depend on channel, currency and quantity, discounts on the customer group. Every price has a validity, so today's price, last January's price and next month's price are all just a question of the date.
entity: manufacturer
fields:
name: { type: string, required: true, unique: true }
entity: product
traits: [auditable, stockable]
fields:
sku: { type: string, required: true, unique: true } # internal article number
name: { type: string, required: true }
unit: { type: enum, values: [pcs, box, pallet], default: pcs }
taxRate: { type: enum, values: [standard, reduced], default: standard }
manufacturer: { type: ref, to: manufacturer, required: true }
manufacturerSku: { type: string }
unique: [[manufacturer, manufacturerSku]]
relations: # reverse references, readable via expand
offers: { entity: supplierProduct, by: product }
salesPrices: { entity: salesPrice, by: product }
views:
full: { expand: [manufacturer, offers.supplier, offers.purchasePrices, salesPrices] }
entity: supplierProduct # one product, many suppliers
fields:
product: { type: ref, to: product, required: true }
supplier: { type: ref, to: supplier, required: true }
supplierSku: { type: string, required: true }
packSize: { type: quantity }
leadTimeDays: { type: integer }
preferred: { type: boolean, default: false }
unique: [[product, supplier], [supplier, supplierSku]]
relations:
purchasePrices: { entity: purchasePrice, by: supplierProduct }
entity: purchasePrice
traits: [auditable, effectiveDated: { key: [supplierProduct, minQty] }]
fields:
supplierProduct: { type: ref, to: supplierProduct, required: true }
minQty: { type: quantity, required: true } # quantity break
price: { type: money, required: true } # net, in the supplier's currency
entity: salesChannel
fields:
code: { type: string, required: true, unique: true } # shop, b2b, marketplace
priceBasis: { type: enum, values: [net, gross], default: net }
currencies: { type: list, of: currency, default: [EUR] } # e.g. [EUR, CHF, GBP]
entity: salesPrice # price list per channel and currency
traits: [auditable, effectiveDated: { key: [product, channel, price.currency, minQty] }]
fields:
product: { type: ref, to: product, required: true }
channel: { type: ref, to: salesChannel, required: true }
minQty: { type: quantity, required: true, default: 1 }
price: { type: money, required: true } # amount and ISO currency
entity: customerGroup # retail, wholesale, keyAccount
fields:
code: { type: string, required: true, unique: true }
entity: discount
traits: [auditable, effectiveDated: { key: [customerGroup, appliesTo, target, minQty] }]
fields:
customerGroup: { type: ref, to: customerGroup, required: true }
channel: { type: ref, to: salesChannel } # empty = all channels
appliesTo: { type: enum, values: [product, manufacturer, all] }
target: { type: ref, to: [product, manufacturer] } # empty when appliesTo = all
minQty: { type: quantity, default: 1 }
percent: { type: decimal, required: true }
entity: exchangeRate # for reporting, never to derive prices
traits: [effectiveDated: { key: [currency] }]
fields:
currency: { type: string, required: true } # ISO 4217, against the base currency
rate: { type: decimal, required: true }
GET /api/v1/pricing/resolve?product=SKU-100&channel=shop¤cy=EUR
&qty=10&customer=C000042&at=2026-01-01
HTTP/1.1 200
{ "product": "SKU-100", "channel": "shop", "qty": 10, "at": "2026-01-01",
"listPrice": "79.00 EUR", "basis": "gross", "minQty": 10,
"discount": { "percent": 5, "customerGroup": "wholesale", "appliesTo": "manufacturer" },
"price": "75.05 EUR",
"sources": [ "019a0f11-7c2d-7e40-a1b3-8c9d0e2f4a61", # salesPrice used
"019a0f30-2b6e-7d11-8e4c-1a7b3c9d5e02" ] } # discount used
GET /api/v1/sales-prices?filter[product.sku]=SKU-100&filter[channel.code]=shop&asOf=2026-01-01
# every tier valid on that day; without asOf: valid today; ?history=true: all versions
Separate records, not one big block
Prices change often, have their own history, their own permissions and come in by import. So each price and each supplier offer is its own record, joined to the product by reference.
effectiveDated
A trait for anything with a validity: no overlaps per key, future prices allowed, past ones locked. Corrections create a new version with a reason, the audit keeps the original.
Quantity breaks
The tier with the highest minimum quantity at or below the ordered quantity wins, among the prices valid on that day. Without tiers, minimum quantity is 1.
Channel prices, group discounts
List prices per channel and currency. Discounts per customer group, for a product, a manufacturer or everything. The most specific discount wins, discounts do not stack.
Several currencies
Prices are maintained per currency, never converted on the fly. Exchange rates with validity serve reporting in the base currency.
Orders remember
An order line keeps the resolved price and the id of the price record it came from. Later price changes never touch it.
Flexible attributes
Every entity can carry attributes that are defined at runtime, without a deployment: color and wattage for lamps, fabric and width for sofas. Attribute definitions are data, the values are validated, filterable and part of the documentation.
Why not just free JSON? Unchecked key value data turns into a swamp: three spellings of the same attribute, numbers stored as text, nothing to filter on. Typed definitions keep the flexibility and the quality.
POST /api/v1/attribute-definitions
{ "entity": "product", "code": "wattage", "type": "quantity", "unit": "W",
"appliesTo": { "category": "lamps" }, "required": true, "filterable": true }
PATCH /api/v1/products/019a1c02-4f7e-7a31-9b20-6d8e1f0a3c55
If-Match: "7"
{ "attributes": { "wattage": "12 W", "color": "black" } }
GET /api/v1/products?filter[attributes.wattage][lt]=15W&filter[attributes.color]=black
Complex objects in one call
An order is one resource: header, lines and the references it needs. Clients never stitch orders, lines, products and manufacturers together themselves, and never calculate anything the server knows better.
GET /api/v1/sales-orders/019a2f3b-1d22-7c40-8a77-3e9b0c5d2f18?view=full
# same as ?expand=customer,lines.item.manufacturer
HTTP/1.1 200
{ "id": "019a2f3b-1d22-7c40-8a77-3e9b0c5d2f18", "number": "SO-2026-00128",
"status": "confirmed", "version": 4,
"customer": { "id": "019a2e77-...", "number": "C000042",
"name": "Weber Bürobedarf" },
"lines": [
{ "id": "019a2f3b-5e10-...", "qty": 4, "delivered": 0,
"price": "89.00 EUR", "net": "356.00 EUR",
"item": { "id": "019a1c02-...", "sku": "SKU-100",
"name": "Desk lamp Arco",
"manufacturer": { "id": "019a0b9d-...", "name": "Lumen GmbH" } } } ],
"totals": { "net": "356.00 EUR", "tax": "67.64 EUR", "gross": "423.64 EUR" } }
POST /api/v1/sales-orders
Idempotency-Key: 4b1e9a0c
{ "customer": { "number": "C000042" },
"lines": [ { "item": { "sku": "SKU-100" }, "qty": 4 },
{ "item": { "sku": "SKU-200" }, "qty": 1 } ] }
HTTP/1.1 201
# the server resolved customer and products by their business keys,
# took prices from the price list, copied sku, name and unit into each line,
# calculated tax and totals, assigned id and number, set status "received"
HTTP/1.1 422 (if something is wrong)
{ "type": "/problems/validation", "title": "Order is not valid",
"errors": [ { "pointer": "/lines/1/item", "detail": "Unknown sku SKU-200" } ] }
One aggregate
Header and lines are stored, read and written together in one transaction. Lines have their own ids for targeted changes.
Nested expand and views
References are expanded up to three levels in one request, batch loaded on the server. Named views keep common shapes short.
Snapshots
An order keeps what was true when it was placed: product name, unit, price and address. Later changes to the product do not rewrite history.
Business keys on write
Clients may reference by id or by a unique business key such as sku or customer number. The server resolves and validates.
Calculated on the server
Prices, tax, totals, numbers and status are read only. State changes go through actions.
Permissions still apply
An expanded reference shows only what the caller may see. A supplier never sees the customer behind an order.
machine: orderStatus
initial: received
states: [received, confirmed, partiallyShipped, shipped, invoiced, cancelled]
transitions:
confirm: { from: received, to: confirmed, guard: "$record.lines.count > 0" }
ship: { from: [confirmed, partiallyShipped], to: shipped,
guard: "$all($record.lines, line.delivered = line.qty)" }
shipPartial: { from: [confirmed, partiallyShipped], to: partiallyShipped }
invoice: { from: shipped, to: invoiced }
cancel: { from: [received, confirmed], to: cancelled, effects: [stock.release] }
# every transition becomes POST /api/v1/sales-orders/{id}/actions/{transition}
# and emits order.{transition}
State machines
Status changes are actions, not field updates. Each transition has guards and effects and becomes an API endpoint and an event.
Processes
A lightweight, BPMN inspired runner chains functions. Branches, retries, approvals and compensation. Nothing more.
POST /api/v1/processes/order_request_to_quote/start
{ "input": { "orderRequest": "OR-2026-00031" }, "mode": "step" }
HTTP/1.1 201
{ "instance": "019a2f52-3d71-7b0e-8c44-6f1a2b9d0e73", "status": "waiting", "nextStep": "checkStock" }
POST /api/v1/process-instances/019a2f52-3d71-7b0e-8c44-6f1a2b9d0e73/step
HTTP/1.1 200
{ "executed": "checkStock", "output": { "allAvailable": true },
"nextStep": "availability", "durationMs": 41 }
Every process can run automatically or one step at a time, with input and output recorded for each step. That makes it a debugger, an audit trail and a good demo.
process: orderRequestToQuote
version: 1
trigger: [api, { event: orderRequest.created }]
input:
orderRequest: { type: ref, to: orderRequest }
steps:
- id: checkStock
function: stock.check
with: { lines: "$input.orderRequest.lines" }
- id: availability
if: "$steps.checkStock.allAvailable"
then: reserve
else: notifyShortage
- id: reserve
function: stock.reserve
with: { lines: "$input.orderRequest.lines", expiresIn: P7D }
compensate: stock.release # undone if a later step fails for good
- id: createQuote
function: entity.create
with: { entity: quote, data: { customer: "$input.orderRequest.customer",
lines: "$input.orderRequest.lines", source: "$input.orderRequest.id" } }
- id: renderPdf
function: pdf.render
with: { template: quote, record: "$steps.createQuote.id" }
- id: approve
when: "$steps.createQuote.total.amount > 10000"
wait: { humanTask: { role: sales, title: "Approve quote" } }
- id: sendMail
function: mail.send
retry: { max: 5, backoff: exponential }
with: { to: ["$input.orderRequest.customer.email"], template: quoteMail,
attachments: ["$steps.renderPdf.file"] }
- id: notifyShortage
function: mail.send
with: { to: ["purchasing@example.com"], template: shortage }
end: true
Functions
Modular tools with a contract: input, output, declared side effects, idempotency. Processes, hooks and the API call them the same way.
- Shipped: entity access, numbers, stock, pricing, templates, PDF, mail, HTTP
- Custom: Slack, shipping labels, accounting exports as extensions
- Functions never touch the database directly, only core APIs
export default defineFunction({
name: "mail.send",
version: "1.0.0",
input: z.object({
to: z.array(z.string().email()),
template: z.string(),
data: z.record(z.unknown()).optional(),
attachments: z.array(fileRef).optional(),
}),
output: z.object({ messageId: z.string() }),
sideEffects: "external", // none | data | external
idempotent: true, // the runner supplies instance + step as key
async run(input, ctx) {
const body = await ctx.templates.render(input.template, input.data)
return ctx.connectors.smtp.send({ ...input, body })
},
})
groups:
internalAdmins:
roles: [admin]
rateLimit: { perHour: 20000, perDay: 200000 }
sales:
roles: [salesRep]
customerSystems: # B2B customers ordering via API
roles: [customerApi]
rateLimit: { perHour: 1000, perDay: 10000 }
suppliers: # dropshipping partners
roles: [supplierApi]
rateLimit: { perHour: 500, perDay: 5000 }
roles:
customerApi:
allow: [order:create, order:read, process:orderFulfillment:start]
scope: { order: "customer.apiPartner = $principal.id" }
supplierApi:
allow: [supplierOrder:read, supplierOrder:transition:accept,
supplierOrder:transition:reject, supplierOrder:transition:ship]
scope: { supplierOrder: "supplier.apiPartner = $principal.id" }
protected: # only the platform level may change these
- groups.*.rateLimit
- roles.admin
Access
Groups, roles, permissions per function, scopes per record, rate limits per group. All of it configuration.
Scopes are what make partner access safe: a supplier's API key can never see another supplier's orders, not via the API, not via events, not via process history.
Extensions
Business processes like dropshipping are extensions, not core code. A manifest declares what they provide and need.
- Semantic versions and a compatible core range
- Permissions confirmed on install
- Own settings with a schema
- Ready for a marketplace, not building one
extension: dropshipping
version: 1.2.0
core: ">=0.4 <1.0" # compatible core versions
provides:
entities: [supplierOrder]
fields: { product: [dropshipSupplier] }
processes: [dropshipOrder]
functions: [supplier.notify]
hooks: [post.transition.order.confirm]
strategies: { fulfillment.allocate: ./allocate-by-postcode.ts } # replaces the default
requires:
traits: [document, numbered]
permissions: [order:read, supplierOrder:create] # shown and confirmed on install
config:
defaultLeadTimeDays: { type: integer, default: 3 }
fallbackSupplier: { type: ref, to: supplier }
Hooks and strategies
Two kinds of extension points. Hooks react around an operation. Strategies replace a decision the core makes with a simple default, which is where custom development usually starts.
| Hook | Strategy | |
|---|---|---|
| Purpose | react, validate, enrich | decide, calculate |
| How many | any number per event | exactly one active, optionally per channel |
| Default in core | none needed | always present |
| Side effects | allowed after commit | none: input in, result out |
| Name | post.transition.order.confirm | strategy.fulfillment.allocate |
| Strategy | Default in the core |
|---|---|
fulfillment.allocate | channel warehouse order, full line first, split if partial delivery is allowed |
packaging.suggest | largest first into the smallest fitting box |
shipping.cost | per delivery, including partial ones, summed per package |
tax.determine | tax rules with OSS and reverse charge |
pricing.resolve | price lists, quantity breaks, group and order discounts |
discount.approve | free up to the role's maximum, above needs approval before the order can be confirmed |
returns.cost | bearer from the channel; defects and wrong items always on us |
Every strategy has a typed contract, a timeout and an explanation that is stored with the document. A custom strategy must pass the same contract tests as the default.
Tests as configuration
Process tests are declarative: given, when, expect, plus business invariants that must always hold.
test: quote is mailed when all items are in stock
given:
stock: { SKU-100: 20, SKU-200: 5 }
orderRequest: { customer: C000042, lines: [ { item: SKU-100, qty: 4 }, { item: SKU-200, qty: 1 } ] }
when:
start: orderRequestToQuote
expect:
steps: [checkStock, availability, reserve, createQuote, renderPdf, sendMail]
skipped: [approve, notifyShortage]
records:
quote: { count: 1, status: draft, total.amount: "> 0" }
stock.available: { SKU-100: 16, SKU-200: 4 }
mails: [ { to: "buyer@customer.example", attachments: 1 } ]
invariants: [reservationsMatchQuotes, noNegativeStock]
Events
One predictable event shape for hooks, webhooks and workers.
{
"specversion": "1.0",
"type": "supplierOrder.shipped",
"source": "/api/v1/supplier-orders/019a2f3c-7b41-7e8a-9c1d-5f2e8a4b6c10",
"id": "019a2f41-0c3e-7f12-b5a0-2d6e9c81f437",
"time": "2026-11-03T09:14:22Z",
"data": { "id": "019a2f3c-7b41-7e8a-9c1d-5f2e8a4b6c10", "number": "SO-2026-00017",
"order": "019a2f3b-1d22-7c40-8a77-3e9b0c5d2f18",
"trackingNumber": "1Z999AA10123456784" }
}