← Back to home

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:

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" }

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:

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):

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 / auditedByDefault no longer exist as macros. Audit ships as the builtin capability auditable declared in src/macros/prelude.ts — apply it directly via the capability surface (implements auditable + the prelude's filter / stamp rules) rather than a with clause. See capabilities.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:

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