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

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&currency=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.

HookStrategy
Purposereact, validate, enrichdecide, calculate
How manyany number per eventexactly one active, optionally per channel
Default in corenone neededalways present
Side effectsallowed after commitnone: input in, result out
Namepost.transition.order.confirmstrategy.fulfillment.allocate
StrategyDefault in the core
fulfillment.allocatechannel warehouse order, full line first, split if partial delivery is allowed
packaging.suggestlargest first into the smallest fitting box
shipping.costper delivery, including partial ones, summed per package
tax.determinetax rules with OSS and reverse charge
pricing.resolveprice lists, quantity breaks, group and order discounts
discount.approvefree up to the role's maximum, above needs approval before the order can be confirmed
returns.costbearer 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" }
}