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
- Comments:
// lineand/* block */(hidden terminals — never reach the AST). - Identifiers:
IDis/[_a-zA-Z][\w_]*/. Case-sensitive, ASCII-word. One lexical gotcha: theTRACE_IDterminal (US-001,AC-12— used byrequirement/testCase/verifies) is tried beforeID, so an unspacedx-1lexes as a trace id, not a subtraction. House style spaces binary operators (x - 1), which sidesteps it. - String literals:
STRINGis/"(\\.|[^"\\])*"/— double-quoted, standard backslash escapes. Langium strips the delimiters ("USD"arrives as the 3-charUSD); emitters re-quote. - Interpolated strings: backtick-delimited
`Order {n}`with{expr}holes and an optional ICU format suffix per hole — see String interpolation. - Number literals:
INT(/[0-9]+/) andDECIMAL(/[0-9]+\.[0-9]+/).money("10.50")is the precise-decimal literal (aSTRINGargument). - Duration literal
DURATION(/[0-9]+(ms|s|m|h|d)/, e.g.15s) is accepted only as atimerSource'severy:cadence. In expressions a span is built by thedays(n)/hours(n)/minutes(n)constructors instead — see Temporal arithmetic. - Whitespace and comments are ignored between tokens.
- Member separators:
aggregate/entity/valueobjectmembers are newline-separated — a comma is rejected (Expecting token of type '}' but found ',').eventfields,enumvalues, and every clause-style block (deployable,resource,storage,ui: X { … }bindings, …) accept commas or newlines. When in doubt, use newlines: they are accepted everywhere.
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:
- Imports are relative to the importing file (
"./other.ddd"is resolved against the directory containing the file with theimport). - The import graph defines the project. Files nobody imports are not part of the project (no autodiscovery).
valueobject,enum,payload-family records,capability, expression-formfunction, andcomponentmay appear at the model root. They form an implicit shared kernel — visible workspace-wide from every importing file. Value objects, enums and payloads resolve into every context's type space; top-levelcomponentdeclarations resolve from every page body in every ui in every system (ui-scope components shadow on name collision). Seepage-metamodel.md§5.1.- Aggregates, events, repositories, projections, workflows, handlers and policies stay inside a context.
- Cross-context aggregate references are not changed by this
feature. Today's rule applies:
X idonly resolves to an aggregate in the same context. - Workspace-level uniqueness: root-level VO / enum names, system names, and context names must each be unique across the whole project. A context-local VO / enum that shadows a root-level one is a hard error.
generate system <main.ddd>is the multi-file-aware entry point. Legacygenerate ts/generate dotnetkeep their single-file semantics.
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):
- Command bodies decide and emit.
operation/create/destroybodies may runpreconditions andemitevents, but must not mutatethisdirectly — the state change is the applier's job. - Appliers fold. Each
apply(e: <Event>) { … }reflects one event type into state, using assignments / collection mutations /letonly (a pure, replayable fold — noemit, no calls, no guards). There is at most one applier per event type, and every emitted event needs a matching applier (or the transition is recorded but never reflected). emitrecords and folds. At runtime anemitboth appends to the stream and applies the fold, so the in-memory aggregate is consistent for the command's response.- Construction is a creation event. An event-sourced aggregate is built
by its
createaction, whose emit-only body raises the creation event; the factory runs that body against a fresh, empty instance so construction goes through the same record-and-fold path. The POST body is the create's params (the command shape), not the field set. At most onecreate(the canonical creator); an aggregate with none is constructed out-of-band and exposes no create route.
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
}
- The stream is the concurrency control (expected-version appends). A
loaded handle remembers the stream version it was folded to, and its save
appends the versions after that one. If another append landed in between,
the first re-used version collides with the
(stream_type, stream_id, version)key and the save answers the optimistic-concurrency 409 (RS-40 sentence, RS-41 rule). A decision taken on a stale fold is never kept. That is whyversionedis not spliced onto an event-sourced aggregate. One handle changed twice does not conflict with itself.
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
managedfield into.create({ … })(e.g.Task.create({ createdAt: now() })) is rejected —managedis server-owned and off the create input. Likewise a scaffolded/custom page that renders aninternalorsecretfield (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:
- Anything but a direct null comparison on that ternary's own condition.
&&chains,!-negated tests,precondition x != null, aletthat copies the optional, and early-return guards do not narrow. There is no flow analysis. - A test and a use spelled differently. Narrowing matches a simple path
syntactically, so
path != null ? this.path …does not narrow — write the same spelling on both sides. A call anywhere in the path (f().x) never narrows, since it names no stable location. - A branch containing a call that could mutate the field. A ternary branch
is an expression and cannot assign, but it can call a sibling
operation, whose body assigns freely — so a branch carrying any call other than a scalar intrinsic or a collection op does not narrow. (A block-formfunctionis gated pure by the IR validator, but is still excluded for now.)
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?andderived 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:
- Expression-form only (
= <expr>). A block-form top-level function ({ … }) is rejected (loom.function-toplevel-block) — it has no emission home yet; make it an aggregate / value-object member instead (there it emits as a real method). - No recursion. Because it inlines, a top-level function must not call itself
directly or through a mutual cycle (
loom.function-recursive). Recursion stays legal for member functions, which emit as real methods. - Shadowing. A local member (
function/ field / operation / VO constructor) of the same name shadows the top-level one; a top-level function in turn shadows the stdlib builtins (a userfunction days(...)shadowsdays()). - Ambient scope. The body sees only its parameters (and
currentUserif present) — pass aggregate fields as arguments; a bare field name won't leak in.
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:
- strings —
isBlank(s),isPresent(s),truncate(s, n) - math —
clamp(n, lo, hi),percentOf(part, whole),roundTo(n, places) - temporal —
isOverdue(due),isFuture(t),isPast(t)
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; }
- Backtick, not
"…"— plain double-quoted strings are never interpolated, so a literal{/}inside"…"stays literal. - Holes are full expressions — arithmetic, calls, ternaries, member chains, even a
nested
`…`template. The one exception: a hole may not contain a literal{ }block (an object /match/ builder-call literal); factor that into aderivedand interpolate the derived. - Hole type — a hole must be
stringor implicitly stringifiable (int/long/decimal/money/bool/ an enum / anX id/ an aggregate with aderived display: string). Adatetime,duration, collection, or plain aggregate hole is rejected (loom.interp-hole-type) — format it first. - Format specs — a hole may carry an ICU format suffix after a comma at
hole-depth 0:
{total, number, ::currency/USD},{n, number, ::percent},{at, date}/{at, time},{n, plural, one {# item} other {# items}},{n, selectordinal, …},{kind, select, a {…} other {…}}. An unknown format isloom.interp-format-unknown; a format that doesn't fit the hole's type (adateon anything but adatetimeordate, atimeon a non-datetime, anumberon a non-numeric, aselecton a non-string/enum) isloom.interp-hole-type. These drive the i18n string catalog — seenew-plan/archive/T1-done.md§ M-T1.11. Formats only render in auipage slot (on the JS/TS frontends; LiveView drops the number/date formatting —D-HEEX-I18N-FORMAT). In backend domain code (aderived,functionoroperationbody) every backend renders a formatted hole as its bare value:{qty, number, ::currency/USD}loses its formatting, and{qty, plural, one {# item} other {# items}}/select/selectordinallose their branch text entirely ("3", not"3 items") — which the warningloom.interp-format-dropped-in-domainreports. Spell the branches withmatchor a ternary in domain code. - Escaping — a literal brace or backtick in the text is
\{/\}/\`;\n/\t/\\behave as in a string literal. - Not queryable — an interpolated string desugars to
+/convert, so (like any concatenation) it cannot appear in afindwhere:clause. - Prefer it over
+in user-visible slots — a"Order " + order.idconcatenation in a page title / label / message slot warns (loom.user-visible-concat) because it won't translate; the template form extracts as one catalog entry.
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):
datetime ± duration → datetime,duration + datetime → datetimedatetime - datetime → durationduration ± duration → duration,duration × int → duration- everything else (
duration ÷ x,datetime × …, mixing with a non-intnumeric) is rejected.
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:
money ± money → moneymoney × {int|long|decimal} → money(commutative)money ÷ {int|long|decimal} → money
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:
- Unit tier only. In a
test e2ebody it is refused (loom.e2e-throw-kind-invalid): over HTTP both rungs answer 422, and their only discriminator is the RFC 7807detailsentence — which an authoredmessageoverwrites. The e2e body keepstoThrow(<status>). It is likewise refused in a context-integrationtest(loom.throw-kind-integration-unsupported), which renders through a separate emitter carrying no rung. requiresis not a rung here. It is an authorization gate (403) needing a principal the unit tier has no vocabulary for.
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:
- TS:
domain/<aggregate>.test.ts(vitest). - .NET:
Tests/<Ns>.Tests/<Plural>/<Aggregate>Tests.cs(xUnit; value objects underValueObjects/, services underServices/). - Python:
tests/test_<aggregate>.py(pytest). - Java:
<Aggregate>Tests.javain the subject's test package (JUnit). - Elixir:
test/<context>/<aggregate>_test.exs(ExUnit).
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
findreturning a collection (T[]/T paged), other thanfind all, isloom.list-find-removed: the list read-path isRepo.run(<Criterion>(args))/ a namedretrieval(composable, page-able, SQL-inlined — seecriterion.md), which is whyactiveForCustomerabove is a criterion. A unique-key find returningT/T?is the intended shape.node scripts/codemod-list-find-to-criterion.mjs <path>rewrites a source.
- TypeScript: when no
whereis given, parameters are equality- matched against aggregate columns and lowered to a Drizzlewhere(eq(...)). Whenwhereis given, the IR expression is lowered to Drizzle operators (eq/ne/lt/lte/gt/gte/and/or/not/inArray) overthis.<col>andthis.<vo>.<sub>references, including the membership formthis.<refColl>.contains(param)against anX id[]join table. The queryable-subset validator rejects shapes that don't fit (e.g..count,.any, lambdas) with a clear diagnostic. - .NET: both forms lower to a LINQ
.Where(x => …)predicate and pass through EF Core to SQL.
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:
- A modifier or header clause written at a callable site that does not carry it
(
loom.callable-modifier-not-allowed-here). The grammar accepts the whole surface —private,extern,audited,requires,when— at every site that runs a named body (operation,create,destroy,apply,function,commandHandler/queryHandler, adomainServiceoperation, a workflowcreate/on, a pageaction); what each site actually carries is a declared table, soauditedon a domain-service operation is an error that explains itself rather than a bare parse failure. The per-site table is in Behavior & statements § The callable modifier surface. preconditionandinvariantexpressions whose type is notbool.- A blank
message "..."clause — empty or whitespace-only — on aninvariant, propertycheck, orprecondition(loom.blank-message). A blank message renders an empty user-facing error string (and degenerates the content-hashed wirecodederived from it), so it's almost always a typo. - Field / parameter / call / member-access type mismatches.
- Access to a member that doesn't exist on a fully-resolved record
receiver —
order.totl,paid.amont,this.noField(loom.unknown-member). Covers aggregates (including fields inherited viaextends), entity parts, value objects, events / payloads, andX idreferences; it does not fire on collection ops (lines.first), onstring.length, or on receivers whose type couldn't be resolved. A primitive receiver has its own code, immediately below. - Access to a member a primitive doesn't have —
s.totallyMadeUp,n.alsoInvented,m.amount(loom.unknown-primitive-member). A primitive is a value, not a record: its whole surface isstring.lengthplus the scalar-intrinsic catalogue, both enumerable, so the message lists what is reachable.moneyis the case that bites — it is a precise decimal, not a{ amount, currency }record, andinvariant limit.amount > deductible.amountused to validate clean and reach the emitters verbatim: node/.NET/Java then failed their own compile, python and elixir did not, leaving a business rule that can never fire. If you wanted the record, declare it —valueobject Money { amount: money currency: string }. A reachable name written without its parens (s.trim) staysloom.intrinsic-bare, and an unknown call staysloom.intrinsic-unknown. - Access to a claim the principal doesn't carry —
currentUser.totallyBoguswhere the system'suser { … }block never declares it (loom.unknown-user-claim). The generated backend'sUserClaimstype is emitted from exactly that block, so an undeclared claim otherwise reaches it verbatim and breaks the generated project's own compile. The derived tenancy memberscurrentUser.orgPath/.rootOrgare always valid (using them without atenancy byline isloom.orgpath-without-tenancy). - Assignment to a derived property.
emitpayloads that don't match the event's declared shape.- Record construction (
X { field: value }for a value object, entity part, orerror/payload/ … record) is checked on three axes at every construction site (operation / create / destroy bodies, property defaults,derived/invariant/functionbodies): an entry naming a field the record doesn't declare (loom.unknown-construction-field), an entry whose value type isn't assignable to the declared field (loom.construction-field-type), and a construction that omits a required field — a declaredPropertythat is non-optional, has no= default, and isn'tprovenanced(loom.construction-missing-field;containsmembers auto-default to empty, so they're never required). - Call arguments — an operation / function call with the wrong number of
arguments (
loom.call-arg-count) or a wrong-typed argument (loom.call-arg-type), at both statement position (bump(a),o.bump(a)) and expression position (free callsfee(a)and member callsprice.scaled(a)inderived/let/precondition/ …). Criterion / policy-function calls keep their own arity gate (loom.criterion-arity) and share the argument type check. Bare-name arguments that don't resolve, and ergonomic numeric-literal promotions (bump(5)into amoney/decimalparam), are admitted exactly as elsewhere. - Unknown / out-of-scope
X idtargets (loom.bare-aggregate-in-typefor a bare aggregate name in a storage / wire position). containsreferencing a part that belongs to a different aggregate.operation/create/destroydeclared outside an aggregate root; atestoutside its subject without afor <Subject>head (loom.test-needs-target), or with a redundant one (loom.test-redundant-for).- A frontend deployable (
react,vue,svelte,angular,feliz,flutter) without atargets:field, or pointingtargets:at another frontend deployable, or without aui:binding. - A non-frontend deployable using
targets:(only valid on frontends).
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):
- Self-recursive operation calls (often unintentional).
emitpayloads missing optional fields.- A reactor / event-create whose event is carried by more than one channel
in its context (
loom.reactor-channel-ambiguous): in-process dispatch records the first channel by declaration order, so the binding is ambiguous — carry the event on a single channel to keep routing explicit. - A frequently-filtered column with no covering index (
loom.index-suggestion, D-INDEX-SUGGEST): advisory only — add a manualresource index: [...]if the access pattern warrants it. Seeresources.md.
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?
}
}