Macros — scaffold, crudish, softDelete
Macros are compile-time with <name>(...) clauses that splice
declarations into the host AST before lowering. They expand to
ordinary DSL constructs you could have written by hand — every macro
documents its source-equivalent.
The stdlib ships three families:
- Scaffolding —
scaffold,scaffoldSubdomain,scaffoldContext,scaffoldAggregate,scaffoldWorkflow. Synthesise UI pages from a domain. - CRUDish —
crudish. Adds a generatedcreate(...)factory,update(...)operation, anddestroy {}terminator to an aggregate. - Cross-cutting capabilities —
softDelete/softDeleteByDefault. Add soft-delete behaviour via the capability surface. (Audit is no longer a macro — it ships as the builtincapability auditable; see below.)
Macros are applied with with <macro>(<args>) on the host
declaration:
aggregate Order with crudish {
subject: string
implements auditable // builtin capability, not a macro
}
context Sales with softDeleteByDefault {
aggregate Order { subject: string }
}
ui WebApp {
with scaffold(subdomains: [Sales])
}
The full grammar of with is in language.md.
The expansion happens in AST phase ② (see
technical.md); the macros' implementations live
under src/macros/stdlib/.
scaffold family
The scaffold family synthesises pages for one or more domain elements.
It's composable end-to-end: the top-level scaffold fans out to
per-element composers, which fan out to leaf macros.
| Macro | Target | What it emits |
|---|---|---|
scaffold(subdomains:, contexts:, aggregates:, workflows:) |
ui |
Home / Workflows-index singletons + invokes the composers below. |
scaffoldSubdomain(of: S) |
ui |
One scaffoldContext per context in subdomain S. |
scaffoldContext(of: C) |
ui |
One scaffoldAggregate / scaffoldWorkflow per member of context C. |
scaffoldAggregate(of: Agg) |
ui |
A List page, a New (create-form) page, and a Detail page for Agg. |
scaffoldWorkflow(of: W) |
ui |
A Form page for workflow W. |
| scaffoldDashboard | context | One singleton query-time projection per aggregate — a row count plus a sum per numeric/money field, aggregated in SQL. |
scaffoldAggregate and scaffoldWorkflow are the
leaves — they don't invoke other macros. Everything else is a
composer that delegates via invokeMacro.
What the aggregate pages show by default
Every choice below is derived from the aggregate and its repository — nothing
is configured, and unfold shows the result as ordinary page source.
aggregate Ticket {
number: string
subject: string
status: TicketStatus = Open
customer: Customer id
implements auditable
derived display: string = number + " " + subject
derived isOpen: bool = status != Resolved && status != Closed
operation assign(agent: Agent id) when isOpen { requires currentUser.role == "agent" … }
operation close() when isOpen { requires currentUser.role == "agent" || currentUser.customerId == customer … }
}
repository Tickets for Ticket { }
policy { read Ticket requires currentUser.role == "agent" }
List columns. The row's label —
derived display: string, the name everyX idpicker already shows — is the first column and the link to Detail, so there is no raw-id column (an aggregate with nodisplaykeeps one). The fieldsdisplayreads (number,subject) andauditable'supdatedAt/createdBy/updatedByget no column, and at most six data columns follow in declaration order. Detail still shows every field.Column("Ticket", o => Anchor(o.display, to: "/tickets/" + o.id)), Column("Status", o => EnumBadge(o.status), sortable: true, field: "status"), Column("Customer", o => IdLink(o.customer, of: Customer), sortable: true, field: "customer"), Column("Created At", o => DateDisplay(o.createdAt), sortable: true, field: "createdAt")<Table.Td><Anchor component={RouterLink} to={("/tickets/" + row.id)}>{row.display}</Anchor></Table.Td>The label is not a stored column, so its header does not sort (the server has no
?sort=key for it, and a LiveView row has no field to sort by).Derived values on Detail. Each scalar
derivedrenders read-only after the stored fields (KeyValueRow("Is Open", Text(data.isOpen ? "Yes" : "No"))).Home cards. Home links only to List pages with no
requires. A gated List stays reachable from the sidebar, which hides it per principal; Home is a static body and cannot.Operation buttons. A
whengate disables a Detail button while the state forbids the op. On anauth: uifrontend, arequiresthat reads onlycurrentUseralso hides it for a principal it refuses:assignabove is hidden from customers, whileclose(its gate reads the record'scustomer) stays visible and the backend decides.{(currentUser.role === "agent") ? (<Button … data-testid="tickets-op-assign">Assign</Button>) : null}<%= if ((@current_user.role == "agent")) do %><.button phx-click={show_modal("ticket-op-assign-modal")} …>Assign</.button> … <% end %>
scaffoldDashboard — KPIs computed by the database
context Orders with scaffoldDashboard {
aggregate Order { code: string total: money lineCount: int … }
}
// Orders gains
projection OrderTotals {
rowCount: int totalSum: money lineCountSum: int
from Order as o
select rowCount = count(), totalSum = sum(o.total), lineCountSum = sum(o.lineCount)
}
A ui carrying scaffold(...) over the same context then grows its Home
page a row of Stat tiles bound to that projection — a money tile through
Money, since a money is a decimal.js Decimal client-side.
Why a projection rather than a count in the page. .all is paged by
default, so counting rows in the browser counts one page; and the aggregation
belongs in the database anyway — this emits one SELECT with
COUNT(*)/SUM(...) and materialises no rows.
Why two macros. A macro attaches to exactly one host, so the projection
(context) and the page (ui) can't come from one — the same split as
scaffoldPaged / scaffoldPagedApi. Both halves derive the projection name in
_dashboard-shared.ts, so a tile can't bind a projection the other didn't emit.
Notes:
One projection per aggregate, not per context: a query-time projection has a single
fromsource.It carries the aggregate's read gate. A totals row discloses the aggregate's data, so each emitted projection (totals and per-day series) copies the context's
policy { read <Agg> requires … }gate onto its header —projection OrderTotals requires currentUser.role == "clerk" { … }— and every backend answers 403 before the query. With no such rule the projection is ungated; underenforcement: denyByDefaultthat is refused (loom.default-deny-ungated, naming the policy rule to add).A hand-written
<Agg>Totalswins — the macro skips a name the context already declares, and the ui side binds whatever fields that projection declares. So the dashboard works without the macro too.Nullable columns are excluded. SQL
SUMskips NULLs, so the tile would describe a different row set than therowCountbeside it.The source has to have columns. Every tile is a direct-table aggregation computed in SQL, so it can only name real columns (
loom.projection-columnless-source). Three aggregate shapes get no dashboard at all: anabstractbase and apersistedAs: eventLogaggregate (neither has a state table, so not evenCOUNT(*)has anything to count), and ashape: documentaggregate. The document case is the interesting one — its table is(id, data, version), so a barerowCounttile is a real query on four of the five backends, and the macro used to keep exactly that tile. It no longer does, because that lone tile costs more than it is worth:- the moment the aggregate carries a read-filtering capability
(
tenantOwned,softDeletable, anyfilter) the tile is refused byloom.projection-document-source-capability-filtered— the filter predicates nametenant_id/is_deleted, which the triple has not — and before that gate existed it was a silent cross-tenant count on .NET/EF, which registers noHasQueryFilterfor a document aggregate; - (
platform: javaused to refuse it too — its JPQL needed a JPA entity a document aggregate never gets. Since M-T4.2 java runs that read natively against the(id, data, version)table, so this is no longer a per-backend reason.)
A scaffold whose default output fails
ddd parseis worse than one tile short, so the skip lives in the macro. A row count over a document aggregate is still writable by hand, on every backend. Both halves read the same predicate in_dashboard-shared.ts(hasDashboardTable, which folds infieldsAreColumns), so a tile can't survive a projection the macro skipped. (Header-visible only — a dataSource binding can overrideshape:at the system level, which the macro can't see; that case is caught by the IR gate.)- the moment the aggregate carries a read-filtering capability
(
Additive: a system that doesn't opt in keeps its welcome page unchanged.
scaffoldPaged / scaffoldPagedApi — a named read on the wire
A criterion or retrieval is a domain declaration with no route of its own.
This pair is how one reaches a client: scaffoldPaged (target context) emits
a paged queryHandler over Repo.run(<read>), and scaffoldPagedApi (target
api) emits the GET /<plural>/projections/<read> route bound to it. of: takes
either kind, and the macro reads the argument's kind. Over a retrieval, the
sort: is the page's default order. requires: <Policy> (a parameterless
function-form policy) gates the handler, the same rule as crudish(requires:):
context Projects with scaffoldPaged(of: TasksInProject, requires: Members) { … }
api TasksApi with scaffoldPagedApi(of: TasksInProject) from Core { }
expands to (unfold ejects exactly this):
queryHandler ListTaskByTasksInProject(p: Project id): Task paged {
requires Members()
let r = Tasks.run(TasksInProject(p))
return r
}
route GET "/tasks/projections/tasks_in_project" -> Projects.ListTaskByTasksInProject
The generated route and repository code for every backend are in
language reference §22. The
ddd new --template crud starter uses this pair for TasksInProject.
Composability
Unfolding one level on with scaffold(subdomains: [Sales]) reveals one
with scaffoldSubdomain(of: Sales) per supplied subdomain. Unfolding that
reveals per-context composers, then per-aggregate / workflow
leaves. Users can drill into a single aggregate's scaffold without
flattening the whole UI.
The leaves all delegate page-shape decisions to pagesForAggregate
/ pageForWorkflow in src/macros/stdlib/scaffold/_pages.ts
— so all the macros agree on what a "list page" looks like.
Overriding a scaffolded page
You don't have to choose between "scaffold everything" and "hand-write everything". A page you write explicitly replaces the scaffolded one of the same name, and every sibling stays scaffolded — the scaffold grows a hole exactly where you want custom UI.
Scaffolded pages live under a per-aggregate area <Plural> at the
role-scoped names List / New / Detail. To override one, open the
matching area next to the with scaffold(...) clause and write the
page there:
ui WebApp with scaffold(subdomains: [Core]) {
area Tasks {
page Detail(id: Task id) {
route: "/tasks/:id"
title: "Custom task"
body: Stack {
Heading { "My bespoke task console", level: 1 },
testid: "custom-detail"
}
}
}
}
One generate system later, pages/tasks/detail.tsx is your page,
list.tsx / new.tsx are the untouched scaffold, and the router wires
/tasks/:id to the custom component exactly once:
// pages/tasks/detail.tsx — the explicit page wins
export default function TaskDetail() {
return (
<Stack data-testid="custom-detail">
<Title order={1}>My bespoke task console</Title>
</Stack>
);
}
The mechanism is scope-local override-by-name in the macro splicer
(mergeScopedMembers, src/macros/expander.ts): a synthesised member
whose name collides with one already present at that scope is dropped,
and same-named area blocks merge recursively. The singleton dashboard
pages (Home, the workflows index) override the same way — write
your own page Home { … } and the scaffolded one steps aside.
Override vs. unfold. Override-by-name replaces a page wholesale
without seeing its generated body — reach for it when you're writing
something bespoke anyway. Unfolding instead
materialises the scaffolded body as real .ddd source so you can edit
it — reach for it when the default is 90% right and you want to tweak,
not rewrite. Both rungs, and where they sit on the path from all-scaffold
to all-custom, are walked in
customization-gradient.md.
crudish
with crudish on an aggregate adds a generated create(...) factory,
an update(...) operation, and a destroy {} destructor — each with
one parameter per writable user field, and a body that assigns each
parameter to the matching field.
aggregate Order with crudish {
subject: string
total: decimal
}
Source-equivalent:
aggregate Order {
subject: string
total: decimal
create(subject: string, total: decimal) {
subject := subject
total := total
}
operation update(subject: string, total: decimal) {
subject := subject
total := total
}
destroy {}
}
These surface on a backend as the POST (create), POST /{id}/update,
and DELETE routes — alongside the GET /{id} and findAll the
repository always emits. A repository without crudish gets only
the read side (getById + findAll); crudish is what adds the
write side.
Which fields become parameters is decided per-surface (see the access
modifier matrix in language.md):
managed/token/internalare dropped from both — they're not client-supplied.immutableis kept bycreate(settable once, at creation) but dropped fromupdate.secretstays on both — write-only fields still belong in the create/update input.- Fields contributed by another capability or macro (
createdAtfromauditable,isDeletedfromsoftDeletable, …) are excluded regardless of access modifier.
update mass-assigns every field left after those rules, which is
worth a moment's thought once the aggregate grows a guarded state-machine
operation: a requires-gated approve() that writes status guards only
POST /{id}/approve, while POST /{id}/update {"status":…} reaches the same
column at whatever gate the update carries. immutable on the field is the
fix — it drops the field from the update surface while leaving the operation
free to assign it (see auth.md → "Guarded state transitions").
The same holds for a when-gated transition (operation close() when status == Resolved { status := Closed }) and for a precondition that reads the field it
assigns (and no operation parameter). Loom raises the warning loom.update-gate-suggestion when it sees any
of these shapes (a counted warning, never an error); managed = <default>
additionally keeps the field off create (see language.md →
"Field access modifiers").
Pass with crudish(updateOnly: true) to emit only update — no
canonical create/destroy. Use it when another macro owns the
create/delete lifecycle, e.g. with crudish(updateOnly: true), softDeletable leaves the soft-delete terminator uncontested.
crudish delete under softDeletable — loom.crudish-hard-delete
softDeletable is a pure mixin: it adds isDeleted/deletedAt and the
!this.isDeleted read filter, and no operation. It does not change what
crudish's destroy does, so on a with softDeletable, crudish aggregate
DELETE /{id} is still a hard delete on every backend: the row is gone,
not flagged. This is deliberate. A capability never rewrites another macro's
operation, and a physical delete (an admin purge, an erasure request) is a
legitimate thing to keep. Loom raises the advisory loom.crudish-hard-delete
(a Suggestions: hint, never an error) so the shape is not a surprise:
aggregate Account with softDeletable, crudish { number: string }
// DELETE /accounts/{id} → DELETE FROM accounts WHERE id = $1 (row removed)
To make soft delete the only way out, drop the canonical destroy and add the operations:
aggregate Account with crudish(updateOnly: true), softDeletable, softDelete {
number: string
create(number: string) { number := number }
}
// POST /accounts/{id}/soft_delete → UPDATE accounts SET is_deleted = true, deleted_at = now() …
// no DELETE /accounts/{id} route (no destroy)
requires: — gating the emitted members
An aggregate create / destroy has no header requires clause:
its gate is a body STATEMENT, and under with crudish the body belongs
to the macro. So under auth { enforcement: denyByDefault } a bare
with crudish cannot validate — three loom.default-deny-ungated
errors on members you have no surface to edit.
Name the gate once as a function-form policy and hand it to
the macro:
policy CatalogManager(): bool =
currentUser.permissions.contains(permissions.catalogManage)
aggregate Product with crudish(requires: CatalogManager) {
sku: string
// A hand-written member still carries its own gate, as always.
operation view() { requires true }
}
Source-equivalent — the macro splices exactly the statement you would have hand-written, first in each body:
create(sku: string) {
requires CatalogManager()
sku := sku
}
operation update(sku: string) {
requires CatalogManager()
sku := sku
}
destroy {
requires CatalogManager()
}
Generated (Hono; each of the three routes opens with it):
if (!((currentUser.permissions).includes("sales.catalogManage")))
throw new ForbiddenError("Forbidden: CatalogManager()");
Nothing is inherited — there is no aggregate- or context-level
default gate — so the rule stays visible at the with call site, right
where the members it guards come from. The policy must be
parameterless (the macro has no arguments to pass it); a parameterised
one is refused at the call site. softDelete takes the same
requires: parameter, and softDeleteByDefault forwards it to every
aggregate in the context.
When the gate is missing, loom.default-deny-ungated names the macro
and this parameter rather than a member you cannot edit:
denyByDefault: 'Product.create' is reachable on an 'auth: required'
deployable but declares no `requires` gate. Its body comes from
`with crudish(...)`, so there is no member here to add one to — name the
gate as a `policy` and hand it to the macro
(`with crudish(requires: <Policy>)`), or drop `with crudish` and
hand-write the member with its own `requires`.
Audit — now the builtin capability auditable
Removed as macros.
audit/auditable/auditedByDefaultno longer exist as macros. Audit ships as the builtincapability auditabledeclared insrc/macros/prelude.ts— apply it directly via the capability surface (implements auditable+ the prelude'sfilter/stamprules) rather than awithclause. Seecapabilities.md.
The capability adds the four canonical audit fields (createdAt,
updatedAt, createdBy: User id, updatedBy: User id) and the
context-level stamping rules:
context Sales {
stamp onCreate {
createdAt := now()
createdBy := currentUser
}
stamp onUpdate {
updatedAt := now()
updatedBy := currentUser
}
aggregate Order {
subject: string
createdAt: datetime
updatedAt: datetime
createdBy: User id
updatedBy: User id
implements auditable
}
}
Why keep fields and stamps separate? The stamping rules are a
context-level concern — they assign the same fields the same way
for every audited aggregate — while the field declarations and the
implements auditable opt-in are per-aggregate. See
capabilities.md for the underlying surface.
softDelete / softDeleteByDefault
Capability group: "softDeletable". The state + filter ship as
the builtin capability softDeletable (isDeleted + deletedAt? +
filter !this.isDeleted, co-located in src/macros/prelude.ts); the
softDelete macro adds only the two operations. A capability
is a pure mixin, so compose them: with softDeletable, softDelete.
| Macro / capability | Target | What it adds |
|---|---|---|
softDeletable (builtin capability) |
aggregate | isDeleted: bool, deletedAt: datetime?, and the !this.isDeleted read filter. No operations. |
softDelete (macro) |
aggregate | The softDelete() and restore() mutations. No state/filter — pair it with the capability. |
softDeleteByDefault (macro) |
context | Invokes softDelete on every child aggregate. |
The filter is a context-level concern carried by the builtin
capability; the operations are per-aggregate (added by softDelete).
Source-equivalent
context Sales {
aggregate Order with softDeletable, softDelete { subject: string }
aggregate Public { name: string } // not soft-deletable
}
↓
context Sales {
// filter !this.isDeleted — carried by the builtin capability
aggregate Order {
subject: string
isDeleted: bool
deletedAt: datetime?
operation softDelete() {
this.isDeleted := true
this.deletedAt := now()
}
operation restore() {
this.isDeleted := false
this.deletedAt := null
}
implements softDeletable
}
aggregate Public { name: string }
}
Public does not implements softDeletable and therefore the
capability-scoped filter doesn't apply — reads of Public are
unfiltered.
Both emitted operations are public commands with macro-owned bodies, so
they carry the same requires:
parameter crudish does — without it, softDelete cannot validate
under enforcement: denyByDefault:
aggregate Order with softDeletable, softDelete(requires: RecordsManager) { … }
// or once for the whole context:
context Sales with softDeleteByDefault(requires: RecordsManager) { … }
Authoring a macro
Macros are TS modules under src/macros/stdlib/<name>/ that default-export
a defineMacro({...}) call. Each macro declares:
defineMacro({
name: "<macro-name>",
target: "ui" | "context" | "aggregate" | …,
apiVersion: 1,
description: "<one-line>",
expand({ target, args, invokeMacro, /* helpers */ }) {
// Inspect `target` (the host AST node) and return AST fragments
// to splice in. Use `invokeMacro` to delegate to other macros.
},
});
The macro-api (src/macros/api/) exposes typed AST factory helpers
(operation, param, primType, boolLit, callExpr, …) and
inspection utilities (writableUpdateFields,
workflowsIn, aggregatesIn). Anything you can write by hand in
.ddd source, a macro can produce.
What unfold writes back
Unfold prints the expanded AST through src/language/print/, so the text
that lands in the user's file is held to the same shape as hand-written
.ddd:
- 2-space indentation throughout, including nested statement blocks
(
for/if let/match) and lambda block bodies (onClick: e => { … }) — each is indented as a block, not by a first-line prefix. - No line past 100 columns. The wrap budget is measured at the column
the text will actually occupy:
print-expr.tstracks an ambient print column (withIndent/atColumn), and the code action seeds it with the indent the splice adds. - Blank lines between declarations, tight field runs. A member is
separated from its neighbour when either spans multiple lines
(
joinDecls), and only declaration containers opt in — so a page'sroute:/body:props and an aggregate's field list stay glued. - No trailing whitespace, including when unfolding into an empty
{ }body.
These are gated by test/language/print/print-block-layout.test.ts (printer
shape) and test/macro/unfold-layout.test.ts (the code action's output, over
every offered unfold). A macro that produces a construct the printer renders
badly is a printer bug — fix it there, not in the macro.
Cross-references
capabilities.md— thefilter/stamp/implementssurface the builtinauditablecapability and thesoftDeletemacro target.page-metamodel.md— the page DSL the scaffold macros emit.language.md— thewith <macro>(...)clause and access modifiers consulted bycrudish.technical.md— phase ② macro expansion and how it sits relative to scope/link and lowering.