1. Lexical structure
The tokens and file-level structure beneath every other chapter: comments, identifiers, literals and how their delimiters are stripped, the soft-keyword rule that lets reserved words double as field names, and multi-file source via import. Reach for it when a name won't parse, a string survives onto the wire differently than you wrote it, or you're splitting a project across files.
Grammar:
ID,STRING,INT,DECIMAL,DURATION,TRACE_ID,TEMPLATE_FULL/_START/_MIDDLE/_END/_FORMAT(terminals),WS/SL_COMMENT/ML_COMMENT(hidden),Model,ImportStmt,CommonSoftKeywords,Property.name,LooseName,NameRefIdent· Validators: — · Docs:../language.md
Whitespace and comments are hidden terminals — ignored between tokens — so layout is free. In the member lists named below, a comma between members (and a trailing one after the last) is optional and carries no meaning, so aggregate Order { qty: int, total: money } and the newline-separated spelling parse identically. Those lists are: user fields; valueobject, aggregate and entity-part members; event fields; payload / command / query / response / error fields; permissions declarations; menu entries and links; migration steps; and match arms. This is not a general rule about braces — a block whose contents are named CLAUSES rather than a member list does not take them (a retrieval's where: / sort: follow one another directly). There is no ; statement separator anywhere in the language. Two number terminals are distinguished by the dot: INT is /[0-9]+/, DECIMAL is /[0-9]+\.[0-9]+/. A third, DURATION (/[0-9]+(ms|s|m|h|d)/ — 15s, 5m), is declared before INT so 15s lexes as one token; it is accepted only as a timerSource's every: cadence — in expressions a span is spelled days(n) / hours(n) / minutes(n) (see Expressions).
Comments
Line (// to end of line) and block (/* … */). Both are hidden terminals (SL_COMMENT, ML_COMMENT) — stripped before parsing, so they never reach the AST and never appear in generated output. Block comments do not nest.
// line comment — to end of line
context Orders {
/* block comment
spanning lines */
aggregate Order {
total: money // trailing comment
}
}
No generated tab: comments are discarded at the lexer, so no platform emits anything for them.
Identifiers
ID is /[_a-zA-Z][\w_]*/ — a letter or underscore, then letters, digits, or underscores. Case-sensitive: Order and order are distinct. There is no separate Unicode-identifier rule; identifiers are ASCII-word.
One neighbour to know about: TRACE_ID (/[A-Za-z][A-Za-z0-9]*(-[A-Za-z0-9]+)*-[0-9]+/ — US-001, AC-12, the ids of requirement / testCase / verifies) is declared before ID for longest-match priority. It must end in -<digits>, so ordinary identifiers fall through to ID; the only collision is an unspaced subtraction ending in a number (x-1), which lexes as one trace id. House style spaces binary operators (x - 1).
context Orders {
aggregate Order {
customerId: string // camelCase
order_total: money // snake_case is admitted too
_internalSeq: int // leading underscore is legal
}
}
Casing is yours to choose in the source, but the backends re-case on emission through src/util/naming.ts (upperFirst / lowerFirst / snake / plural). The important nuance: upperFirst/lowerFirst touch only the first character — they do not normalise snake_case. So a snake_case field name diverges across backends: TypeScript keeps it verbatim, while .NET property-cases it by uppercasing the first letter and leaving the underscore in place (order_total → Order_total), exactly as upperFirst's contract states. The database column, derived via snake, stays order_total on both.
aggregate Order { order_total: money } emits:
// domain/order.ts — TS keeps the source casing verbatim
private _order_total: Decimal;
get order_total(): Decimal { return this._order_total; }
static create(input: { order_total: Decimal; currency: Currency }): Order { /* … */ }
// db/schema.ts — column name via snake()
order_total: numeric("order_total", { precision: 19, scale: 4 }).notNull(),
// Domain/Orders/Order.cs — upperFirst: first char only, underscore kept
public static Order Create(decimal order_total, Currency currency)
{
e.Order_total = order_total; // property is PascalCase-of-first-char
// …
}
// Application/Orders/Responses/OrderResponses.cs — wire record property
public sealed record OrderResponse(
[property: Required] Guid Id,
[property: Required] string Order_total,
[property: Required] Currency Currency);
The DB column is identical on both ("order_total" DECIMAL(19, 4) NOT NULL in each migration — money is numeric(19, 4)); only the host-language member name differs. House style is to write camelCase fields so the divergence never bites — but the rule above is what governs anything you do write.
Literals
String, integer, decimal, boolean, null, plus the backtick template string and the money("…") literal. The STRING terminal is /"(\\.|[^"\\])*"/ — double-quoted, standard backslash escapes.
context Orders {
enum Currency { USD, EUR, GBP }
aggregate Order {
note: string = "ships in 2-3 days" // string literal
qty: int = 0 // INT
rate: decimal = 1.5 // DECIMAL (the dot is required)
active: bool = true // boolean
canceledAt: datetime? = null // null
}
}
Delimiter stripping (re-quote on emission). STRING.value arrives at the compiler with its quotes already removed — "USD" is the 3-character string USD, not the 5-character "USD". Anything that emits a string literal must therefore re-quote, by convention via JSON.stringify or the target's equivalent. Enum members make this visible: written bare in the source, they are emitted as re-quoted string literals in the wire/DB layer.
enum Currency { USD, EUR, GBP } emits:
// db/schema.ts — bare members re-quoted into a string array
export const currencyValues = ["USD", "EUR", "GBP"] as const;
// Domain/Enums/Currency.cs — emitted as a real CLR enum, members verbatim
public enum Currency
{
USD,
EUR,
GBP
}
Money has its own literal form, money("10.50") — see Type system — whose argument is a STRING (precise-decimal, never a float). It is distinct from DECIMAL; 1.5 is a lossy decimal, money("1.50") is precise.
Template strings are backtick-delimited with {expr} holes: `Order {n} for {customer.name}`. The lexer splits one into TEMPLATE_FULL (no holes) or a TEMPLATE_START … TEMPLATE_MIDDLE* … TEMPLATE_END sequence with an expression between each pair; a dedicated interpolation lexer mode keeps a hole's } from closing the enclosing block (which is why a hole may not itself contain a { } literal). A comma at hole-paren-depth 0 begins an optional ICU format suffix (TEMPLATE_FORMAT): {total, number, ::currency/USD}. Literal braces / backticks in the text are \{ / \} / \`. See Expressions and ../language.md → "String interpolation".
Soft keywords
Loom keeps the hard-reserved set to declaration heads, type names and expression keywords. Many words that act as keywords in one position are soft keywords — reserved only in the rule that uses them, and admitted as ordinary identifiers everywhere else. This is what lets a domain field be named state, kind, payload, query, body, parent, money, action, paged, option, or, write, migration, … without colliding with the page DSL, the storage-clause keys, the payload-family declarations, the policy verbs, or the type-carrier syntax.
Mechanically: the grammar factors the shared set into one rule, CommonSoftKeywords, and composes it into every value position — Property.name (field names), LooseName (parameter / argument / clause names), NameRefIdent (bare references in expressions), LValueIdent (assignment targets), MemberName (after .) and the body binders let x = … / if let x = … / for x in … (so let event = n + 1 reads back as event). Each of those rules then adds a few position-specific extras, so the soft set is not identical across positions: await is soft as a field / parameter name but not as an expression ref (it is the match await marker there); contains is soft as a parameter name and an expression ref but not as a field name (it heads a containment member); create / destroy are soft only as expression refs, member names and assignment targets. They bind as keywords only where their owning rule begins. test/language/parsing/keyword-identifier-completeness.test.ts pins the factoring.
Readable wherever declarable. A word admitted as a declared name
(Property.name, LooseName) must also be admitted wherever that name is read
(NameRefIdent, MemberName) — a field you can declare but not read back in a
match arm or after a . is a trap. keyword-identifier-completeness.test.ts
and its keyword-identifier-coverage.snapshot.json gate it. The same applies to
the derived surfaces: a keyword that is also an aggregate's plural (claims
for aggregate Claim) must stay addressable as an e2e slug, which is what
PluralSlugKeyword is for. A new keyword extends these sets in the same PR.
of, allow, deny, local, deep, global, policy and persistence used to be soft only as LooseName — usable as a parameter or clause name but not as a field name, so aggregate Claim { policy: Policy id } was a parse error with no explanation why (audit finding D4). All eight are now in CommonSoftKeywords, i.e. soft in every value position, even though each still heads its own hard syntax somewhere (policy { … } / policy N(): bool, realization { persistence: … }, criterion X() of T, the local/deep/global read ladder). That is safe because none of those hard positions is reachable from a value position: PolicyDecl is a context member and Property an aggregate/value-object/payload one, and the rest are introduced by a preceding hard keyword. test/language/parsing/reserved-field-name-widening.test.ts pins both halves — the field-name admission and an AST snapshot of every hard position, captured before the widening.
Soft by default. Hand-picked widenings kept losing the race against real domains — event, theme, layout, channel, document, node, platform, port, header, seed, log were all parse errors as field names — so the rule is now the other way round: every word keyword is a soft keyword unless it is on the short hard list below. That covers the declaration heads (event, channel, api, ui, workflow, projection, …), every block-config key and value (platform, provider, theme, layout, postgres, react, …) and the member modifiers (check, mask, provenanced, sensitive, …). A new keyword is soft too, unless one of the reasons below applies.
The words that are not field names today: apply, as, bool, contains, create, datetime, decimal, DELETE, derived, destroy, else, emit, entity, expect, false, File, find, for, from, function, GET, guid, id, if, in, int, invariant, json, let, long, match, notFound, now, null, on, operation, PATCH, persist, POST, precondition, private, PUT, requires, return, Self, string, test, this, true, unique, unless, when, where, with. They stay hard for one of two reasons: they are types, literals, or expression / statement words (int, true, this, if, match, where, let, emit, requires, precondition, the HTTP verbs, …), where a name would make an expression or statement ambiguous; they head a member that shares a body with fields (derived, operation, create, destroy, invariant, unique, function, entity, contains, on, apply, test, private), where admitting the name would turn a malformed member's real error into a vaguer one. (enum, import, extends, static, system, repository and aggregate were also held hard until every backend escaped or renamed a field, parameter or local spelled that way; they are soft now.) Declaring a field with one of them reports 'derived' is a Loom keyword, so it cannot be used as a name here. Rename it … on the field's own line — never the bare Expecting token of type '}' it used to. The list is pinned against the grammar by test/language/parsing/reserved-field-name-widening.test.ts.
context Orders {
aggregate Order {
state: string // `state` is the page-DSL state-block keyword — soft as a field
kind: string // `kind` is a DataSource clause key — soft as a field
payload: string // `payload` begins a PayloadDecl at context level — soft as a field
parent: Order id? // `parent` is the requirement-hierarchy keyword — soft as a field
money: int // even `money` (a primitive type / MoneyLit) is soft as a name
write: int // the `policy` verb — soft as a field
migration: int // the top-level ledger-block head — soft as a field
policy: string // heads `policy { … }` at context level — soft as a field
deny: bool // a `policy` read-ladder effect — soft as a field
of: string // the `criterion X() of T` connector — soft as a field
}
}
The same word, in its keyword position, is hard:
// `state` heads a page state block; `payload` heads a top-level payload record.
payload OrderPlaced { orderId: string } // here `payload` is the declaration keyword
ui Web {
page Home {
state { count: int } // here `state` is the state-block keyword
}
}
The CommonSoftKeywords set today (soft in every value position, hard only in its own rule): abstract, action, against, aggregate, aggregates, allow, angular, angularMaterial, api, area, asc, audience, audited, auth, bigquery, bind, body, broadcast, by, cache, canonical, capability, carries, chakra, channel, channels, channelSource, check, claims, clickhouse, clientId, clientSecret, command, commandHandler, component, config, connection, context, contexts, cookie, coreComponents, covers, criterion, cron, crossTenant, daisyui, dataSources, date, deep, delivery, deny, denyByDefault, deployable, desc, description, design, directoryLayout, document, domainService, dotnet, e2e, elastic, elixir, embedded, enforcement, entitles, enum, env, envelope, ephemeral, error, event, eventLog, eventSourced, every, extends, extern, favicon, feliz, filter, flowbite, flutter, footer, framework, global, group, header, hosts, httpStatus, immutable, implements, implies, import, index, inheritanceUsing, inMemory, instance, internal, isolationLevel, issuer, java, join, jwt, kafka, key, keyed, keyPrefix, kind, layout, link, literal, loads, local, localDisk, log, mailer, main, managed, mantine, mask, meilisearch, menu, message, migration, modules, money, mui, mysql, nats, node, objectStore, of, ogImage, oidc, onCreate, onUpdate, opt, option, or, overlap, ownTable, paged, parent, payload, permissions, persistedAs, persistence, phoenixLiveView, platform, policy, port, postgres, primeng, projection, provenanced, provider, python, query, queryHandler, queue, rabbitmq, raw, react, readCommitted, readonly, readUncommitted, redis, relational, repeatableRead, replica, repository, required, requirement, resource, response, restApi, retain, retention, retrieval, route, s3, schema, scopes, secret, section, seed, select, sendgrid, sensitive, serializable, serves, service, ses, sessions, shadcn, shadcnSvelte, shadcnVue, shape, sharedTable, sidebar, slot, smtp, snapshot, solution, sort, spartanNg, sql, sqlite, stamp, state, static, storage, store, subdomain, svelte, system, tablePrefix, targets, tenancy, testCase, theme, timerSource, title, token, transactional, ttl, type, ui, urlStyle, use, user, valueobject, verifies, views, vue, vuetify, work, workflow, workflows, write. That list is pinned against the grammar by test/language/parsing/reserved-field-name-widening.test.ts (it had already drifted — key was missing). The grammar rules are the source of truth — CommonSoftKeywords plus the per-rule extras on Property, LooseName, NameRefIdent, LValueIdent and MemberName in src/language/ddd.langium.
No generated tab: soft-keyword admission is a parse-time concern; the resulting field/enum/declaration emits exactly as its non-keyword-named sibling would.
import & multi-file source
ImportStmt is 'import' path=STRING ';'? — a path-based include of another .ddd file, resolved relative to the importing file ("./shared/money.ddd" against that file's directory). Imports may only appear at the top of a file, before the model members. import is a hard keyword.
// main.ddd
import "./shared/money.ddd"
import "./orders.ddd"
system Shop {
subdomain Sales { context Orders { /* … */ } }
}
// shared/money.ddd — declared at model root: ambient across the whole project
valueobject Money { amount: decimal, currency: string }
enum Currency { USD, EUR, GBP }
// orders.ddd
context Orders {
aggregate Order {
total: Money // root-level Money resolves here, across files
currency: Currency
}
}
Semantics:
- Imports are file-loading, not visibility. The CLI's project loader walks the import graph transitively from the entry file (conventionally
main.ddd) and registers every reachable document with one shared global scope; the rest of the pipeline sees one merged model. There is no autodiscovery — a.dddfile nobody imports is not part of the project. - Ambient (model-root) vs context-local.
valueobject,enum, thepayloadfamily,capability, expression-formfunction, andcomponentmay be declared at the model root. Those form an implicit shared kernel, visible workspace-wide: root VOs / enums / payloads resolve into every context's type space, root components into every page body. Aggregates, events, repositories, projections, workflows, handlers and policies stay inside theircontext. The deployment-shape members (deployable,storage,resource,channelSource,ui,layout,theme,user,auth,api,test e2e), amigration "…" { … }block, the traceability declarations and atest "…" for <Subject>are root-admissible too — root deployment members compose into the project's singlesystem, so deployment can live in its own file. - Cross-context aggregate references are unchanged by imports — a
X idreference still resolves only to an aggregate in the same context;importdoes not relax that. A context-local VO/enum that shadows a root-level one of the same name is a hard error, and root-level VO/enum, system, and context names must each be unique across the whole project. generate system <main.ddd>is the multi-file-aware entry point; legacygenerate ts/generate dotnetkeep single-file semantics.
No generated tab: import shapes which documents compose into the model; the emitted code is identical to writing the same declarations in one file. See ../language.md §"Multi-file projects" for the full ruleset.