← Back to home

Loom — Language Reference

Loom is a high-descriptive DSL for Domain-Driven Design. A .ddd source describes one or more bounded contexts, each containing the familiar DDD primitives — aggregates, value objects, enums, events, repositories, projections, workflows, policies — with strongly-typed invariants, operations, and a small expression language.

This document defines the language formally. For the architectural view (AST → IR → emitters) see technical.md; for CLI and tooling see tools.md.


Lexical structure

Keywords — hard vs. soft

Loom keeps the hard-reserved set to declaration heads, type names and expression keywords (context, aggregate, entity, event, repository, find, where, derived, invariant, unique, function, operation, create, destroy, apply, precondition, requires, emit, let, return, for, in, if, match, else, expect, test, when, extends, abstract, private, extern, audited, mask, unless, true, false, null, this, now, id, Self, and the primitive type names int long decimal money string bool datetime guid json File (date is a primitive type name too, but a SOFT one — a field may still be named date), plus the system-level heads — system, subdomain, deployable, storage, api, ui, component, theme, user, auth, policy, projection, workflow, capability, seed, criterion, domainService, commandHandler, queryHandler, channel, channelSource, timerSource, requirement, solution, testCase, layout, import). None of these can name a field, and none can name a declaration either — command File { … } is a parse error for the same reason File: string is. Both now say so: a keyword in a name position reports 'File' is a Loom keyword, so it cannot be used as a name here, rather than Chevrotain's Expecting token of type 'ID' (audit #2864). The wording says "here" because most of Loom's keywords are soft — page is a field name but not a derived name — so the bar is positional, not global. A handful (api, ui, component, policy, id, contains, permissions, create, destroy) are nonetheless admitted as parameter / argument names or bare expression refs by the per-rule extras described next.

Everything else that acts as a keyword somewhere is a soft keyword — reserved only where its own rule begins, and admitted as an ordinary identifier elsewhere. The grammar factors the shared set into one rule, CommonSoftKeywords (state, kind, payload, command, query, response, error, paged, envelope, option, or, money, parent, title, body, sort, select, join, key, group, filter, stamp, store, schema, slot, ttl, use, write, migration, tenancy, immutable / managed / token / internal / secret, …), composed into every value position: a field name (Property.name), a parameter / argument / clause name (LooseName), a bare reference in an expression (NameRefIdent), an assignment target (LValueIdent) and a member name after .. Each of those rules adds a few position-specific extras — for example await is admissible as a field / parameter name but not as a bare expression ref (it is the match await marker there); api, route, component, menu, section, link, targets, framework, design, ui, page … are admissible as parameter / argument names and expression refs but — apart from page — not as field names (aggregate Order { route: string } is a parse error). of, allow, deny, local, deep, global, policy and persistence were soft only as parameter / clause names until audit finding D4; they are now in the shared set, so aggregate Claim { policy: Policy id } parses. slot joined them for #2864: its only hard position is the component element-param type, exactly the shape money and action already had, so valueobject Berth { slot: int } parses now. The source of truth is the rule set in src/language/ddd.langium, pinned by test/language/parsing/keyword-identifier-completeness.test.ts and test/language/parsing/reserved-field-name-widening.test.ts.

context Orders {
  aggregate Order {
    state: string        // page-DSL `state {}` keyword — soft as a field
    kind: string         // resource clause key — soft as a field
    payload: string      // context-level `payload` head — soft as a field
    parent: Order id?    // requirement-hierarchy keyword — soft as a field
    money: int           // even the primitive-type name is soft as a name
    key: string          // `channel`'s partition-field clause — soft as a field
    write: int           // the policy verb — soft as a field
  }
}

Top-level declarations

A file is one or more bounded contexts (legacy, single-deployable mode) or one or more systems (deployment-plan mode):

// Legacy: bare context — generates a single project of the platform
// chosen at the CLI (`generate ts` / `generate dotnet`).
context Sales {
    // declarations...
}

// System: groups subdomains and deployables.  `generate system` emits
// every deployable as its own project plus a docker-compose.yml.
system Acme {
    subdomain Catalog { context Products { … } }
    subdomain Sales   { context Orders   { … } }
    storage primary { type: postgres }
    resource productsState { for: Products, kind: state, use: primary }
    resource ordersState   { for: Orders,   kind: state, use: primary }
    deployable api {
        platform: dotnet, contexts: [Products, Orders],
        dataSources: [productsState, ordersState], port: 8080
    }
    deployable web {
        platform: node, contexts: [Products],
        dataSources: [productsState], port: 3000
    }
}

The two forms can coexist in one file but typically you'd use one or the other.

What may sit at the file root (grammar ModelMember): system, subdomain, context, the ambient shared-kernel types (valueobject, enum, payload-family records, component, expression-form function, capability), a migration "…" { … } ledger block, the traceability declarations (requirement / solution / testCase), a unit test "…" for <Subject> { … }, and every deployment-shape member (deployable, storage, resource, channelSource, ui, layout, theme, user, auth, api, test e2e) — root-level deployment members compose into the project's single system, so deployment can live in its own file.

Multi-file projects: import and root-level shared types

A project may be split across multiple .ddd files. An entry file (conventionally main.ddd) declares per-file path-based imports; the project loader walks the import graph transitively from the entry file and treats every reachable document as one project.

// main.ddd
import "./shared/money.ddd"
import "./orders.ddd"

system Shop {
    subdomain Sales { context Orders { … } }
    storage primary { type: postgres }
    resource ordersState { for: Orders, kind: state, use: primary }
    deployable api {
        platform: node, contexts: [Orders], dataSources: [ordersState]
    }
}
// shared/money.ddd — declared at model root, ambient across files.
valueobject Money {
    amount: decimal
    currency: string
}

enum Currency { USD, EUR, GBP }
// orders.ddd
context Orders {
    aggregate Order {
        total: Money            // root-level Money resolves here
        currency: Currency
    }
}

Rules:

See tools.md for the CLI side; the original design rationale (stages, work items, deferred cross-context features) is preserved at plans/multi-file-source.md.

Inside a system

Form Purpose
subdomain Name { … } Groups one or more bounded contexts under a name. A subdomain is a logical unit; it doesn't directly produce code. Was named module before the D-STORAGE-SPLIT rename.
deployable name { platform: dotnet|node|elixir|python|java, contexts: [A, B], dataSources: [X, Y], port: N, … } A backend deployable: one project, one HTTP server, one DbContext, listening on port. contexts: names which bounded contexts this deployable hosts; dataSources: lists the system-scope resource decls that route those contexts' persistence (every hosted aggregate must have a matching binding — see the resource row below; the clause keyword stays dataSources: for compatibility). Further clauses, all order-independent: serves: <Api> (which api contracts it exposes), channels: [<channelSource>, …] (broker bindings — channels.md), ui: <Ui> / ui: <Ui> { Param: <deployable>, … } (mount a UI on a fullstack host — elixir renders it as HEEx, dotnet / java embed a SPA), hosts: [...], auth: required (JWT-decode middleware + verifier seam — auth.md), design: <pack>, favicon: "…". platform: accepts a bareword or a quoted "family@version" pin ("node@v5"), optionally followed by a realization block { persistence: …, directoryLayout: … } — the only two axes that remain (see Deployable platforms).
deployable name { platform: react|vue|svelte|angular|feliz|flutter, targets: <backend>, ui: <Ui>, port: N } A frontend deployable. targets: names the backend whose API base URL it is wired to and whose hosted contexts it inherits; a ui: binding is required (every page flows through the page metamodel — ui Web with scaffold(subdomains: [...]) { } is the bulk-CRUD shape). The four static-bundle hosts (react / vue / svelte / angular) render a ui of any of those four framework:s; feliz (F#/Fable/Elmish) and flutter (Dart/Riverpod) host only their own. Optional auth: ui mounts the login redirect + route guard under a system auth { … }.
context Name { … } Allowed directly inside a system; treated as if it were in an implicit _default subdomain.
test e2e "name" against <deployable> [verifies <TestCase>] { … } End-to-end test that runs against the named deployable — HTTP (vitest + fetch) for a backend, Playwright page objects for a frontend; lowers to <system>/e2e/<System>.e2e.test.ts / <frontend>/e2e/<System>.ui.spec.ts. See End-to-end tests.
user { id: string, role: string, … } System-wide JWT-claim shape decoded by the verifier hook. At most one per system; required when any deployable opts in via auth: required and by an auth { … } block (loom.auth-without-user). The currentUser magic identifier in operation / workflow / find / projection expressions is typed against this shape. See auth.md.
auth { provider: …, oidc { issuer: …, clientId: … }, sessions: cookie|jwt, claims: { … }, enforcement: denyByDefault|opt } System-wide OIDC configuration: who issues the token and how its claims map onto the user { … } shape. At most one per system; needs a user block. Generates the token verifier + /auth/* handshake (PKCE, refresh rotation). enforcement: defaults to denyByDefault (every client-reachable command and declared read on an auth: required deployable needs a requires gate); enforcement: opt is the explicit opt-out — the default until M-T3.1, see migrations.md. See auth.md.
tenancy by user.<claim> of <RegistryAggregate> Multi-tenant partitioning: names the claim that partitions data and the aggregate that is the tenant registry. Pairs with the tenantOwned / tenantRegistry capabilities, the per-aggregate crossTenant marker and the policy { allow deep|global … } ladder; every aggregate must take an explicit stance (loom.tenancy-stance-unmarked). Under a hierarchy, backend code may read the ambient organizationContext.orgPath — the org the request operates in: the principal's own orgPath, or a validated x-org-context switch into a descendant org (every backend's auth middleware refuses anything else with a pre-body 403). orgPath is its only member and it is backend-only (loom.org-context-surface); a read needs the hierarchy and auth: required on its hosting deployables (loom.org-context-gate-unmet). See tenancy.md and its organizationContext section.
theme { primary: "#…", radius: "md", … } System-wide visual identity — design tokens consumed by the react / vue / svelte / angular frontends and the Phoenix LiveView shell (feliz and flutter render their own toolkit defaults and ignore it). At most one per system. Colour properties (primary, secondary, accent, success, warning, error, neutral) accept CSS hex values (#RGB / #RRGGBB / #RRGGBBAA). radius is one of none / sm / md / lg / xl. fontFamily and fontFamilyMono are free-form strings. colorScheme is light / dark / auto. Unknown property names and invalid values are validator errors.
api Name [with …] from <Subdomain> [{ urlStyle: literal|resource, <statuses>, <routes> }] First-class API contract derived from a subdomain's domain (aggregates expose all / byId, plus whatever lifecycle they DECLARE — create / update / delete appear only for an aggregate that declares an unnamed create / destroy, by hand or via with crudish; an aggregate with none exposes reads and its operations only, with no diagnostic, because being constructed solely by a workflow is a legitimate design. See Identity and X id and the create row in Aggregate / entity-part members). Repositories expose their finds, workflows expose mutations. Backend deployables serves: an api; UIs reference one via api X: <ApiName> parameters; the optional body adds httpStatus <Error> -> <code> mappings and hand-written routes (route GET|POST|PUT|PATCH|DELETE "/path" -> <handler>) over commandHandler / queryHandler declarations. See architecture.md.
storage Name { type: <sourceType>, connection: env("…")|service(x)|secret(x)|literal("…"), config: { k: v } } Typed physical store / service reusable across deployables. type: names the built-in sourceType: relational postgres / mysql / sqlite / inMemory, redis, kafka, object stores s3 / localDisk, queue rabbitmq, restApi, mailers smtp / ses / sendgrid — these bind to a resource kind: and activate dev-compose sidecars + client emission per the kind × sourceType matrix in resources.md (postgres is the fully-supported state store). Five further literals parse but bind to nothing: elastic / meilisearch (search), clickhouse / bigquery (analytics) and nats — there is no search or analytics kind, and queue admits rabbitmq only, so a resource over one of these is refused by the resource kind ↔ sourceType check (an uncoded error from src/language/validators/datasource.ts) and nothing is emitted (resources.md).
resource Name { for: <Ctx>, kind: <k>, use: <storage>|<api>, … } The configured binding (renamed from dataSource) from a bounded context's data of kind state / eventLog / snapshot / cache / replica / objectStore / queue / api / mailer to a physical storage (or, for kind: api, a sibling api served in the same system — a typed client). Optional knobs: schema, tablePrefix, keyPrefix, ttl, every, retain, isolationLevel, readonly, shape, index: [Entity.col, Entity.(a, b)], config { … }. Every backend deployable hosting an aggregate must list a matching resource under its dataSources: field. See resources.md for the full model (sourceTypes, kinds, capabilities, interfaces) and workflow-level consumption.
channelSource Name { for: <channel>, use: <storage> } / timerSource Name { for: <Event>, cron: "…" | every: 15s, in: "<tz>", overlap: allow } System-scope transports: a channelSource binds a context channel to a broker storage (redis / rabbitmq / kafka) and is attached to deployables via channels: (channels.md); a timerSource is a cron / cadence tick that raises the named event into a context (loom.timer-*; the target aggregate must be state-based — loom.timer-needs-state).
ui Name [with scaffold(...)] { framework: react|vue|svelte|angular|feliz|flutter|phoenixLiveView, … } Block of pages, components, stores, areas, menu, and api / channel parameters that a deployable binds via ui:. See page-metamodel.md.
layout Name { … } A reusable page shell (header / sidebar / footer slots) a ui renders through. See page-metamodel.md.
capability Name { … } A pure mixin (fields — which may be typed Self id — plus filter / stamp contributions) applied to aggregates via with Name / implements Name; also declarable at file root. See capabilities.md.
function name(params): T = Expr An ambient expression-form helper — same rule as at file root; see Top-level helper functions.

A subdomain (and the bounded contexts it groups) may appear in any number of deployables — its code is inlined into each. For v1 there is no shared-library / npm-workspace shape; duplication is the trade-off for simplicity.

Cross-context type references (X id, value-object usage, enum values) resolve across context and subdomain boundaries. A deployable need not host the context that DECLARES a value object or enum: when a hosted declaration (an aggregate or part field, an operation / find / workflow / handler parameter, a hosted value object's or event's field, …) names a value object or enum declared in a sibling context the deployable does not host, every backend declares that type itself — only the ones the hosted surface reaches, transitively through value-object fields. The same holds for an X id named by a workflow parameter or state field or a broker-consumed foreign event; an X id field on a hosted aggregate whose target X is not hosted is not yet covered, so host X's context alongside it. The Langium scope provider exports all named declarations — aggregates, entity parts, value objects, enums — across subdomain / context boundaries within the same source file.

A subdomain body may also include one or more permissions { ... } blocks declaring typed permission identifiers used in operation / workflow expression bodies — optionally with an implies closure (admin implies [read, write]; loom.permission-implies-*). The permissions.<name> magic identifier lowers to the runtime string <lowercase-subdomain>.<name>. The block is subdomain-local, and the qualifier is part of the emitted string — so declaring the same permission name in two subdomains mints two DIFFERENT runtime permissions (shipping.routeCargo and operations.routeCargo), both of which the identity provider must then grant. See auth.md.

Deployable platforms

platform: Stack
dotnet ASP.NET Core + EF Core + Mediator (martinothamar) + Swashbuckle. Default port 8080.
node Hono + Drizzle ORM + Zod with @hono/zod-openapi (bareword resolves to the hono@v5 package, the only registered version; node@v4 (zod 3 / TS 5) was retired, and pinning it is a validation error naming node@v5). Default port 3000.
elixir Phoenix + plain Ecto (the Ash foundation was removed). A fullstack host: mounts a framework: phoenixLiveView ui as HEEx (coreComponents / daisyui packs). The old platform: phoenix / phoenixLiveView aliases were retired — elixir is the only spelling. Default port 4000.
python FastAPI + SQLAlchemy 2 + Pydantic. Default port 8000.
java Spring Boot + Spring Data JPA + Hibernate. Default port 8081.
react Vite + React Router + React Query + Zod + Playwright page objects; design packs mantine (default) / shadcn / mui / chakra. Default port 3001.
vue Vite + vue-router + vue-query + Zod (vuetify / shadcnVue). Default port 3002.
svelte Svelte 5 / SvelteKit static SPA + svelte-query + Zod (shadcnSvelte / flowbite). Default port 3003.
angular Angular SPA (angularMaterial / primeng / spartanNg). Default port 3004.
feliz F# / Fable / Elmish (MVU) SPA built by dotnet fable + vite, daisyUI styling. Hosts only framework: feliz. Default port 3005.
flutter Dart / Flutter + Riverpod; the web bundle is served by compose, and the same Dart source builds native Android / iOS via the project Makefile. Hosts only framework: flutter. Default port 3006.
static A UI-only static host — lowers through the react path.

Backend deployables (dotnet, node, elixir, python, java) declare contexts: [...] (which bounded contexts they host) and dataSources: [...] (the system-scope resource decls that route those contexts' persistence). Frontend deployables (react, vue, svelte, angular, feliz, flutter) declare targets: <backend-deployable> instead — the frontend's API base URL is wired to the target's port and its hosted contexts are inherited from the target so pages exactly cover the API surface.

The optional realization block after a backend platform: carries exactly two axes — persistence: (e.g. efcore / dapper on dotnet, drizzle / mikroorm on node) and directoryLayout:. The former foundation: / application: / transport: / runtime: clauses were removed and no longer parse.

deployable api {
    platform: dotnet { persistence: dapper }
    contexts: [Orders], dataSources: [ordersState], port: 8080
}

See platforms.md for the registry and adapter menus, resources.md for the storage/resource model, and generators.md for what each platform emits per aggregate.

Inside a context

Inside a context, the following kinds of declarations may appear, in any order:

Form Purpose
enum Name { A, B, C } Closed enumeration; values are referenced bare.
valueobject Name { … } Immutable record with optional invariants and derived members.
[abstract] aggregate Name [extends Base] [persistedAs: eventLog|state] [shape: relational|embedded|document] [inheritanceUsing: sharedTable|ownTable] [crossTenant] [audited] [with Cap, Macro(...)] { … } Aggregate root with implicit Name id field (always a guid — there is no ids clause; see the identity note below). abstract leads (what the declaration is); the header region after the name is order-independent (how it participates): persistedAs: … picks the primary truth kind (default state); shape: … picks the saving shape (default relational) — relational = table-per-entity; embedded = queryable root row + contained parts folded into one JSONB column; document = the whole aggregate as one opaque JSONB blob (id, data, version), emitted on all five backends (a shape: a backend can't emit is a validation error); inheritanceUsing: picks TPH vs TPC (below); crossTenant opts shared-reference data out of the tenant filter under a tenancy by system (tenancy.md); audited records an audit_records row for every public command (the aggregate-wide form of the per-command audited flag). A trailing with … clause mixes in capabilities and macros (capabilities.md, scaffold-macros.md).
event Name { field: Type, … } Flat record raised via emit.
payload Name { … } / command / query / response / error / payload U = A | B Transport records and discriminated unions — the workflow / handler command surface and or-union return arms. See payloads.md.
repository Name for Aggregate { find … } Repository declaration with optional find queries (see Repositories).
criterion Name(params) of Aggregate = Expr / retrieval Name of Aggregate { where: Expr sort: [field desc, other asc] } Reusable, SQL-inlinable predicate specifications and the named list-read shapes Repo.run(...) consumes. A retrieval also has a one-line form, retrieval Name of Aggregate = Expr. The block's clauses are NOT comma-separated — where: / sort: follow one another directly (a newline between them is the readable spelling); only the items inside sort: [...] take commas. The grammar also admits a loads: [path, …] clause, but declaring one is rejected today (loom.retrieval-loads-unsupported — a retrieval loads the whole aggregate). See criterion.md.
projection Name[(params)] [keyed by <field>] [requires Expr] { <fields> from <Source> [as x] [where …] [ignoring …] [join Agg as a on …] [group by …] select f = …, … } / projection Name keyed by k { <fields> on(e: Event) [by e.key] { … } } A read model: query-time (a select over an aggregate / projection / workflow source — optional where, join, group by, whole-table aggregation, paged reads) or folded (on(e: Event) arms folding a stream into a keyed row). Exposed as GET /projections/<name> and readable from pages. ~50 loom.projection-* gates; see language-reference/10-repositories-and-queries.md and scaffold-macros.md (scaffoldDashboard).
workflow Name { create(...)[: T] …, on(e: Event) …, function … } Context-level orchestration across aggregates, with create starters, on reactors and workflow-local state (a named command entry is a context-level commandHandler — there is no workflow handle). A command-triggered create(…): T { … return <expr> } answers its POST with 200 + the value (an X id / string / int / long / bool); without : T it stays 204. See workflow.md.
[extern] commandHandler Name(cmd: Cmd)[: Response] { … } / [extern] queryHandler Name(q: Query): Response { … } Application-layer handlers — a single-aggregate handle lifted out of a workflow (load → mutate → save → return), routable from an api { … } body. queryHandler must not save (loom.query-handler-saves); commandHandler touches one aggregate (loom.command-handler-multi-aggregate). An extern handler is bodyless (;) — see extern.md.
domainService Name { operation calc(...): T { … } } A stateless, pure cross-aggregate calculator. See domain-services.md.
channel Name { carries: EventA, EventB, delivery: broadcast|queue, retention: ephemeral|log|work, key: field } Publisher-side contract for how a context's events are transported (paired with a system channelSource). carries: takes a bare comma-separated event list — no [ ] (unlike contexts: / dataSources: on a deployable, which do take brackets). See channels.md.
seed [dataset] [raw] { Aggregate { field: value, … } … } Declarative first-boot rows (one Aggregate { … } object literal per row; raw writes table-level inserts). loom.seed-*. See language-reference/23-domain-services-and-seeds.md.
filter … / stamp … / implements Cap Context-level capability contributions (a query filter, a write stamp, a typed capability application) propagated to every aggregate in the context. See capabilities.md.
test "name" for <Aggregate|ValueObject|DomainService|Context> { … } A unit test hoisted beside its subject (for names the home; a test nested inside its subject needs no for — loom.test-redundant-for / loom.test-needs-target).
policy [Name] { allow [write] local|deep|global on Aggregate … } Read/write-scope ladder for tenantOwned aggregates under a tenant hierarchy — widens the tenant floor to the caller's org subtree (deep) or root-org subtree (global); the optional write verb gates instance mutations. The name is optional; one rule per aggregate. See tenancy.md → "The policy {} read ladder".
policy [Name] { deny [write] on Aggregate … } Deny-wins carve-out (Phase 4): removes access to an aggregate. deny on X denies READ (X becomes invisible → empty / 404; writes fail too since the write load reuses the read filter); deny write on X denies WRITE only (reads stay, mutations 404). All-or-nothing at the aggregate (no level word); applied after the allow passes, so deny wins. Not restricted to tenantOwned. Diagnostics: loom.policy-deny-unknown-aggregate, loom.policy-deny-duplicate, loom.policy-deny-shadows-allow (warning). See auth.md → "Deny carve-outs".
policy [Name] { read Aggregate requires Expr … } Read gate: who may read the aggregate at all — every read of it (list, by-id, each find's route, audit history, the scaffolded List page) answers 403 before the load unless Expr holds. currentUser and constants only. Diagnostics: loom.policy-read-gate-not-current-user, loom.policy-duplicate-target, loom.policy-unknown-aggregate. See auth.md → "Read gates".
policy Name(params): bool ( = Expr | { Expr } ) Named policy function (P3.2): a reusable boolean authorization predicate (sees currentUser + its own parameters). It is context-level, not ambient: a policy declared in one context does not resolve from another (a call there reports loom.policy-out-of-scope: policy X is declared in context A; policies are context-local — redeclare it in B), so a gate shared across contexts is declared in each, referenced from a requires PolicyName(args) gate and inlined there like a criterion … of bool. Parentheses are required (they distinguish it from the policy {} block form). See auth.md → "Named policy functions".

Identity and X id

aggregate Order { … } implicitly declares an identity field id of type Order id. Likewise each entity Foo { … } declared inside an aggregate implicitly has an id: Foo id plus an implicit parent reference.

Cross-aggregate references are written as Other id:

customerId: Customer id

The underlying value type is always guid, and there is no ids clause — ids int|long|string were removed (no backend implemented id generation for a non-guid primary key, so declaring one produced an app that collided on the second insert), and the no-op ids guid spelling was removed with the rest of the header normalization (M-T5.17). aggregate Order ids guid { … } is a parse error; write aggregate Order { … }. See docs/old/plans/non-guid-id-http-params.md.

Reference collections — X id[]

A field typed as a collection of references to another aggregate is a many-to-many relation:

aggregate Trainer {
  party:  Pokemon id[]
  caught: Pokemon id[]
}

No grammar keyword switches it on — any aggregate field whose type is X id[] is a reference collection. Semantically it is a set of references: the same target appears at most once per owner (the join table's composite (owner_id, target_id) primary key enforces this), and no position is stored — the join table has no ordinal column. Every backend reads the list back sorted by target id, so the order is stable across reads and backends, but it is an id order, not one the domain chose: a += lands wherever its id sorts. If a position is part of the domain (e.g. a battle slot number where slot 1 attacks first), model it as a separate ordinal field on a dedicated child aggregate rather than relying on list order.

Mutate the collection from operations with += / -=:

operation addToParty(pokemon: Pokemon id) {
  precondition party.count < 6
  party += pokemon
}

Membership is queryable from a repository find ... where (see Repositories below).

Reference collections are not the same as containment. contains lines: OrderLine[] (or, equivalently, the contains-less lines: OrderLine[] — see below) declares entity parts that live and die with the parent — a child table joined on parent_id. X id[] is a list of references to a different aggregate that outlives any one owner — persisted as a separate join table when the backend supports it (see docs/generators.md).

Aggregate inheritance

An aggregate may extend a shared base so subtypes carry a common field set and can be queried polymorphically:

Form Notes
abstract aggregate <Name> { … } A base that is never instantiated: no table / repository / routes of its own. May declare fields and derived getters; may not declare create / operation behaviour or a repository.
aggregate <X> extends <Base> { … } A concrete subtype. <Base> must be an abstract aggregate in the same context. Inherits the base's fields (merged into the wire shape ahead of its own; an own field shadows a like-named base field).
inheritanceUsing: sharedTable|ownTable Header modifier (on the base, optionally per concrete) choosing the table-mapping strategy. Default sharedTable (TPH). ownTable (TPC) emits a standalone table per concrete.

find all <Base> returns the polymorphic union of all subtypes via a per-backend reader. TPC (ownTable) is emitted on every backend; TPH (sharedTable) is emitted on all five backends. See inheritance.md for the per-backend emission, the validation rules, and the deferred patterns.

Aggregate / entity-part members

Inside an aggregate or an entity part:

Form Notes
name: TypeRef [provenanced] [sensitive(tags)] [access] [= default] [check Expr [message "…"]] [mask unless Expr] Property, with optional modifiers. provenanced, sensitive(...), and access parse in any order relative to each other; = default, check, and mask unless must come after all three, in that order (an unconsumed flag keyword after an in-progress default risks the expression greedily swallowing it — access's keywords double as valid identifiers). provenanced records assignment lineage (below); sensitive(...) tags the field for log-redaction / inspect; access is one of immutable / managed / token / internal / secret (default: editable — see Field access modifiers below); check Expr is a per-field validation predicate (optional author-written message); mask unless <currentUser-predicate> redacts the field to null on the wire unless the caller satisfies the predicate (loom.field-mask-*; see auth.md → "Field masking").
contains name: PartName[] Containment of a part declared within the same aggregate; collection.
contains name: PartName Containment, single (required).
contains name: PartName? Containment, single (optional) — the part may be absent at runtime; serialised as a nullable wire field. []? is rejected: an empty collection already encodes absence.
name: PartName[] / name: PartName / name: PartName? contains is optional. A field whose type is a locally-declared entity part is a containment — lines: OrderLine[] means exactly what contains lines: OrderLine[] means (an entity part is owned by its root, never held by value). The keyword stays valid for readers who want the composition boundary spelled out; both spellings lower identically. Only what contains carries applies — a name, [], and ?; the value-property modifiers (provenanced / access / = default / sensitive(...) / check) are rejected on an entity-typed field (loom.entity-field-modifier). X id remains required for cross-aggregate references — id is a type constructor (the field is a key, not the entity), which is why it is never inferred.
derived name: TypeRef = Expression Computed read-only property.
derived display: string = Expression Reserved — declares the aggregate's user-facing label. When present, string(aggregate) and implicit "x " + aggregate compile to a member access on this derived; React Select pickers use it for option text. Without it, those expressions are compile errors.
derived inspect: string = Expression Reserved — declares the aggregate's developer-facing debug form. Auto-generated when omitted (structural form, sensitive fields shown as <redacted>). Backends emit it as ToString() / [util.inspect.custom] / Inspect so debugger watches, exceptions, and logger output get a useful representation.
[private] invariant Expression [when Expression] [message "…"] bool predicate; checked after every mutation. Optional when is a guard; message is the user-facing text (also the i18n key). private keeps the rule off the wire-layer schemas (Zod / FluentValidation / OpenAPI) — it runs only in the domain floor.
unique (a, b) Set-level natural-key invariant — derived into a DB unique index (partial under softDeletable) plus a per-backend 23505 → 409 mapping (loom.unique-*). A find that returns a single row (T / T?) by equality on fields no unique (…) covers gets the advisory loom.single-row-find-not-unique — with duplicates, every backend would return whichever row the database yields first.
function name(params): TypeRef = Expression Pure helper (expression form); callable from any expression in the same aggregate. Stays SQL-inlinable like a criterion.
function name(params): TypeRef { … } Pure helper (block form); let + branch (ternary/match, or a statement if/else) + bug-regime precondition/requires, returning a value on every path (loom.function-block-no-return — a return inside an if counts, but an if with no else leaves one path valueless). Still pure — no mutation, no emit, no repository / operation / domain-service / extern call (the IR validator rejects each). Not queryable (a block-form call is rejected in a where / criterion filter).
[private] operation name(params) [extern] [audited] [: ReturnType] [requires Expr] [when Expr] { … } Mutating method (root only). private = callable only from within the same aggregate root (no route). audited records an audit_records row per call. : A or B declares an exception-less outcome returned via return (an error variant maps to a ProblemDetails status — payloads.md). requires is the authorization gate (403; auth.md).
operation name(params) extern { precondition … } Public op whose business decision lives in user code; body must contain only precondition statements. See extern.md.
operation name(params) when <pred> { … } canCommand state gate (criterion.md, use site 2): <pred> is a pure bool predicate over the aggregate's own state — referencing an operation parameter is an error (move argument-aware checks into a precondition); evaluated against the loaded instance before the body. False → 409 "Disallowed" ProblemDetails; a side-effect-free GET /{id}/can_<op> companion returns { allowed } for UI enablement (so when on a private operation is rejected — nothing could read it). Named criteria / aggregate functions inline like any bool position. Supported on all five backends. Distinct from requires (auth, 403) and precondition (domain validity, 422).
create [name](params) [audited] [{ … }] Lifecycle factory — the unnamed form is the aggregate's canonical creator, and its parameter list IS the POST /<plural> request contract (a field it leaves out takes its declared default; the empty create() keeps the full field-derived input). The body is optional: create(title: string) is the same declaration as create(title: string) { } (likewise a workflow starter). On a state-based aggregate the body runs against the constructed instance like an operation body: a requires gates the route (403), a precondition refuses with the domain-floor 422, an emit publishes on save, and a let binds a value either reads. Any other statement (a computed assignment, +=, a call) is refused by loom.lifecycle-body-dropped. On a persistedAs: eventLog aggregate the create emits the creation event and the applier folds it. See language-reference/06-behavior-and-statements.md.
destroy [name][(params)] [audited] { … } Lifecycle terminator — loaded by id, the body runs (a throw aborts removal), then the framework removes the row; the unnamed destroy { } is the canonical DELETE.
apply(e: <Event>) { … } Event-sourcing fold (only on a persistedAs: eventLog aggregate). Folds one emitted event type into state — a pure transition: assignments / collection mutations / let only, no emit, no side-effecting calls, no guards (loom.applier-impure). One apply per event type. See the event-sourcing note below.
filter … / stamp … / implements Cap Aggregate-level capability contributions — a read-side query filter, a write-side stamp, a typed capability application. See capabilities.md.
entity Name { … } Nested part declaration (inside an aggregate).
test "name" { … } Unit test block nested in the aggregate; also declarable beside it as test "…" for <Aggregate> (root, context, or a tests/*.ddd file — which must be imported from the entry file). Lowers per backend — see Tests.

Entity parts may declare only contains, properties, derived, invariant, and function — no operation / create / destroy / apply / unique / test (those live on the root). A valueobject admits properties, derived, invariant, function, and nested test blocks.

Constructing values vs. aggregates

Two things you declare are instantiated two different ways, and mixing them up is the first off-happy-path surprise:

You built a… Construct it with Why
valueobject / entity part Money { amount: 1, currency: "USD" } — a brace literal A plain immutable record; the { … } is the whole value.
aggregate root Order.create({ … }) — the create factory An aggregate has identity + invariants; construction must run the create action so those are enforced and the create wire shape (not the raw field set) is honoured.
test "build them" {
  let m = Money { amount: 5, currency: "USD" }      // value object — brace literal
  let o = Order.create({ customerId: "c1", total: m })  // aggregate — factory call
}

Writing Order { … } in an expression position is an error — since #2005 the diagnostic points you at Order.create({ … }) rather than the older generic "unknown builder type". The factory input follows the create-input column of the access-modifier matrix: a managed field like createdAt is off it, so Order.create({ createdAt: … }) is rejected.

Comparing value objects

Two value objects are equal when their fields are. a == b and a.equals(b) are the same comparison — equals is an intrinsic every value object answers (stdlib.md), lowered to == and rendered as each backend's value equality:

function isAt(pier: string, position: int): bool = berth.equals(Berth { pier: pier, position: position })
public isAt(pier: string, position: number): boolean { return (this._berth.equals(new Berth(pier, position))); }
def is_at(self, pier: str, position: int) -> bool:
    return (self._berth == Berth(pier, position))

Java renders Objects.equals(this.berth, new Berth(pier, position)), .NET this.Berth == new Berth(pier, position) (record equality), Elixir Loom.ValueObject.equal?(record.berth, %{pier: pier, position: position}) — value equality across the shapes an Elixir value object takes (a column value, a constructor map, a collection-element struct; decimals by value, not scale).

The same holds in a ui page expression. A page-side value object on the JS frontends is the plain JSON the wire delivered, so === would compare references; the comparison renders through a loomValueEquals helper emitted into the file that uses it (Flutter: lib/value_equals.dart):

Column { "Ship", o => Text { o.shipTo == o.billTo ? "same" : "split" } }
<Text>{(loomValueEquals(row.shipTo, row.billTo) ? "same" : "split")}</Text>

Feliz renders F#'s structural record = (an optional/plain pair compares as row.alt = (Some row.shipTo)), HEEx Elixir's structural ==. A comparison with null stays a presence test on every frontend.

Event sourcing — persistedAs: eventLog + apply(...)

An aggregate marked persistedAs: eventLog in its header is event-sourced: its truth is an append-only event stream, and its state is a fold of that stream. The body contract differs from a state-based aggregate, and the compiler enforces it (in the IR validator and live in the editor):

event Opened { account: Account id, owner: string }
event Deposited { account: Account id, amount: int }

aggregate Account persistedAs: eventLog {
  owner: string
  balance: int
  create open(owner: string) {
    emit Opened { account: id, owner: owner }   // construct via creation event
  }
  operation deposit(amount: int) {
    precondition amount > 0
    emit Deposited { account: id, amount: amount }   // decide + emit
  }
  apply(e: Opened) { owner := e.owner  balance := 0 }   // fold (initialises)
  apply(e: Deposited) { balance := balance + e.amount }   // fold
}
workflow withdrawTwice transactional {
  create(a: Account id, amount: int) {
    let first = Accounts.getById(a)
    let second = Accounts.getById(a)
    first.withdraw(amount)
    second.withdraw(amount)   // stale fold → 409, neither withdrawal kept
  }
}
// node — AccountRepository.save (excerpt)
let version = aggregate._streamVersion;          // the version this handle folded
const rows = pending.map((event) => ({ …, version: ++version, … }));
try { await this.db.insert(schema.ledgerEvents).values(rows); }
catch (err) { if (/* 23505 */) throw new ConcurrencyError("Account", aggregate.id); throw err; }
aggregate._streamVersion = version;

Storage emission ships on node, .NET, Python, Java, and Phoenix (plain Ecto/Phoenix): an event-sourced aggregate persists to an append-only <agg>_events table, constructs and mutates through emitted events, and rehydrates by folding the stream on load. See generators.md for the per-backend matrix and docs/old/proposals/workflow-and-applier.md for the roadmap.

Provenanced fields

Mark a stored field provenanced to capture the lineage of every value it holds:

aggregate Order {
  total: int provenanced
  operation reprice(qty: int, price: int) {
    total := qty * price - discount   // write-site #1
  }
  operation applyDiscount(amount: int) {
    total := quantity * unitPrice - amount   // write-site #2
  }
}

Each distinct assignment site (:=, +=, -=) to a provenanced field is a rule snapshot — the RHS expression captured both as source text and as the resolved IR. Snapshots are content-addressed by a snapshotId; identical expressions at different sites collapse to one snapshot.

The capture is an explicit, separate step from code generation:

ddd snapshot path/to/system.ddd -o out
# → out/.loom/snapshots/<ts>-<guid>.loomsnap.json  (one entry per write-site)

The TypeScript/Hono backend additionally emits a domain/provenance.ts runtime SDK and a recordTrace(...) call after each write, so a value can be traced back to the snapshot that produced it at runtime. The provenance runtime (co-located lineage column, per-write trace capture, transactional provenance_records flush) is emitted on all five backends — node, .NET, Java, Python, and elixir (plain Ecto/Phoenix); the frontends render a ? disclosure over it (ProvenanceInfo). See provenance.md, examples/provenance.ddd for a runnable backend example and the Provenance System playground example for the same domain as a Hono + React system.

Field access modifiers

Every property gets an access modifier that governs how it participates in input DTOs, the update wire shape, and API read exposure. The grammar form is

name: TypeRef [provenanced] [sensitive(...)] [immutable|managed|token|internal|secret]

provenanced and sensitive(...) may appear in any order around the access modifier — name: TypeRef managed provenanced sensitive(pii) parses the same as the canonical order above.

The default — no keyword — is editable. The five keywords (and the implicit editable) form this matrix:

Modifier Client read In create(...) input In update(...) input In UI-read payloads
editable (default) ✓ ✓ ✓ ✓
immutable ✓ ✓ ✗ (server rejects) — wire only; a domain operation may still assign it ✓
managed ✓ ✗ (server owns it) ✗ ✓
token ✓ ✗ ✗ body — sent as an optimistic-concurrency precondition (like id/version) ✓
internal ✗ (never exposed via API) ✗ ✗ ✓ (the UI may read it)
secret ✗ (never disclosed) ✓ ✓ (write-only) ✗

This table is not prose that can drift from behaviour — each column is a projection function in src/ir/enrich/wire-projection.ts that every backend shares: Client read = forApiRead, create input = forCreateInput, update input = forUpdateInput (with token fields split out by forPreconditionInput), UI-read payloads = forUiRead. If a generated create({ … }) / read DTO surprises you, this is the authority.

Common gotcha. Passing a managed field into .create({ … }) (e.g. Task.create({ createdAt: now() })) is rejected — managed is server-owned and off the create input. Likewise a scaffolded/custom page that renders an internal or secret field (row.isDeleted, row.tenantId) won't type-check against the API-read DTO, which omits them. Both are the same rule read off the two ✗ columns above.

A server-owned field with no default starts at its type's seed

A managed / token / internal field is off the create input, so a non-optional one with no = <default> gets its first value from the create factory: the language-defined seed for its type (src/ir/util/server-init-seed.ts, one table every backend reads).

Type Seed
datetime now (UTC)
int / long / decimal 0
bool false
string ""
T[] []
aggregate Ticket {
  title: string
  openedAt: datetime managed       // no default: created at now
  hits: int managed                // created at 0
  create(title: string) { }
}
// Java — Ticket.create(...)
e.openedAt = java.time.Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
e.hits = 0;
# Elixir — Ticket changeset, ahead of validate_required
|> __default(:opened_at, Loom.Datetime.normalize(DateTime.utc_now()))
|> __default(:hits, 0)

The other types have no honest seed: money has no currency, an enum has no first value, a value object or X id has no value to invent, and a guid has no "absent" ("" is not a uuid and the nil uuid is a real value). A non-optional server-owned field of one of these needs a = <default>, a capability stamp, or a ?. Otherwise it is refused with loom.unconstructible-server-field. The corpus/managed-seeds wire golden pins the seeds on all five backends.

immutable constrains the CLIENT UPDATE INPUT, not domain assignment

Every ✗ in the matrix is a wire fact. immutable in particular reads "absent from the update input", not "never changes" — nothing stops a domain operation from assigning an immutable field, on any backend. That makes it the right modifier for a field whose only legitimate writer is a guarded operation:

aggregate Claim with crudish {
  status: ClaimStatus immutable          // off the generic update's input …
  description: string

  operation approve() {
    requires currentUser.permissions.contains(permissions.claimsApprove)
    precondition status == UnderReview
    status := Approved                   // … but this still assigns it
  }
}

The client still reads status, create still seeds it, and only approve() can move it afterwards:

// generated: api/domain/claim.ts  (node — the other four backends are the same shape)
public approve(): void {
  if (!(this._status === ClaimStatus.UnderReview)) throw new DomainError("Precondition failed: status == UnderReview");
  this._status = ClaimStatus.Approved;                 // immutable ≠ unassignable
}

public update(description: string): void {            // `status` is GONE from the update surface
  this._description = description;
}

Without the modifier, status is a writable update field like any other, so POST /claims/{id}/update {"status":"Approved"} sets it at whatever gate the update carries — skipping both the requires on approve() and its precondition. That is a state-machine bypass, not only an authorization one, and with crudish(requires: <Policy>) does not close it: that gate is per-member and identical across create/update/destroy, while the update still writes every field.

The compiler will not decide this for you — a field with no modifier is declared editable, and it never silently makes one non-editable. It does flag the case: an aggregate whose crudish update mass-assigns a field that a gated operation also writes raises the warning loom.update-gate-suggestion (counted among ddd parse's warnings; never an error, never an exit code). An operation gates the field when it carries a requires, a when state gate, or a precondition that reads that same field and no operation parameter (a pure state predicate — what a when could say) — so the plain state machine with no auth at all is caught too:

aggregate Ticket with crudish {
  status: TicketStatus = New          // ⚠ loom.update-gate-suggestion
  operation resolve() when status == Open { status := Resolved }
  operation close() when status == Resolved { status := Closed }
}
loom.update-gate-suggestion Core/Ticket warning: 'Ticket.status' is assigned by 'resolve', which is gated by its 'when' state gate, but 'crudish' also exposes the field on its generic 'update' — a caller can set it there and skip that gate. Mark it 'immutable' …

A field only the generic update writes (title above) is ordinary data and never fires, nor does a precondition over other fields only or one comparing the field with an argument (precondition stockLevel >= qty validates the call's inputs, it does not gate the field's state).

immutable closes the update half; managed closes create too. Every update-writable field is also on the create input, so with immutable a client can still POST /tickets {"status":"Closed"} and start an instance in any state. When the field has a default and every instance must start there, mark it managed instead — off both inputs, initialised from the default, and still assignable by resolve()/close():

  status: TicketStatus managed = New
// generated: api/domain/ticket.ts (node)
public resolve(): void {
  if (!(this._status === TicketStatus.Open)) throw new DisallowedError("operation 'resolve' is not allowed in the current state of Ticket.");
  this._status = TicketStatus.Resolved;      // the gated operation still moves it …
  this._assertInvariants();
}
public update(title: string): void {         // … `status` is off the update input …
  this._title = title;
  this._assertInvariants();
}
static create(input: { title: string }): Ticket {
  return new Ticket({
    id: Ids.newTicketId(),
    title: input.title,
    status: TicketStatus.New,                // … and off create: it starts at the default
    version: 1,
  });
}

The warning says which applies (the managed hint appears for when / precondition gates, where the initial state is the point). See also auth.md → "Guarded state transitions".

Examples:

aggregate User {
  email: string                            // editable (default)
  createdAt: datetime managed              // server stamps it
  passwordHash: string secret              // accepted on create + update; never sent back
  version: int token                       // round-tripped for optimistic concurrency
  isDeleted: bool internal                 // hidden from clients; UI may read
  slug: string immutable                   // off the update input; a domain operation may still assign it
}

The aggregate's synthetic id is hardcoded to token access — it's read-only from the client's perspective but must be echoed on update. X id foreign-key references default to editable (the client supplies them on create) regardless of the target's identity access. Reference-collection fields (T id[]) are persisted via a join table and follow the default.

The macro stdlib uses these modifiers to scope its emissions: auditable declares createdAt/updatedAt as managed, softDeletable declares isDeleted as internal. The writableUpdateFields macro helper consumes the modifier matrix when synthesising crudish's update operation — see scaffold-macros.md.

Sensitivity tags

sensitive(tag1, tag2, ...) marks a property as carrying sensitive data. Tags are free identifiers; nothing in the compiler treats them specially today — they are opaque metadata reserved for external tooling (audit reports, log redaction policies, schema discovery for compliance).

Conventional tag names (not enforced):

Tag Meaning
pii Personally identifiable information (name, email, phone, address).
phi Protected health information (HIPAA-adjacent).
cred Credentials (passwords, API keys, tokens).
audited The field's value lineage should be retained for audit.
aggregate Patient {
  fullName: string sensitive(pii)
  diagnosis: string sensitive(pii, phi)
  ssn: string sensitive(pii, audited) secret    // sensitive + secret access
}

A field's derived inspect output redacts sensitive fields by default — the auto-generated structural form prints <redacted> for any property carrying any sensitivity tag. A user-supplied derived inspect = … is rendered verbatim; the user opts out of redaction by writing their own debug form.

Type references

TypeRef       = TypeAtom ('or' TypeAtom)*                  // anonymous union
TypeAtom      = BaseType GenericCtor* ('[]')? ('?')?
GenericCtor   = 'paged' | 'envelope' | 'option'             // ML-postfix carriers
BaseType      = PrimitiveType | SlotType | ActionType | SelfType | IdType | NamedType
IdType        = Identifier 'id'                // cross-aggregate FK
NamedType     = Identifier                     // bare name
SelfType      = 'Self'                         // the host aggregate, inside a capability
PrimitiveType = 'int' | 'long' | 'decimal' | 'money' | 'string' | 'bool' | 'date' | 'datetime' | 'guid' | 'json' | 'File'
SlotType      = 'slot'                         // element-shaped param marker — UI-only
ActionType    = 'action' ('(' TypeRef ')')?    // callback-shaped param marker — UI-only
// `money("10.50")` — the precise-decimal literal — has no rule of its own: it
// is the STRING-argument arm of `PrimitiveConversion` (`money(x)`), which is
// what tells it apart from the runtime conversion.  Two rules for one `money(`
// prefix is an ambiguity no lookahead can resolve; see
// `docs/language-reference/05-expressions.md` § Money literals vs money(x).

The postfix carriers fold left (string envelope paged is paged(envelope(string))) and bind tighter than or, as do [] / ?: string or int option is string or (int option). A or B and the named payload U = A | B form are the discriminated-union surface (tagged type on the wire) — see payloads.md. File is the uploaded-file type backing FileUpload / FileLink (needs an objectStore resource — loom.file-field-needs-object-storage; see resources.md). Self id is valid only inside a capability body, where it stands for a reference to the host aggregate (parent: Self id? in tenantRegistry) — the expander rewrites it to <Host> id when the capability is spliced in.

json is an opaque JSON blob — Loom does not model its interior. It maps to Postgres JSONB (Drizzle jsonb, EF System.Text.Json.JsonElement, Ecto :map), TS unknown, Zod z.unknown(), and a freeform object in the OpenAPI/wire spec (a leaf — never expanded or structurally diffed). Reach for a valueobject instead when the shape is known. See document-and-json-hierarchies.md (D-DOCUMENT-AXIS).

A bare Identifier in type position must resolve to one of:

Resolves to Meaning
Enum (any context) An enum value.
Value object (any context) An embedded value object — copied by value into the wire shape.
Entity part of the same aggregate An addressable child of this aggregate, by-reference at runtime (the engine has the loaded object).
Event / payload — workflow create parameter only The transport record that triggers the starter — create(e: PaymentReceived) by …, create(c: SettleOrder). Offered as a type only in that position (see below).

Cross-aggregate references must use X id — an explicit foreign key. The validator rejects a bare aggregate name in storage / wire positions (aggregate fields, event fields, operation / function / find / workflow parameters) with a fixit pointing at 'X id'; it also rejects an entity-part from a different aggregate the same way, pointing at the owning aggregate's id.

Events and payloads as parameter types. An event or a payload (command / query / response / error) may be named by a bare identifier as the type of a workflow create parameter — the workflow command surface (create(c: PlaceOrder), create(e: OrderPlaced) by e.order; see workflow.md). The bound parameter is a flat transport record: e.field resolves to the field's declared type and participates in the usual comparison / arithmetic / assignment checks. These types are scoped only to that position — a stray event name in an aggregate field, operation parameter, or UI position stays an unresolved reference, and Event id is not a valid X id link.

The result is a legible three-keyword surface — id shows up exactly when you cross an aggregate boundary; everything else is a bare name, and the type system tells you what it means.

T[] denotes a collection; T? denotes an optional value. Both suffixes apply to the same TypeRef, in either order (Customer id?, Pokemon id[], Address?).

Null narrowing

A T? is not usable where a T is required — + needs two strings, and every member read needs a receiver that cannot be null (loom.nullable-receiver rejects path.trim(), path.length and addr.city on a string? / Addr?, because every backend emits a bare dereference; a field read in a ui body is spelled null-safe and passes). A null test is the guard: it narrows the ternary branch it proves safe, the later operands of an && (x != null && …) or || (x == null || …) chain, an if block, and the rest of a body after a precondition x != null or an if x == null { return … } guard (06 § Null narrowing in a body).

// dataKey: string?  — a root tenant has no parent path
dataKey := parent.dataKey != null ? parent.dataKey + "." + seg : seg
// generated TS (Hono) — the emitted expression is unchanged by narrowing;
// the null test was always in the condition (TS narrows it itself).
org.setPath(loaded.dataKey !== null ? loaded.dataKey + "." + nm : nm);

Both directions narrow: x != null ? … narrows the then-branch, x == null ? … : … narrows the else-branch, and the null literal may sit on either side of the comparison. x ?? y is the same narrowing (it desugars to x == null ? y : x).

A nullable value into a non-optional slot — an assignment, a call argument, a record construction, an emit field or an Agg.create({ … }) field — is an error (loom.assign-type-mismatch, loom.call-arg-type, loom.construction-field-type, loom.emit-field-type, loom.create-field-type). That includes an optional currentUser claim, even behind a requires that tests it:

user { customerId: Customer id? }
workflow openTicket {
  create(subject: string, onBehalfOf: Customer id) {
    requires currentUser.customerId != null || IsAgent()
    // requester: currentUser.customerId            ← loom.create-field-type
    let t = Ticket.create({ subject: subject,
      requester: currentUser.customerId != null ? currentUser.customerId : onBehalfOf })
  }
}
// generated C# (.NET) — an id is a `readonly record struct`, so the claim is a
// `Nullable<CustomerId>`; the narrowed branch is unwrapped with `.Value`.
var t = Ticket.Create(subject: command.Subject,
    requester: currentUser.CustomerId != null ? currentUser.CustomerId.Value : command.OnBehalfOf, …);

.NET is the one backend whose emitted expression changes: when the narrowed branch is exactly the tested path and C# models it as Nullable<T> (ids, enums, numerics, bool, datetime, guid), that branch gets .Value, unless the other branch is a bare null. Reference types (string, value objects) and the other backends emit the expression unchanged.

Deliberately not narrowed — each is conservative, never unsound:

Comparing a nullable money or decimal

A comparison does not need a null guard. The type system admits T? against T for every operator (comparable unwraps the optional), so it gives the comparison an answer: an equality is null-safe (both absent are equal, one absent is unequal), and an ordering (< <= > >=) with an absent side is false, as in SQL's UNKNOWN and .NET's lifted operators. For money? / decimal? (and a nullable int?/long? against a decimal) every backend and every frontend implements this rule.

fee: money?
derived feeOverFive: bool = fee > money("5")      // false when fee is absent
derived feeAtMostFive: bool = fee <= money("5")   // also false when absent
// generated TS (Hono): an absent receiver short-circuits, an absent argument is NaN
get feeOverFive(): boolean { return ((this._fee)?.gt(new Decimal("5")) ?? false); }
// generated Java: both sides are tested before compareTo
return ((java.util.function.BiPredicate<java.math.BigDecimal, java.math.BigDecimal>) (loomL, loomR)
    -> loomL != null && loomR != null && loomL.compareTo(loomR) > 0).test(this.fee, new BigDecimal("5"));

!(fee > money("5")) is therefore true when fee is absent. Write the null test explicitly (fee == null || fee <= money("5")) when absent must mean something else.

slot is a UI-only marker — valid only on a component's parameter list, where the caller injects a JSX expression that the component body renders via a bare ref. The validator rejects slot in any other position (aggregate field, value-object field, operation param, etc.) with loom.slot-out-of-position. Member access on a slot ref is also rejected (loom.slot-member-access) — slots are opaque values, not records. See page-metamodel.md §5.2.

Query results and projections are exempt — find byEmail(e: string): Customer? and derived owner: Customer = ... may legitimately reference an aggregate as a domain object. The check only fires in storage / wire-data positions.


Top-level helper functions

A pure, expression-form function may be declared at file root (or directly inside a system { }), making it an ambient helper visible workspace-wide — like a root valueobject / enum. It inlines at every call site during lowering (no function is emitted), so it works uniformly on every backend and a call inside a find … where stays queryable.

function isBlank(s: string): bool = s.trim().length == 0
function withTax(amount: int, pct: int): int = amount + amount * pct / 100

context Sales {
  aggregate Invoice {
    customerName: string
    net: int
    invariant !isBlank(customerName)
    derived gross: int = withTax(net, 20)
  }
}
// .NET — inlined, no helper function/class emitted
if (!(!(CustomerName.Trim().Length == 0))) throw new DomainInvariantException(...);
public int Gross => (Net + Net * 20 / 100);

Rules:

The same function keyword declares member helpers inside an aggregate / value object / workflow; those emit as real methods (this.<fn>) and may recurse.

The standard prelude

A small set of top-level functions ships with the toolchain and is ambient — callable in any .ddd with nothing imported, like a language builtin. They are ordinary expression-form functions, so they inline at the call site and an uncalled one emits nothing. A user-declared top-level function of the same name shadows the prelude.

// no import needed — isBlank / isPresent / truncate are ambient
aggregate Customer {
  name: string
  invariant isPresent(name)
  derived initial: string = truncate(name, 1)
}

Current prelude:

The full library — the ambient prelude, the Layer-0 scalar intrinsics, and the collection operations — is catalogued in stdlib.md (generated from the registries). The set grows over subsequent stdlib slices; see docs/old/plans/stdlib.md → Phase C.


Expression language

Pragmatic core, similar to a subset of TypeScript / C# expressions.

Literals

Kind Examples
String "hello"
Interpolated string `Order #{quantity} for {customerName}` — see String interpolation
Integer 0, 42
Decimal 1.5, 0.0
Boolean true, false
Null null
Now now() — current datetime
Money money("10.50") — precise decimal; see money
Duration days(n), hours(n), minutes(n) — an absolute span (int amount only — loom.duration-arity / loom.duration-arg-type; write hours(36), not days(1.5)). Ordinary free calls that lower to duration nodes unless a user function of that name shadows them. See Temporal arithmetic.
List [3, 2, 1] — bracketed list literal (page-DSL argument values such as Grid { cols: [3, 2, 1] })

References

Form Resolves to
id the implicit identity of the enclosing aggregate or part.
this the enclosing aggregate / part / value object.
name a parameter, let-binding, lambda parameter, property of this, derived member, helper function, or enum value (in lookup order).

Composite

Form Notes
a.b Member access.
a.b(x, y) Method call (collection ops, helper functions).
f(args) Free call (helper function or value-object constructor).
(expr) Grouping.
-x, !x Unary.
a + b, a - b, a * b, a / b, a % b Arithmetic.
a < b, a <= b, a > b, a >= b, a == b, a != b Comparison.
a && b, a || b Logical.
a ?? b Nullish coalescing — a unless it is null, else b. Binds LOOSER than || and TIGHTER than ? :, and is RIGHT-associative (a ?? b ?? c is a ?? (b ?? c)). Pure sugar: it lowers to a == null ? b : a, so it works on every backend and every frontend the ternary does. NOTE the left operand is evaluated TWICE (once in the test, once as the value) — bind an effectful one with let first.
cond ? a : b Ternary (loom.ternary-condition / loom.ternary-branches). A direct null test on cond narrows the proven branch — see Null narrowing.
match { cond => value, …, else => value } Predicate-arms expression — the first true arm wins; else is the fallthrough.
match subject { Variant [b] => value, …, else => value } Variant match over an A or B / T option union scrutinee, optionally binding the narrowed variant. The subject must be a simple ref / member read, not a call — except match await <call> { … } in a page action, which awaits a remote command and matches its Result (actions.md).
x => expr / (a, b) => expr Lambda (a collection-op argument or a page-DSL handler value).
f(name: value, …) Named call arguments (page primitives, macro-style calls).
PartName { field: expr, … } Construct a contained part; id and parent parentId are auto-injected.
Money { amount, currency } / Money { amount: 1, currency: "USD" } Value-object constructor (positional or named).
Repo.run(Criterion(args), page?) / Repo.run(retrieval { where: …, sort: […], loads: […] }) / Repo.findAll(...) ignoring Cap Repository reads inside workflows / handlers — see criterion.md and capabilities.md for ignoring.

String interpolation

A backtick-delimited template with {expr} holes. It lowers to plain string concatenation of the literal segments and the string()-converted holes, so it is exactly "…" + string(hole) + … written more legibly — it works anywhere a string expression is valid (derived members, labels, function bodies).

derived label: string = `Order #{quantity} for {customerName}`
// generated TS (Hono) — concatenation through the existing String() path
get label(): string { return "Order #" + String(this._quantity) + " for " + this._customerName; }

Collection operators

When the receiver type is T[]:

Form Returns Notes
xs.count int Length.
xs.sum(x => expr) type of expr Reduction; element-typed.
xs.all(x => expr) bool Universal quantifier.
xs.any(x => expr) bool Existential quantifier.
xs.where(x => expr) T[] Filter.
xs.first T First element (assumes non-empty).
xs.firstOrNull T? First or null.
xs.contains(x) bool Membership. Renders to Array.includes (TS) / Enumerable.Contains (.NET). Also admitted in repository where clauses when xs is a this-rooted X id[] reference collection — see Repositories.
xs.map(x => expr) U[] Projection.
xs.sortBy(x => expr, desc?) T[] Ordering.
xs.distinct T[] De-duplication (scalar elements — loom.distinct-non-scalar).
xs.take(n) / xs.skip(n) T[] Slicing.
xs.join(sep) string Concatenate string elements (loom.join-non-string).
xs.min(x => expr) / xs.max(x => expr) T? Reductions over comparable elements (loom.reduction-non-comparable); null on empty.
xs.avg(x => expr) decimal? Mean (loom.avg-non-numeric); null on empty.

A reduction spelled without its lambda (xs.sum, xs.any) is rejected (loom.bare-collection-accessor). The full catalogue with per-backend queryability lives in stdlib.md → "Collection operations"; the scalar intrinsics (s.trim(), n.abs(), d.round(2), t.startOfDay(), …) are in the same document — an unknown intrinsic, a wrong arity / argument type, or a call on a nullable receiver is rejected (loom.intrinsic-unknown / -arity / -arg-type, and loom.nullable-receiver). A member read on a primitive is judged against the same catalogue plus the one field-shaped scalar member (string.length): m.amount on a money field, or any other invented member on a primitive, is loom.unknown-primitive-member (see the validation list below).

Numeric widening

Within arithmetic, int < long < decimal. An int is assignable to long or decimal; a long to decimal.

Division always yields a fractional result. Unlike +, -, *, % (which preserve the widened integer type), / on two integers widens to decimal: int / int, int / long, and long / long all type as decimal, so 5 / 2 is 2.5 on every backend rather than truncating differently per host. A consequence: derived half: int = a / b is a type error (decimal is not assignable to int) — declare the field decimal, or use the truncating-division intrinsic a.divTrunc(b) (int × int → int, truncating toward zero, e.g. (-5).divTrunc(2) == -2) when you deliberately want integer division (page counts, bucketing, …). Money and decimal operands are unaffected (money / int → money, decimal / int → decimal).

Temporal arithmetic

datetime and duration form a closed algebra, the temporal twin of the money rules below. A duration is an absolute span (fixed millisecond width per unit — no calendar-relative months / years):

A calendar date stays outside this algebra: two dates compare, and a.daysUntil(b) / .year() / .month() / .day() / datetime.toDate() / today() cover the rest — see the type-system chapter. There is no time (time-of-day) type, by decision: without a date it has no ordering across a daylight-saving change.

aggregate Invoice {
  issuedAt: datetime
  derived dueAt: datetime = issuedAt + days(30)
  derived overdue: bool = now() > dueAt
}
// generated TS (Hono)
get dueAt(): Date { return new Date((this._issuedAt).getTime() + (((30) * 86400000))); }
get overdue(): boolean { return new Date() > this.dueAt; }

There is no duration field type on the wire — it lives only in expressions (and as the every: cadence of a timerSource). Written as a field, parameter or return type it is refused with that explanation; carry the amount as an int in a fixed unit (function slaHours(p: Priority): int) and build the duration where it is used (openedAt + hours(slaHours(priority))).

money — precise decimal, distinct from decimal

money is a primitive type for precise-decimal values that must survive the JSON wire round-trip without precision loss. Distinct from decimal (which serialises as a JSON number and is lossy for high-magnitude / high-precision values).

Aspect decimal money
JSON wire number — the same IEEE-754 double on every backend; a backend whose domain type is wider narrows at the response boundary (RS-24) string, fixed scale 4 ("12.5000" — RS-12)
Arithmetic exact on every backend (RS-37): 0.1 + 0.2 is 0.3, narrowed to the wire double once, at the root of a chain exact (closed — see below)
Storage (Postgres) unbounded DECIMAL NUMERIC(19,4)
TS host type number; arithmetic lifted into decimal.js and narrowed with .toNumber() once decimal.js Decimal
.NET host type System.Decimal; a response DTO field is a double (#2563 / #2575) System.Decimal, string-on-wire via [JsonNumberHandling]
Phoenix host type Elixir Decimal (Ecto :decimal); serialized through Decimal.to_float/1 so it ships as a number, not Jason's default string Elixir Decimal, string-on-wire
Python host type float; arithmetic lifted through Decimal(str(x)) and narrowed with float(...) once decimal.Decimal, string-on-wire
Java host type BigDecimal; a response record component is a double (.doubleValue(), M-T6.46) BigDecimal, string-on-wire
OpenAPI { type: number } { type: string, format: decimal } (PayPal/Coinbase/ISO 20022 convention)
Source-level literal 10.50 money("10.50")
Widening participates in int < long < decimal widening closed: see below

Closed arithmetic. money does NOT participate in the int → long → decimal widening chain. Permitted:

Everything else involving money (e.g. money + decimal, money × money, decimal ÷ money) is rejected at the type-system layer. The bridges between decimal and money are the money("…") constructor — which accepts a precise-decimal source string, checked at the source by loom.money-literal-malformed — and the money(x) / decimal(x) conversions for a typed value. Both spell money(; the argument decides which you get.

Invariants and preconditions on money are enforced server-side only (the aggregate's _assertInvariants runs the .gte() / .lte() / .eq() checks at the precise-decimal type); they're NOT propagated into the wire-layer Zod / FluentValidation schemas, because client-side JS can't faithfully compare Decimal instances using host operators.

Best practice. Use money for fields where precision matters (prices, balances, tax amounts). Use decimal for rates, percentages, and other multiplicands where JS-number precision is acceptable. The two types compose naturally in scaling: taxAmount: money = subtotal * taxRate where subtotal: money, taxRate: decimal.


Statements (in operation bodies)

Form Purpose
precondition Expression [message "…"] Runtime check; failure throws a domain error (HTTP 422 — RS-15). The optional message is the user-facing text.
requires Expression Authorization gate (HTTP 403) — currentUser / permissions.<x> predicate; distinct from precondition (validity) and the header when (state, 409). Also a header clause on operation / workflow create / projection, and the gate of a policy { read X requires … } rule (who may read aggregate X). Not as a header clause on create / destroy — the grammar parses one there, but the validator refuses it ('requires' is not allowed on a create: there is no loaded instance for a header gate to read). Gate a hand-written create with a leading body statement requires <expr> instead (emitted as a 403 on all five backends — pinned by test/generator/lifecycle-forbidden-remap.test.ts), with with crudish(requires: <Policy>), or by gating the workflow that constructs the aggregate. See auth.md.
lhs := Expression Assignment to a property reachable from this, optionally spelled with an explicit this. prefix (this.name := name), which is what makes the assignment type-check against the field rather than against a same-named parameter — see Behavior & statements. Derived properties are not assignable; under persistedAs: eventLog assignments live only in apply bodies.
coll += value Append to a contained collection (or an X id[] reference collection).
coll -= value Remove from a contained collection.
emit EventName { field: expr, … } Raise a domain event; drained by the repository on save.
let name = Expression Local binding for the rest of the body.
let name: Type = Expression The same, with a declared type: the value must fit it (a bare literal promotes, a narrower number widens).
helperName(args) / this.op(args) Call a helper function or private operation of the same aggregate (loom.call-arg-count / loom.call-arg-type).
return Expression The designed-in outcome of an operation / handler declared with an or-union return type — an error variant maps to a ProblemDetails status, a success variant to 200. See payloads.md. Also the result of a workflow create(…): T — last top-level statement only, answered as the POST's 200 body (loom.workflow-return-*; workflow.md).
match subject { Variant [b] => { … }, else => { … } } Statement-form variant match — arms run statements (state writes, navigate(...)) rather than yield a value. In a page action, match await Agg.op(...) { Ok r => …, Err e => … } is how a remote command is awaited and its Result handled — a remote mutating call without the marker is loom.missing-effect-marker; loom.match-await* gates the awaited call's shape (actions.md).
let xs = Repo.run(R(args)) then for x in xs { … } Workflow / handler bodies only — iterate an aggregate array, saving each element's mutations per iteration (workflow.md). The iterable must be a let-bound Repo.run(...) result; iterating the call inline (for x in Repo.run(...)) is refused with loom.workflow-foreach-source.
if let x = Repo.find(C(args)) { … } else { … } Workflow / handler bodies only — bind an optional repository result (find(C), findById, findBy, a T? find, or an optional let binding) and branch on presence.
if Expression { … } else { … } Boolean branch; else is optional and else if chains. The condition is a full boolean expression EXCEPT that a {-opening form (a part / value-object construction, an object literal, a match) must be parenthesised — otherwise it would claim the if's own body block. Rendered on node / dotnet / java / python. Refused with loom.elixir-if-stmt-unsupported when an elixir deployable hosts the context (a Phoenix body threads its result through a rebound record, and an Elixir if block's bindings do not escape it), and with loom.if-stmt-page-body-unsupported in a ui body — a page expresses a condition as a VALUE (cond ? a : b or match).
operation finish(fallback: string) {
    if status == Open {
        status := Done
    } else if attempts > 3 {
        status := Failed
    } else {
        attempts := attempts + 1
    }
    note := lastNote ?? fallback
}
// generated (Hono / TypeScript)
public finish(fallback: string): void {
  if (this._status === Status.Open) {
    this._status = Status.Done;
  } else {
    if (this._attempts > 3) {
      this._status = Status.Failed;
    } else {
      this._attempts = this._attempts + 1;
    }
  }
  this._note = this._lastNote === null ? fallback : this._lastNote;
  this._assertInvariants();
}

Tests

Each aggregate may declare zero or more test blocks at the root level (a value object or domainService may nest them too, and a test may be hoisted beside its subject — or into a tests/*.ddd file — as test "…" for <Subject> { … }, where the subject is an aggregate, value object, domain service, or a bounded context for the in-process integration rung). A tests/*.ddd file is not discovered on its own: like any other file it joins the project only through an import from the entry file (import "./tests/order.ddd" in main.ddd) — an unimported test file is silently ignored.

test "money literal builds" {
    let m = Money { 10.5, "USD" }
    expect(m.amount).toBe(10.5)
    expect(m.currency).toBe("USD")
}

test "negative money rejected" {
    expect(Money { -1.0, "USD" }).toThrow()
}

Assertions are method-based: every expect carries a matcher — a bare expect <bool> is a validation error. The matcher set is a closed, compiler-known catalogue (toBe / toBeGreaterThan(OrEqual) / toBeLessThan(OrEqual) / toBeSameInstant / toBeNull / toBeAbsent / toContain / toHaveText / toHaveCount / toBeVisible / toThrow); they are not methods on a domain type but intrinsic assertions the compiler type-checks and lowers per backend. Some are context- restricted (validator-enforced): toThrow(<status>), toBeSameInstant and toBeAbsent are only valid in a test e2e body, toThrow(<kind>) only in a unit test, and neither toBeNull nor toBeAbsent is legal in a ui e2e body — and toThrow in any form is rejected in a test e2e body targeting a FRONTEND deployable, where no HTTP response exists (loom.e2e-ui-throw-invalid; see the negative-path section below). The status form pins an HTTP status, toBeSameInstant compares two ISO-8601 timestamps as instants (so a backend that serializes a datetime as …00.0000000Z still equals the canonical …00Z on the wire, while a real difference in time still fails), and the kind form is described just below. Inside a test body the standard operation statements are allowed plus:

Form Lowers to
expect(<actual>).<matcher>(…) vitest expect(<actual>).<matcher>(…) / xUnit Assert.* / Playwright matcher.
expect(<call>).toThrow() the call is refused by the domain — see what a bare toThrow() accepts below. node expectDomainRejection(() => { <call>; }) / python pytest.raises((DisallowedError, DomainError, ForbiddenError)) / xUnit Assert.ThrowsAny<Exception>(…) + an is DomainException or DisallowedException or ForbiddenException check / JUnit assertThrows(RuntimeException.class, …) + the same instanceof family check (a create(...) keeps the exact DomainException assertion, and a value-object literal the exact ValueObjectInvariantException one, since its invariant throws that type) / ExUnit assert_raise <App>.GuardError or assert {:error, _} =.
expect(<call>).toThrow(<kind>) unit test only — additionally pins WHICH rung rejected (precondition / invariant). See below.
expect(<api-call>).toThrow(<status>) api e2e only — .rejects.toThrow(/→ <status>\b/) (pins the rejected HTTP status). Rejected in a ui e2e body.
expect(<actual>).toBeNull() vitest .toBeNull() / .Should().BeNull() / assertNull / assert x is None / assert is_nil(x).
expect(<read>.<field>).toBeAbsent() api e2e only — expect("<field>" in <read>).toBe(false) (the key is not in the payload).
expect(<actual>).toContain(<x>) membership for a collection, substring for a string — chosen by the subject's type.
absence — toBeNull() / toBeAbsent()

Loom has one absence value; the wire has two spellings of it. A int? holding nothing can be serialized either as "estimate": null or by omitting the estimate key altogether, and the five backends have genuinely disagreed about which they send — which is why test/fixtures/corpus/absent-optional.ddd exists. The two matchers let a test pin the spelling deliberately:

expect(read.estimate).toBeNull()      // present, explicitly null
expect(read.estimate).toBeAbsent()    // the key is not in the payload at all

What keeps that from being backend-roulette is not the author's care but the conformance gate: every behavioural leg diffs its recording against the committed wire golden with diffBodies, which unions both key sets and raises a key-set divergence. The enforced contract today is explicit null on all five backends — RS-35 — so toBeNull() asserts the gated reality and toBeAbsent() currently has no passing subject. That is deliberate: it is not special-cased into passing, so a backend that starts omitting a key turns a test red instead of drifting silently.

Neither absence matcher is legal in a ui test e2e body (loom.e2e-ui-absence-invalid): a ui assertion reads rendered text off a Playwright locator, which is always a string — so toBeNull() can never hold and toBeAbsent() is not a runtime matcher at all. Assert what the page shows (toHaveText("") / toBeVisible()), or move the absence claim to a block targeting a backend deployable. This is the same ruling loom.e2e-ui-throw-invalid makes for toThrow. toContain is legal there — a substring of the rendered text is a real claim.

toBeAbsent() is e2e-only (loom.unit-absent-invalid). "The key is not in the payload" needs a payload to be about; a unit test asserts against an in-memory aggregate, where a declared field always exists — on three of the five backends (C# int?, Java Integer, an Elixir struct's nil default) in-process absence is not observable at all. Use toBeNull() there; in-process, that is the whole of Loom's absence.

toContain() — membership or substring

One matcher, two lowerings, chosen by the subject's type:

expect(read.tags).toContain("urgent")   // collection membership
expect(read.title).toContain("Ship")    // substring

Any other subject is refused (loom.contain-receiver-invalid) — there is no third lowering. Most targets spell both the same way (in in Python, .contains(...) in Java, Contain in AwesomeAssertions, and vitest's toContain dispatches at run time), but Elixir does not: membership is x in list and substring is String.contains?(s, x), and picking the wrong one crashes the generated suite rather than returning a wrong answer. The dispatch therefore reads the subject's resolved type off the IR.

What a bare toThrow() accepts

A bare toThrow() in a unit test asserts that the domain refused the call: a precondition, an aggregate / part / value-object invariant, or an operation's when state gate. It does not accept an arbitrary error. A ReferenceError, TypeError or NameError out of the code under test is a defect, not a rejection, so the test fails on it. Before this rule, node lowered the bare form to vitest .toThrow() and python to pytest.raises(Exception). A negative test then passed when codegen had emitted an undefined name: the test said "rejected" and the code had simply crashed.

test "a title must be longer than two characters" {
    expect(Claim.create({ title: "x", status: Draft })).toThrow()
}
// generated (node): a file-local helper; any other error is rethrown, so it fails the test
import { DisallowedError, DomainError, ForbiddenError } from "./errors";
function expectDomainRejection(call: () => unknown): void {
  try {
    call();
  } catch (err) {
    if (err instanceof DisallowedError || err instanceof DomainError || err instanceof ForbiddenError) return;
    throw err;
  }
  expect.fail("expected the call to be rejected by the domain, but it returned");
}
// …
expectDomainRejection(() => { Claim.create({ title: "x", status: ClaimStatus.Draft }); });
# generated (python)
with pytest.raises((DisallowedError, DomainError, ForbiddenError)):
    Claim.create(title="x", status=ClaimStatus.Draft)

On both, DomainError covers preconditions and invariants; a value object's ValueObjectInvariantError subclasses it. DisallowedError is the when gate, and ForbiddenError is a requires left in the operation body. Java, .NET and elixir accept the same domain-rejection family — a precondition, an invariant, a when gate and a requires. Java and .NET catch the throw and then assert it is one of DomainException / DisallowedException / ForbiddenException (.NET adds ValueObjectInvariantException when the context emits it); the when gate's DisallowedException is a sibling of DomainException, not a subclass, so a single-type assertion would miss it. Elixir asserts its GuardError (whose :kind is :precondition, :forbidden or :disallowed — the pure op core checks the when gate first) or an {:error, _} result. A create(...) or a value-object literal can only be refused by an invariant, so there the bare form keeps its exact single-type assertion (DomainException; ValueObjectInvariantException for a .NET value object). Every member of the family is a domain class, so a crash fails the test on these backends too.

// generated (java): `expect(t.close()).toThrow()` over a `when`-gated op
RuntimeException __thrown2 = assertThrows(RuntimeException.class, () -> t.close());
assertTrue(__thrown2 instanceof DomainException || __thrown2 instanceof DisallowedException || __thrown2 instanceof ForbiddenException,
    "expected the domain to reject this call, but it threw: " + __thrown2);
// generated (.NET)
var __thrown2 = Assert.ThrowsAny<Exception>(() => { t.Close(); });
Assert.True(__thrown2 is DomainException or DisallowedException or ForbiddenException,
    $"expected the domain to reject this call, but it threw: {__thrown2}");

The context-level integration test (the for <Context> rung) is not covered by this. It still accepts any rejection, because a repository read can also refuse with not-found or a concurrency conflict.

toThrow(precondition) / toThrow(invariant) — which rule rejected

A bare toThrow() asserts only that something threw, and the domain floor has more than one rung. A test that means "a fresh work order cannot be completed" and settles for "it threw" keeps passing when the rule it names is deleted and a different rule throws in its place — measured, not hypothetical: that is the 2026-09-13 testability audit's F11. Name the rung:

operation complete() {
    precondition status == InProgress message "finish the work first"
    status := Completed
}

test "a fresh work order cannot be completed" {
    let wo = WorkOrder.create({ reference: "WO-1", customerName: "Ada", status: Draft })
    expect(wo.complete()).toThrow(precondition)
}

The rung is structural on every backend: the domain error carries it as a field, separate from the message, so an authored message "…" on the rule costs the assertion nothing.

// generated (node) — domain/workOrder.ts
if (!(this._status === WorkStatus.InProgress)) throw new DomainError("finish the work first", "precondition", "msg.…", "");
// generated (node) — domain/workOrder.test.ts
expectDomainRejection(() => { wo.complete(); }, "precondition");   // reads err.kind
# generated (python)
with pytest.raises(DomainError) as rejected:
    wo.complete()
assert rejected.value.kind == "precondition", str(rejected.value)
// generated (java)
DomainException __thrown1 = assertThrows(DomainException.class, () -> wo.complete());
assertEquals(DomainException.Kind.PRECONDITION, __thrown1.getKind(), __thrown1.getMessage());
// generated (.NET)
var __thrown1 = Assert.Throws<DomainException>(() => { wo.Complete(); });
Assert.Equal(DomainErrorKind.Precondition, __thrown1.Kind);
# generated (elixir)
rejected1 = assert_raise Api.GuardError, fn -> Api.Ops.WorkOrder.complete(wo, %{}) end
assert rejected1.kind == :precondition

The field is DomainError.kind (node, python), DomainException.getKind() (java), DomainException.Kind (.NET; a value object's invariant throws the separate ValueObjectInvariantException, so there the type is the rung) and GuardError.kind (elixir). It does not reach the wire: the HTTP answer is the same 422 problem whose detail is the authored message.

precondition and invariant are keywords, not values — they are legal only in this one argument position (loom.throw-kind-outside-tothrow). Two rules bound the form, each for its own reason:

One backend asymmetry is worth knowing before you reach for it: on elixir, toThrow(invariant) over an aggregate operation emits a @tag :skip carrying its reason. The vanilla pure op core runs preconditions and an in-memory struct update; aggregate invariants live in the Ecto changeset (validate_invariants/1), which no in-memory call reaches. toThrow(invariant) over a create or a value-object construction runs normally there.

Test blocks emit one file per subject on every backend:


End-to-end tests against a deployable

Inside a system, declare test e2e blocks that exercise a running deployable through HTTP:

test e2e "create then confirm an order" against api {
    let cust = api.customers.create({ name: "Ada", email: "ada@example.com" })
    let prod = api.products.create({ sku: "WIDGET-1", price: { amount: 5.0, currency: "USD" } })
    let ord = api.orders.create({ number: "A-1", customerId: cust.id, status: "Draft", placedAt: "2024-01-01T00:00:00Z" })
    api.orders.addLine(ord, { productId: prod.id, qty: 3, price: { amount: 5.0, currency: "USD" } })
    api.orders.confirm(ord)
    let read = api.orders.getById(ord)
    expect(read.status).toBe("Confirmed")
    expect(read.lines.length).toBe(1)
}

(The domain is the complete example at the end of this document.) An optional verifies <TestCase> after the target links the test to a traceability testCase (traceability.md).

The magic identifier api resolves to the named deployable's HTTP surface. Member-access chains describe the call shape:

Form Lowers to
api.<aggregate>.create({ … }) POST /<plural> with the body.
api.<aggregate>.getById(idExpr) GET /<plural>/{id}.
api.<aggregate>.destroy(idExpr) DELETE /<plural>/{id} — the canonical destroy, asserted to answer 204 with an empty body. Available only on an aggregate that declares one (an unnamed destroy { }, e.g. via crudish).
api.<aggregate>.all(args?) GET /<plural> — the auto-findAll at the bare collection root, not /<plural>/all. Args ride as the query string (page, pageSize, sort, dir).
api.<aggregate>.<operation>(idExpr, body?) POST /<plural>/{id}/<op_snake> with the body (or {} if absent).
api.<aggregate>.<find>(args) GET /<plural>/<find_snake>?… with args as query string.
api.<projection>.byKey(keyExpr) GET /projections/<proj_snake>/{key} — one folded read-model row by its correlation key.
api.<projection>.list() GET /projections/<proj_snake> — every folded read-model row.
api.<workflow>.run({ … }) POST /workflows/<wf_snake> with the body — the workflow's command create, answering 204.
api.<workflow>.instances() GET /workflows/<wf_snake>/instances — every persisted instance row.
api.<workflow>.instance(keyExpr) GET /workflows/<wf_snake>/instances/{key} — one instance row by its correlation key.
api.<context>.<handler>(args…) The explicit route <METHOD> <PATH> -> <Context>.<Handler> binding — <METHOD> <PATH> with each {token} substituted from the positionally matching argument and the remaining params in the JSON body.

all returns whatever the aggregate's all find returns, unwrapped by nobody: the paged envelope ({ items, page, pageSize, total, totalPages }) for a relational aggregate, a bare JSON array where all is typed T[] — exactly like a declared collection find, so expect(xs.items.length) reads the same as it does for find … paged.

A ROUTED HANDLER is addressed through the same two-level shape, with the bounded CONTEXT in the slug position and the HANDLER in the method position — the spelling route POST "/echo/{text}" -> Sales.Echo already uses, so api.sales.echo("hi") posts /api/echo/hi. Arguments bind POSITIONALLY to the declared params; a wrong count is loom.e2e-routed-handler-arity, and a GET/DELETE route whose handler declares a param no {token} binds is loom.e2e-routed-handler-bodyless-method (the backends read it from a body those methods cannot carry). An aggregate verb always wins, so a context whose name slugs like an aggregate changes no existing call.

The projection verbs read a folded projection's read model (see projection.md), so a test e2e can assert the state an operation's events fold into (drive an operation, then byKey the row and expect its columns).

The workflow verbs reach the orchestration tier through the workflow's own name in the slug position (an aggregate slug wins a collision):

test e2e "shipping folds into the saga row" against api {
    let ord = api.orders.create({ sku: "SKU-1", status: "Placed" })
    api.fulfillment.run({ orderId: ord.id })
    let running = api.fulfillment.instances()
    expect(running.length).toBe(1)
    api.orders.ship(ord)
    let row = api.fulfillment.instance(ord)
    expect(row.status).toBe("Shipped")
}
// e2e/Shop.e2e.test.ts
const ord = await __post(`${base}/api/orders`, ({ sku: "SKU-1", status: "Placed" }));
await __post(`${base}/api/workflows/fulfillment`, ({ orderId: ord.id }));
const running = await __get(`${base}/api/workflows/fulfillment/instances`);
expect(running.length).toBe(1);
await __post(`${base}/api/orders/${ord.id}/ship`, {});
const row = await __get(`${base}/api/workflows/fulfillment/instances/${ord.id}`);
expect(row.status).toBe("Shipped");

run's single argument is the workflow create's params by name. A key the workflow does not declare is loom.e2e-unknown-body-key, and an omitted one loom.e2e-missing-required-field. instance and instances return the instance wire shape: the correlation field, then the state fields. Reading any other field off an instance result is loom.e2e-unknown-response-field. Both routes are conditional. An event-triggered workflow mounts no POST, so .run() on it is loom.e2e-unrouted-verb. A workflow with no correlation field persists no row, so .instances() / .instance(key) on it is refused the same way. The full rules are in language-reference/18-testing.md.

Every verb must resolve to a route this same compilation emits. The table above is what a verb lowers to, not a promise that the route is there: POST /<plural> appears only when the aggregate declares a canonical create (by hand or via with crudish), DELETE /{id} only when it declares an unnamed destroy, GET /<plural>/{id}/history only when it is auditable, and a find route only for a declared find (a compiler-synthesized retrieval has none). Calling a verb whose route the model does not emit is loom.e2e-unrouted-verb — a phase-⑦ error naming the aggregate, the verb and the fix. The check resolves each verb against deriveAggregateOperations, the same derivation every backend's route builder renders from, so it cannot drift from what the backends mount. Before it existed, api.products.create({…}) on an aggregate with no create compiled with 0 errors and shipped a suite that answered 405 Method Not Allowed against the very backend the same run generated.

On the ui side the harness drives page objects rather than routes, and addresses exactly three shapes — the New-page create flow (present only when the aggregate has a create surface, the same gate that drops the scaffolded New page), the Detail-page getById, and a public operation's detail-page action. A ui.<agg>.<find>(…) has no page object and raises the same code.

An e2e body speaks wire, not domain: it sends JSON and reads JSON back, and it resolves no context-scoped names (one body may drive several contexts, so there is no single scope to resolve against). The only names it binds are its own let bindings and the magic receivers api/ui. So an enum value goes in as the string the backend serializes — { status: "Placed" }, not { status: Placed }. The bare form is rejected with loom.e2e-unresolved-ref; before that check existed it lowered to an unresolved reference and emitted an undefined identifier into the generated test.

When an argument is a previously bound let name (typically the result of a create call), .id is appended automatically — api.x.getById(p) becomes GET /x/{p.id}.

Bare object literals { a: 1, b: "x" } are allowed inside test bodies (elsewhere in the DSL only new <PartName> { … } is permitted). They serialize to JSON as the request body.

Negative-path assertions — expect(<call>).toThrow(<status>)

expect(<api-call>).toThrow() asserts the call rejects (any non-2xx). To pin the exact HTTP status — turning a one-backend test into a cross-backend status parity assertion — pass the status to toThrow:

test e2e "creating a project with an empty name is rejected" against api {
    expect(api.projects.create({ name: "" })).toThrow(422)
}
test e2e "reading a non-existent project is 404" against api {
    expect(api.projects.getById("…")).toThrow(404)
}

The lowering recognises toThrow and rewrites the expect into a throw assertion, lowering to .rejects.toThrow(/→ N\b/) — matching the status the generated fetch helper surfaces in the thrown error message. The status argument is e2e-only (an in-process test has no wire status — the validator rejects it there) and must be an integer literal. The status contract is identical across every backend: an invariant / check violation rejects with 422 (DomainError — RS-15; 400 stays for a malformed body), a missing aggregate with 404. Because every test e2e block replays against each backend serving the referenced module, toThrow(N) asserts they all reject with the same status — the behavioral complement to the static OpenAPI errorResponseDiffs parity gate.

toThrow is rejected outright in a UI e2e body — a test e2e block whose target is a frontend deployable, which lowers to a Playwright spec rather than a fetch suite (loom.e2e-ui-throw-invalid). There is no response to read a status from: the emitted form validates client-side against a schema derived from the aggregate's own invariants, so an invalid submit issues no request at all, and the page object's submit() awaits a detail-page testid an invalid form never renders. A permanent refusal rather than a gap — an HTTP status and a form-error DOM state are different assertions, and toThrow names the first. Assert the UI's negative path with the locator matchers (toHaveText / toHaveCount / toBeVisible) on a row the test has on screen, or put the status assertion in a block written against <backend-deployable>.

The generated vitest file lives at <system>/e2e/<SystemName>.e2e.test.ts in the output directory. Endpoints default to the docker-compose ports; override per environment via E2E_<DEPLOYABLE>_BASE env vars.

UI e2e tests against a frontend deployable

The same test e2e syntax targets a frontend deployable as long as the body uses the ui identifier instead of api:

test e2e "create then confirm an order via UI" against webApp {
    let cust = ui.customers.create({ name: "Ada", email: "ada@example.com" })
    let prod = ui.products.create({ sku: "WIDGET-1", price: { amount: 5.0, currency: "USD" } })
    let ord = ui.orders.create({ number: "A-1", customerId: cust.id, status: "Draft", placedAt: "2024-01-01T00:00" })
    ui.orders.addLine(ord, { productId: prod.id, qty: 3, price: { amount: 5.0, currency: "USD" } })
    ui.orders.confirm(ord)
    let read = ui.orders.getById(ord)
    expect(read.status).toBe("Confirmed")
    expect(read.lines.length).toBe(1)
}

The test kind is implied by the target deployable's platform — any frontend deployable (react, vue, svelte, angular, feliz, flutter) gets a Playwright spec routed through the auto-generated page objects (<frontend-deployable>/e2e/pages/<aggregate>.ts); backend deployables get the vitest+fetch path described above. The locator matchers (toHaveText / toHaveCount / toBeVisible) are the frontend-only half of the matcher catalogue.

The DSL surface is identical to api e2e (ui.<aggregate>.<verb>(...)); only the lowering differs:

Form Lowers to
ui.<aggregate>.create({ … }) <Agg>ListPage.goto() → create() → fill({…}) → submit(); returns { id } like the api version.
ui.<aggregate>.getById(idExpr) <Agg>DetailPage.goto(idExpr.id) plus eager field("…") reads of every primitive / enum / VO field, plus <containment>.length accessors per contained collection. The result behaves like the api JSON: read.status is a string, read.lines.length is a number.
ui.<aggregate>.<operation>(idExpr, body?) <Agg>DetailPage.goto(idExpr.id) → <opName>(body ?? {}) — opens the operation modal, fills it, submits.

The generated Playwright spec lives at <frontend-deployable>/e2e/<SystemName>.ui.spec.ts. Run via the existing Playwright config in that directory (npx playwright test from <frontend-deployable>/e2e/).

Repositories

repository Orders for Order {
    // unique-key reconstitution: parameter names match aggregate properties.
    find byNumber(number: string): Order?

}
// explicit predicate; `this` refers to the aggregate root.
criterion OrdersActiveForCustomer(forCustomer: Customer id) of Order = this.customerId == forCustomer && this.status == Draft

The full form is find name(params): T | T? | T or NotFound | T option | T[] | T paged [where Expr] [ignoring Cap, … | ignoring *] — a find carries no authorization gate (who may read the aggregate is policy { read X requires Expr }, which covers every find), and ignoring bypasses a capability's contributed query filter (e.g. softDeletable). Each find declaration becomes a method on the generated repository plus a Mediator query in the .NET backend.

List finds are an error. A find returning a collection (T[] / T paged), other than find all, is loom.list-find-removed: the list read-path is Repo.run(<Criterion>(args)) / a named retrieval (composable, page-able, SQL-inlined — see criterion.md), which is why activeForCustomer above is a criterion. A unique-key find returning T / T? is the intended shape. node scripts/codemod-list-find-to-criterion.mjs <path> rewrites a source.

Null semantics. A comparison against a nullable value — a currentUser.<claim> declared T?, or a find parameter typed T? — is null-aware on every backend: this.col == v with v null matches the rows whose col IS NULL, != matches the rows where it is NOT NULL, and an ordering (<, >, …) against null matches no row. So criterion Mine of WorkOrder = this.technicianId == currentUser.technicianId matches the unassigned orders for a principal with no technicianId rather than none (or, on Ecto, a raised ArgumentError). An absent principal stays fail-closed.

A query filter (a criterion body, a find's where) may use this.<refColl>.contains(param) to query membership over an X id[] reference collection — for example, criterion HoldingInParty(pokemon: Pokemon id) of Trainer = this.party.contains(pokemon). The TypeScript backend lowers this to an inArray(...subquery...) against the field's join table; other collection operations (.count, .any, .where, …) remain rejected by the queryable-subset validator.

findById and getById are auto-generated for every aggregate (no need to declare them in the repository). An auto-included find all(): T paged is also added to every aggregate's repository (a bare T[] for an event-sourced, document/embedded-shaped or extends subtype aggregate, whose rows cannot page), so all five backends always expose GET /<plural> and every frontend (react, vue, svelte, angular, feliz, flutter) always has a list page to render. Declaring your own find all(...) in the DSL overrides the implicit one, and its declared return type is the wire contract: find all(): T paged keeps the Paged<T> envelope; find all(): T[] answers a bare JSON array. The list is gated by the context's policy { read T requires <expr> }, like every other read of T.

Absence, in a body. getById and a find declared : T load a single row and answer the declared 404 when it is not there — on the GET route, an operation route and a workflow step alike (the not-found-on-load policy; the binding is a plain T). To BRANCH on the absent row instead, declare the find : T or NotFound / : T option and read the binding through a variant match (match r { Order o => o.code, NotFound => "–" }). Reading such a binding straight through — r.code, r.touch() — is refused with loom.union-read-undiscriminated. A T? read — a : T? find, Repo.find(<Criterion>), Repo.findById(id), Repo.findBy(col: v) — stays a native nullable: let c = Customers.find(ByEmail(e)) binds Customer?, read through a null test (c == null, c ?? d) or narrowed by if let x = c { … } else { … } (an if let takes the read itself as its source too: if let o = Orders.findById(id) { o.touch() }). A member read or an op-call on the binding before it is narrowed is loom.nullable-receiver. See payloads.md → "Union finds".


Validation rules

The validator runs after parsing and reports errors for:

The list above is the classic core. The catalogue today carries ~460 loom.* codes (src/diagnostics/messages.ts is the single source of the wording; test/system/diagnostic-catalog.test.ts pins that every code a validator raises is in it). By family, with the doc that explains each:

Family (prefix) What it gates Reference
loom.projection-* (~35) Projection sources / select / group by / keyed / folds / paging / per-backend support language-reference/10-repositories-and-queries.md
loom.workflow-* (~30), loom.applier-*, loom.lifecycle-* Workflow bodies, reactors, create, appliers, event-sourced lifecycle workflow.md
loom.policy-*, loom.permission-*, loom.field-mask-*, loom.auth-*, loom.*-gate-not-current-user, loom.guard-principal-without-auth Policy ladder / deny carve-outs / named policy functions, implies, mask unless, OIDC config, requires gates auth.md
loom.tenancy-*, loom.tenant-*, loom.cross-tenant-without-tenancy, loom.orgpath-without-tenancy The explicit-stance rule, registry / claim wiring, crossTenant tenancy.md
loom.migration-*, loom.rename-*, loom.backfill-*, loom.unique-* migration { … } ledger steps, destructive / rebaseline gating, unique (…) migrations.md
loom.handler-*, loom.command-handler-*, loom.query-handler-*, loom.route-handler-unresolved, loom.extern-* commandHandler / queryHandler contracts, api routes, extern pairing extern.md, architecture.md
loom.criterion-*, loom.retrieval-*, loom.find-*, loom.findall-*, loom.list-find-removed Criteria purity / arity / aliasing, retrievals, the queryable subset criterion.md
loom.union-*, loom.payload-*, loom.generic-*, loom.match-* A or B unions, payload records, paged / envelope / option carriers, match exhaustiveness and subjects payloads.md
loom.channel*, loom.reactor-*, loom.relay-*, loom.timer-* Channels, channelSources, reactors, timers channels.md
loom.resource-*, loom.datasource-*, loom.file-*, loom.config-*, loom.index-suggestion Resource bindings, storage kinds, File fields, vendor config, index advice resources.md
loom.seed-* Seed rows vs abstract / event-sourced / tenant-owned aggregates, raw columns language-reference/23-domain-services-and-seeds.md
loom.macro-*, loom.unknown-macro, loom.scaffold-*, loom.softdelete-*, loom.capability-*, loom.stamp-*, loom.filter-*, loom.ignoring-clause-placement Macro args / targets, scaffold params, capability hosts, filters / stamps, ignoring placement scaffold-macros.md, capabilities.md
loom.abstract-*, loom.extends-*, loom.tph-*, loom.polymorphic-* Inheritance, TPH / TPC layout inheritance.md
loom.intrinsic-*, loom.duration-*, loom.interp-*, loom.ternary-*, loom.call-arg-*, loom.construction-*, loom.unknown-*, loom.bare-collection-accessor, loom.user-visible-concat Expression typing — intrinsics, durations, interpolation formats, ternaries, calls, record construction, names / members this document, stdlib.md
loom.function-*, loom.blank-message, loom.entity-field-*, loom.duplicate-* Functions, when gates, messages, entity-typed fields, duplicate names / ports / tables this document
loom.ui-*, loom.page-primitive*, loom.store-*, loom.datagrid-*, loom.chart-*, loom.table-*, loom.a11y-*, loom.slot-*, loom.component-*, loom.action-*, loom.missing-effect-marker, loom.match-await*, loom.feliz-*, loom.flutter-*, loom.heex-*, loom.frontend-* Page metamodel, primitive arity / args, stores and lifetimes, grids / charts, accessibility, slots, actions and effect markers, per-frontend support gaps page-metamodel.md, actions.md
loom.e2e-*, loom.test-*, loom.context-test-unsupported e2e bodies, test placement this document, testing.md
loom.domain-service-* Domain-service purity / read rules domain-services.md
loom.provenanced-*, loom.audit-*, loom.correlation-* Provenance, audit trails, correlation keys provenance.md, observability.md
loom.platform-*, loom.java-*, loom.vanilla-*, loom.*-unsupported-backend, loom.*-unsupported Honest per-backend / per-adapter gaps — the feature parses but the selected target cannot emit it generators.md, platforms.md

Warnings (non-fatal):


A complete example

context Sales {

    enum OrderStatus { Draft, Confirmed, Shipped, Cancelled }

    valueobject Money {
        amount: decimal
        currency: string
        invariant amount >= 0
        invariant currency.length == 3
    }

    event OrderConfirmed { order: Order id, at: datetime }

    aggregate Customer {
        name: string
        email: string
        derived display: string = name
    }
    aggregate Product {
        sku: string
        price: Money
        derived display: string = sku
    }

    aggregate Order {
        number: string
        customerId: Customer id
        status: OrderStatus
        placedAt: datetime
        contains lines: OrderLine[]

        derived total: Money =
            Money { lines.sum(l => l.subtotal.amount), "USD" }

        invariant lines.count > 0 when status == Confirmed

        function isMutable(): bool = status == Draft

        operation addLine(productId: Product id, qty: int, price: Money) {
            precondition isMutable()
            precondition qty > 0
            lines += OrderLine {
                productId: productId, quantity: qty, unitPrice: price
            }
        }

        operation confirm() {
            precondition isMutable()
            precondition lines.count > 0
            status := Confirmed
            emit OrderConfirmed { order: id, at: now() }
        }

        entity OrderLine {
            productId: Product id
            quantity: int
            unitPrice: Money
            derived subtotal: Money =
                Money { unitPrice.amount * quantity, unitPrice.currency }
            invariant quantity > 0
        }

        test "money literal builds" {
            let m = Money { 10.5, "USD" }
            expect(m.amount).toBe(10.5)
            expect(m.currency).toBe("USD")
        }
    }

    repository Orders for Order {
        find byNumber(number: string): Order?
    }
}