Loom — Generator Reference
Per-platform reference for every file the generators emit and the features they implement. This document maps each DSL construct to its output across the five backends and six frontends so you can answer questions like:
- "What does
aggregate Orderproduce on .NET?" - "Where does my
derived total: Money = …end up in the React app?" - "Does the Hono backend support per-DSL
findwith awhereclause?"
For language syntax see language.md; for architecture
see technical.md; for CLI / Docker / Playwright
workflow see tools.md.
To see one identical domain lowered onto every backend, pick the
Storefront trio from the playground dropdown — storefront-system
(Hono + React), storefront-dotnet (.NET + embedded SPA), and
storefront-elixir (Elixir/Phoenix LiveView). All three share the same
aggregate tree (Order → OrderLine + Money), Wallet aggregate,
and transactional checkout saga, so diffing their output is the
fastest way to read this matrix concretely.
Cross-platform feature matrix
The persistence stack shown per column is the default adapter (Drizzle on
node, EF Core on .NET). Persistence is a selectable realization axis —
platform: dotnet { persistence: dapper }, platform: node { persistence: mikroorm } — see the "Realization axes" section of platforms.md.
The alternates (dapper, mikroorm) share the domain layer below and only
swap the repository/schema layer; both are now at full parity with their
default (M-T6.9) — see platforms.md → "Realization axes".
Schema evolution differs between them. Dapper applies the same versioned
MigrationsIR chain as EF Core: Migrations/*.cs, run at boot by
DbSchema.EnsureAsync behind public.__loom_migrations, projected onto its
jsonb value-object layout. A database provisioned by the old CREATE TABLE IF NOT EXISTS bootstrap is baselined on its first migration-aware boot. See
migrations.md → "persistence: dapper". MikroORM still
self-provisions with schema.update(), and declared migration steps are
refused on it.
Scope note. The construct-by-construct matrix below tracks the three reference platforms (TypeScript/Hono, .NET, React) for readability — it maps each DSL construct to its emitted shape in idiom. The other backends — Python/FastAPI, Java/Spring Boot, and Elixir/Phoenix — and the other frontends — Vue, Svelte, and Angular — consume the same
LoomModel/wireShapeIR contract and emit the analogous constructs in their own idiom. Seeplatforms.mdfor the full registered set.
Five-backend feature parity
Which of the five domain-logic backends emits each gated feature vs. fails
fast at validate. A cell is ✓ (emitted, build-gate-verified), 🚫 (gated —
the validator rejects the combination with the named loom.* diagnostic, a
reviewed decision), or ⚠ (partial — see the note). Elixir runs on plain
Ecto/Phoenix only (the Ash foundation was removed; foundation: ash is a
validation error). This grid is the live view, derived from the
validator gate sets in src/ir/validate/checks/ and frozen against drift by
test/platform/backend-parity-gates.test.ts
(a backend can't be silently ungated-and-unemitting — the F1 class of bug).
The dated baseline write-up is
audits/backend-feature-parity-2026-06.md;
remaining gaps + sequencing are in
plans/backend-parity-plan.md.
| Feature | node | dotnet | java | python | elixir | Gate |
|---|---|---|---|---|---|---|
Event-sourced storage persistedAs: eventLog |
✓ | ✓ | ✓ | ✓ | ✓ | (universal) |
| Event-sourced workflow (saga appliers) | ✓ | ✓ | ✓ | ✓ | ✓ | (universal) |
TPH inheritance inheritanceUsing: sharedTable |
✓ | ✓ | ✓ | ✓ | ✓ | (universal) |
TPC inheritance inheritanceUsing: ownTable |
✓ | ✓ | ✓ | ✓ | ✓ | (universal) |
Discriminated unions / the paged carrier |
✓ | ✓ | ✓ | ✓ | ✓ | (universal) |
The envelope carrier — a single-row find (see below) |
✓ | ✓ | ✓ | ✓ | ✓ | corpus envelope + test/generator/envelope-carrier.test.ts |
when canCommand gate + can_<op> query |
✓ | ✓ | ✓ | ✓ | ✓ | (universal) |
Exception-less returns (op(): X or NotFound) |
✓ | ✓ | ✓ | ✓ | ✓ | (universal) |
Capability filter — relational (non-principal) |
✓ | ✓ | ✓ | ✓ | ✓ | LIMITED_FAMILIES |
Capability filter — principal (currentUser/tenancy) |
✓ | ✓ | ✓ | ✓ | ✓ | system-checks.ts |
| Provenanced fields (runtime trace) | ✓ | ✓ | ✓ | ✓ | ✓ | (universal) |
Per-operation audited |
✓ | ✓ | ✓ | ✓ | ✓ | (universal) |
Audited lifecycle (audited create/destroy) |
✓ | ✓ | ✓ | ✓ | ✓ | (universal) |
Audit/context stamping (with audit) |
✓ | ✓ | ✓ | ✓ | ✓ | (universal) |
envelopeis a single-row find (M-T6.57, ratified 2026-09-10).find audit(): Order envelopemeans "read at most oneOrder": the repository answersOrder, the route serialises the bare body, and an empty result set is the not-found rung (404). It carries no distinct wire shape — the{ id, ts, body }wrapper was the P3 design and never shipped, because nothing in the IR can sourcets.T envelopeandTtherefore emit identically on every backend, which is what the gate on that row asserts.Until that landed, the row above carried five ticks on output that did not build: java named
Envelope<Order>in the repository port, the Spring Data interface and the impl and declared it nowhere; .NET returned a bareOrderfrom aTask<Envelope<Order>>(CS0029); ElixirRepo.all-ed every row and answered a JSON array against its own single-object OpenAPI. Only node and python were honest. Nothing caught it because no.dddin the repo instantiated the carrier — every compile gate was blind to it by construction, which is whytest/fixtures/corpus/envelope.dddnow exists.
Re-verified 2026-08-23 against src/ir/validate/checks/system-checks.ts: every
gate set in this table held all five backends — so the per-backend support
sets (EVENT_SOURCING_BACKENDS, SUPPORTED_UNION_BACKENDS, AUDIT_OP_BACKENDS,
…) were later deleted outright; (universal) means no per-backend gate exists,
and test/platform/backend-parity-gates.test.ts pins the emission per backend. The
former open gaps (principal Python filters W1b, per-op/lifecycle audited
beyond node/dotnet W3, event-sourced workflow on Elixir W4) are closed. The
gaps that remain are language-level rather than per-backend — see
audits/language-gaps-2026-08.md.
| Construct | TypeScript (Hono + Drizzle) | .NET (ASP.NET + EF + Mediator) | React (Vite + RQ + Mantine) |
|---|---|---|---|
enum |
TEXT column + exported union type (text(col, { enum: … })) |
C# enum + EF HasConversion<string> |
Zod z.enum([...]) |
valueobject |
Class with invariant ctor + accessors; flattened columns in Drizzle | record with invariant ctor; OwnsOne in EF |
Zod object schema, nested <Fieldset> in forms |
event |
TypeScript discriminated union; pushed via _events.push |
record implementing IDomainEvent; pushed via _events.Add |
(events are domain-internal; not surfaced to the SPA) |
aggregate |
Class with private state, factory, ops, derived getters, pullEvents() |
Sealed class with private state, factory, ops, derived getters, PullEvents() |
List + Detail + New page; api hooks |
entity part |
Same as aggregate but with _parentId |
Same as aggregate; mapped via OwnsMany |
Sub-table on detail page, master-detail row testids |
abstract aggregate + extends / inheritanceUsing |
TPC (ownTable): standalone Drizzle table per concrete; a read-only <Base>Repository.findAll() union reader + <Base> discriminated-union type. TPH (sharedTable, default): one shared table + kind discriminator + nullable per-concrete columns; <Base> id refs + base reader supported. |
TPC: abstract class <Base> carrying shared fields, concretes : <Base>, EF Ignore<<Base>>() (each concrete maps standalone); read-only I<Base>Repository / <Base>Repository → IReadOnlyList<<Base>>. TPH (sharedTable): one shared table via EF Core native HasDiscriminator<string>("kind"); the abstract base owns the shared Id and DbSet<<Base>>, concretes inherit it (own columns only); <Base> id refs + base reader supported. |
Concrete subtypes carry the merged base fields in their wire shape; no base-specific page. |
persistedAs: eventLog + apply(...) (event sourcing) |
Append-only <agg>_events stream table (stream_id, version, type, data, occurred_at); appliers render as a _apply(ev) fold + _fromEvents(id, events) rehydrator; emit records and folds; create builds an empty shell + runs its emit-only body (POST body = create params); repository folds the stream on load and appends pending events on save (fold-from-zero MVP). Both node persistences supported — Drizzle (default) and MikroORM (EntityManager event store + <agg>_events EntitySchema). |
EF Core and Dapper supported — <agg>_events table (on the DbContext / raw Npgsql), C# _Apply<Event> methods + _FromEvents rehydrator + _Apply dispatch switch; EF-only, not a dedicated Marten backend (D-DOCUMENT-AXIS). The persistence-agnostic fold + CQRS create chain are reused across both. |
(n/a — wire shape unchanged) |
contains (collection) |
Drizzle table with parent_id FK; auto-loaded in repo |
EF owned-collection; auto-loaded by tracker | Sub-table on detail; not editable in the create form |
X id[] (reference collection) |
Auto-derived many-to-many join table (composite PK enforces set semantics); save diff-syncs join rows, .contains(param) lowers to an inArray subquery against the join table. The join table is just the composite key — no ordinal column; reads come back sorted by target id — see "What the generators don't do" below. |
EF Core join entity + DbSet<JoinEntity> (composite PK); GetByIdAsync loads via the join entity, SaveAsync diff-syncs, .contains(param) lowers to _db.<JoinDbSet>.Any(...). (Phoenix/Ecto backend: a many_to_many association through the <join> table + the id-array wire shape projected from the loaded association, set on create/update via put_assoc; .contains lowers to an EXISTS subquery against the join table.) |
X id[] appears in the wire shape as string[]; populated/displayed via the response, but no first-class editor yet |
derived |
Getter that calls into the expression | Computed property that calls into the expression | Read-only field on detail; included in the response Zod schema |
invariant |
Private _assertInvariants() called at the end of every mutator |
Private AssertInvariants() called at the end of every mutator |
(enforced server-side; surfaces as 422 in the UI) |
provenanced property |
domain/provenance.ts SDK + recordTrace(...) after each write; ddd snapshot captures rule snapshots to .loom/snapshots/*.loomsnap.json |
Domain/Common/ProvLineage.cs SDK + inline lineage capture after each write; co-located <field>_provenance jsonb column; provenance_records flushed in the EF save transaction; current lineage exposed on <Agg>Response. Elixir emits the same shape — the <App>.Provenance SDK (process-dictionary trace buffer + flush/1), a co-located <field>_provenance jsonb column, inline capture at each named-op write site, and a provenance_records flush inside the save Repo.transaction. |
(n/a — wire shape unaffected) |
function |
Private method on the aggregate / part class | Private expression-bodied member | (server-only) |
operation |
Public method (or private if marked) that enforces preconditions, mutates state, queues events, and re-asserts invariants | Same shape; visibility honoured | Mantine button on the detail page; opens a modal whose form binds to <Op>Request; submit calls use<Op><Agg>() |
precondition |
if (!cond) throw new DomainError(<source>) |
if (!cond) throw new DomainException(<source>) |
(server-side; HTTP 422 surfaces as a Mantine error notification) |
emit |
_events.push({ type: "X", … }) |
_events.Add(new X(...)) |
(server-side) |
repository find |
Method on <Agg>Repository; where clauses lower to Drizzle predicates (lowerToDrizzle) over the queryable subset, paramless finds fall back to convention-matching |
Method on I<Agg>Repository; LINQ .Where(x => …) for both convention and where forms |
use<FindName><Agg>(query) React Query hook + a list-page filter bar (one input per where param) that drives the hook, falling back to useAll<Agg>() when unfiltered |
criterion (inline use-site) |
Predicate body re-lowered + substituted at each where / invariant / precondition (same Drizzle predicate as a hand-written filter) |
Same — inlined into the LINQ .Where(...) / guard |
(server-side; not surfaced) |
criterion reified by a retrieval or find where |
Module-level predicate fn <name>Criterion = (args) => <Drizzle predicate>, called by run<Name> and the matching find (one fn, deduped across both) |
Criterion<T> (IsSatisfiedBy + ToExpression()) fed into the retrieval's Ardalis Specification<T> bundle and a find's .Where(crit.ToExpression()) (EF); a parameterised SQL fragment on Dapper. (Phoenix/Ecto backend: a shared <name>_criterion/1 query fragment (an Ecto dynamic) the read filters by — one fragment shared by retrieval + find.) |
(n/a — wire shape unchanged) |
retrieval (named query bundle) |
run<Name>(args, page?) on <Agg>Repository — where + .orderBy(...) + .limit/.offset paging |
Run<Name>Async(args, page?, ct) — .WithSpecification(spec).ApplyPaging(page).ToListAsync(ct) (EF) / parameterised SQL (Dapper). (Phoenix/Ecto: a context query function + limit/offset page.) |
(n/a — backend-only) |
Auto findById / getById |
Yes — load root + parts in a transaction; getById throws on missing |
Yes — GetByIdAsync returns Order?, getById is implicit via the controller raising 404 |
use<Agg>ById(id) hook, used by the detail page |
Auto find all |
Yes — GET /<plural>, loads with master-detail |
Yes — GET /<plural> via GetAllQuery + handler |
useAll<Agg>() hook, used by the list page |
test "name" { … } |
Vitest at domain/<aggregate>.test.ts |
xUnit at Tests/<Plural>/<Aggregate>Tests.cs |
(n/a — backend-only) |
test e2e "name" against <backend> { … } |
Vitest at <system>/e2e/<System>.e2e.test.ts (typed fetch against the live HTTP) |
(same file; targets the named .NET deployable) | (n/a — see UI variant below) |
test e2e "name" against <react-deployable> { … } |
(n/a — UI tests live in the react deployable) | (n/a) | Playwright spec at <react-deployable>/e2e/<System>.ui.spec.ts, routes through the auto-generated page objects |
TypeScript backend (platform: node)
generate ts for legacy single-context sources; generate system for
deployables marked platform: node.
File map
For a context with aggregates Order (containing parts) and Product:
<deployable>/
├── package.json # deps: hono, @hono/node-server, @hono/zod-openapi, zod, drizzle-orm, pg
├── tsconfig.json
├── index.ts # pg Pool → drizzle → createApp(db) → @hono/node-server.serve
├── scheduler.ts # timerSource jobs (when the deployable owns any) — see "Timer sources"
├── drizzle.config.ts # Drizzle Kit config (db:generate, db:push, db:studio)
├── Dockerfile # multi-stage node:22-alpine; runtime serves `node out/index.js`
├── .dockerignore
├── certs/.gitkeep # proxy-CA escape hatch (drop *.crt files here at build time)
├── domain/
│ ├── ids.ts # branded id types: OrderId, OrderLineId, ProductId, …
│ ├── value-objects.ts # enums + value-object classes with invariant ctors
│ ├── events.ts # discriminated event union + dispatcher type + Noop dispatcher
│ ├── errors.ts # DomainError, AggregateNotFoundError
│ ├── order.ts # Order class + OrderLine part class(es)
│ ├── order.test.ts # vitest from `test "name" { … }` blocks (when present)
│ └── product.ts
├── db/
│ ├── schema.ts # Drizzle pgTable / enum value tuples
│ └── repositories/
│ ├── order-repository.ts # findById / getById / save / find* / all() / toWire
│ └── product-repository.ts
└── http/
├── index.ts # Hono app composer: CORS, /health, sub-routers, /openapi.json
├── order.routes.ts # OpenAPIHono router with createRoute() + Zod request/response schemas
└── product.routes.ts
Timer sources (timerSource)
A system-scope timerSource { for: <Event>, cron: "…" | every: <dur> }
(scheduling.md) fires a plain domain event on a wall-clock cadence; workflows
react through the existing on/create … by triggers. When a deployable owns a
timer (its subdomain is the for-event's migrationsOwner), the Hono backend
emits scheduler.ts and wires it at boot. Phase 2 splits the two cadences by
where durability matters:
cron:timers → pg-boss (a Postgres-backed durable job queue). pg-boss owns single-fire across replicas natively (no advisory lock), persists each run, and retries a failed body with backoff (retryLimit: 3,retryBackoff: true). The schedule (boss.schedule) and worker (boss.work) live in the app's own Postgres.- Missed-window catch-up. pg-boss does not back-fill a cron boundary that
elapsed while every replica was down (its
shouldSendItonly sends when the last boundary is < 60s old). A self-ownedloom_timer_runswatermark (created by the scheduler on boot, like pg-boss creates its own schema — not in the domain MigrationsIR) drives a coalesce-once catch-up: on the first boot it records a baseline without retro-firing (a fresh deploy must not replay history); on a later boot, if the previous boundary is > 60s old and later than the last recorded run, it replays that boundary exactly once (a 20-minute outage over four 5-minute boundaries fires one catch-up, not four). every:(sub-minute) timers → in-processsetInterval+ a transaction-scopedpg_try_advisory_xact_lock(single-fire) with arunningno-overlap guard. Durability is meaningless for a high-frequency poll (resume-at-next-tick is correct) and pg-boss cron is minute-granularity.- Dispatch. The tick event is constructed (id fields minted,
atstamped) and dispatched through the same in-process dispatcher the sagas use, so anon/createreactor fires with no new machinery. - Observability.
timer_fired/timer_skipped_overlap/timer_lock_contended/timer_emit_failed/timer_catchupon the catalog.
Delivery semantics (multi-pod). Still at-least-once: pg-boss gives single-fire per boundary and durable retry, but a clock-skewed peer or a catch-up replay can re-deliver, so tick reactors must be idempotent (the same contract event reactors already carry). Exactly-once-per-instant is not a goal — at-least-once + idempotent reactors is the contract.
A deployable owning no timer emits no scheduler.ts, no pg-boss dep, and no
boot wiring — byte-identical to before.
Phase-2 durable drivers (M-T4.1). The advisory-lock path above is the
in-process tier — correct for every: (sub-minute) cadences but at-least-once.
cron: timers additionally get a durable, store-coordinated driver per
backend, so a boundary missed while every replica was down is replayed once on
recovery (missed-run catch-up) and fires exactly once across replicas
(single-fire), with retries. Each backend uses its ecosystem's Postgres-native
job store: node → pg-boss, .NET → Hangfire, Java → JobRunr,
Python → procrastinate, Elixir → Oban. The every: tier stays the
in-process interval loop + pg_try_advisory_xact_lock on every backend.
Per-aggregate detail
domain/<aggregate>.ts — class with:
- Private state fields, private constructor for rehydration
static <Agg>.create(input)factory: allocates a fresh id, calls the constructor, runs invariantsstatic <Agg>._create(state)for the repo to rehydrate from rows- Public method per public operation; private method per private op
- Private method per
function - Getter per
derived private _assertInvariants()invoked from every mutator_events: DomainEvent[]+pullEvents()drained by the repo
Entity parts are sibling classes in the same file with _parentId
set on construction; mutations on parts go through their parent's
operations.
http/<aggregate>.routes.ts — built by routes-builder.ts:
- Zod schemas for every value object, enum, and per-route DTO; named
via
.openapi("Foo")so they appear in/openapi.json'scomponents.schemas - One route per shape:
POST /→ create (body =Create<Agg>Request, returns{id})GET /→ all (auto findAll; returns<Agg>ListResponse)GET /{id}→ findById (returns<Agg>Response)POST /{id}/<snake_op>→ operation (body =<Op>Request, returns 204)GET /<snake_find>→ user-declared find (query params =<Find>Query)
- Domain-error handler maps
DomainError→ 422 (RS-15),AggregateNotFoundError→ 404
Response schemas carry the full wire shape: every field, every
contained part nested, every derived value (except the reserved
inspect derived, which stays host-language-only — see below),
value objects as nested objects. Decimals are JSON numbers;
datetimes are ISO strings.
Aggregate stringification hooks: when an aggregate declares
derived inspect: string = ... (auto-injected when omitted), each
backend emits a host-language debug-string hook that delegates to
the inspect value — public override string ToString() (.NET,
auto-invoked by $"{x}" / Console.WriteLine), toString() +
[Symbol.for("nodejs.util.inspect.custom")] (TS, auto-invoked by
String(x) / ${x} / console.log), a public
def inspect(record) module function (Phoenix, invoked
explicitly as MyApp.Catalog.Customer.inspect(record) — the
loom-emitted form lives at the module-function level to avoid an
Inspect-protocol collision under mix compile --warnings-as-errors), a __repr__ (Python, auto-invoked by
repr(x) / f-strings), and a toString() override (Java). Honours sensitive(...) field tags by
substituting <redacted> for the value while keeping the field
name in the structural output. VO-typed fields are inlined
structurally one level deep — price: Money shows as
price: Money(amount: 99, currency: 'USD') rather than the opaque
price: [Money] placeholder; further nesting (VO inside VO,
arrays, optionals) falls back to the placeholder so the expression
stays bounded and self-recursive VO shapes can't cycle. A
sensitive(...) tag on the parent's VO-typed field redacts the
whole VO; per-field sensitive(...) inside the VO redacts only
that slot. Never reached by string(aggregate) or implicit
"x " + aggregate — the user-facing form is derived display: string = ... (opt-in), routed through the Loom expression layer
and the React Select picker.
db/repositories/<aggregate>-repository.ts — built by
repository-builder.ts:
findById(id)— load root + every part collection in one transaction, hydrate aggregategetById(id)— same but throwsAggregateNotFoundErroron missingsave(aggregate)— upsert root, diff-sync each contained collection (insert new + update existing + delete removed) and each reference-collection join table (delete removed pairs; upsert current pairs carrying theirordinalposition so reorders persist) in a transaction, drain events viadispatcher.dispatchall()— auto-included; loads all rows and hydrates with parts<find>(...)— one method per user-declaredfind; awhereclause lowers to a Drizzle predicate (lowerToDrizzleinrepository-find-predicate.ts) over the queryable subset (comparisons,&&/||,!, bare-boolean columns, value-object sub-columns,currentUser.<field>, enum values, and<refColl>.contains(x)join-table subqueries); a paramless find falls back to convention-matching its params to columns. The IR validator (firstNonQueryableNode) gateswhereclauses to exactly this subset, so lowering never silently drops a predicate.toWire(root)— domain → wire DTO projection used by route handlers
db/schema.ts — Drizzle pgTable per aggregate root and per
contained part (parts get a parent_id FK), a value tuple per enum
(the column is TEXT, matching the emitted migration),
value-object fields flattened into prefix-named columns
(price_amount, price_currency).
Every X id[] reference-collection field on an aggregate also gets
its own join table, snake(owner)_snake(field) (e.g. trainer_party,
trainer_caught) — two FK columns (<owner>_id text not null,
<target>_id text not null), an ordinal integer not null carrying
the collection's position, a composite primary key (owner_fk, target_fk), and an index on the target FK so the reverse membership
subquery (this.party.contains(pokemon)) stays index-backed. The
field is not persisted as a column on the owner table.
http/index.ts — composer:
app.use("*", cors())(permissive; pin in.loomignorefor prod)app.get("/health", ...)app.route("/<plural>", <agg>Routes(new <Agg>Repository(db, events)))app.doc("/openapi.json", { openapi: "3.1.0", … })
.NET backend (platform: dotnet)
generate dotnet for legacy single-context sources; generate system
for deployables marked platform: dotnet.
File map
<deployable>/
├── <Namespace>.csproj # net10.0, EF Core 10, Mediator source-gen, Swashbuckle, EF Tools
├── Program.cs # AddDbContext, AddMediator, AddCors, AddSwaggerGen, MapControllers,
│ # camelCase JSON, EnsureCreated, /health
├── Dockerfile # multi-stage dotnet/sdk → dotnet/aspnet
├── .dockerignore
├── certs/.gitkeep # proxy-CA escape hatch
├── Domain/
│ ├── Common/DomainException.cs
│ ├── Ids/ # OrderId.cs, OrderLineId.cs, ProductId.cs, …
│ ├── Enums/ # one .cs per enum + a _namespace.cs marker so empty namespaces compile
│ ├── ValueObjects/ # one .cs per value object + _namespace.cs marker
│ ├── Events/
│ │ ├── IDomainEvent.cs
│ │ └── OrderConfirmed.cs # one .cs per event
│ └── Orders/ # one folder per aggregate (plural)
│ ├── Order.cs # aggregate root class
│ ├── OrderLine.cs # entity-part classes
│ └── IOrderRepository.cs # interface
├── Application/
│ └── Orders/
│ ├── Requests/OrderRequests.cs # CreateOrderRequest, AddLineRequest, …
│ ├── Responses/OrderResponses.cs # OrderResponse, OrderLineResponse, MoneyResponse, CreateOrderResponse
│ ├── Commands/ # one Command + Handler per public operation + Create<Agg>
│ │ ├── CreateOrderCommand.cs
│ │ ├── CreateOrderHandler.cs
│ │ ├── ConfirmCommand.cs
│ │ ├── ConfirmHandler.cs
│ │ ├── AddLineCommand.cs
│ │ └── AddLineHandler.cs
│ └── Queries/ # one Query + Handler per find (incl. auto findById / all)
│ ├── GetOrderByIdQuery.cs
│ ├── GetOrderByIdHandler.cs
│ ├── AllQuery.cs
│ ├── AllHandler.cs
│ ├── ByCustomerQuery.cs
│ └── ByCustomerHandler.cs
├── Infrastructure/
│ ├── Persistence/
│ │ ├── AppDbContext.cs # DbSet<Order>, DbSet<Product>, …
│ │ └── Configurations/
│ │ ├── OrderConfiguration.cs # IEntityTypeConfiguration: OwnsMany lines, OwnsOne for VOs, HasConversion for ids/enums
│ │ └── ProductConfiguration.cs
│ ├── Repositories/
│ │ ├── OrderRepository.cs # implements IOrderRepository: GetByIdAsync, SaveAsync, All, …
│ │ └── ProductRepository.cs
│ └── Events/NoopDomainEventDispatcher.cs
├── Api/
│ ├── DomainExceptionFilter.cs # DomainException → 422, AggregateNotFoundException → 404
│ ├── ValidationProblem.cs # model-binding/DataAnnotations failures → the same 422 + errors[]
│ ├── OrdersController.cs # [HttpPost], [HttpGet], [HttpPost("{id}/<op>")], [HttpGet("<find>")]
│ └── ProductsController.cs
└── Tests/<Namespace>.Tests/ # xUnit project — emitted only when `test` blocks exist
├── <Namespace>.Tests.csproj
└── Orders/OrderTests.cs
Per-aggregate detail
Domain/<Plural>/<Aggregate>.cs — sealed class with:
public <Agg>Id Id { get; private set; }- Private setter on every property; parameterless ctor for EF
public static <Agg> Create(...)factory- Public method per public op; private method per private op
- Private expression-bodied member per
function - Computed property per
derived private void AssertInvariants()invoked from every mutatorprivate readonly List<IDomainEvent> _events+PullEvents()
Infrastructure/Persistence/Configurations/<Aggregate>Configuration.cs —
IEntityTypeConfiguration<<Aggregate>>:
OwnsManyper contained collection (parts as owned entities)OwnsOneper value-object propertyHasConversionfor ids and enums
Application/<Plural>/Commands/... — for every public DSL
operation: a record Command implementing ICommand<TResponse> plus
a Handler that loads the aggregate, invokes the method, saves.
Preconditions are checked inside the aggregate, never duplicated
in the handler.
Application/<Plural>/Queries/... — for every find (including
auto findById and all): a record Query + a Handler that calls
the repository and projects through <Agg>Mapper.ToDto(...) (lives
in the Responses module via dto-mapping.ts).
Application/<Plural>/Requests/<Aggregate>Requests.cs — wire-shape
records: Create<Agg>Request, <Op>Request per public op,
<Vo>Request per used value object. Datetimes are string on the
wire (Hono parity); the controllers parse with
DateTimeStyles.AssumeUniversal | AdjustToUniversal → UTC.
Application/<Plural>/Responses/<Aggregate>Responses.cs — wire-
shape records (<Agg>Response, <Part>Response, <Vo>Response,
Create<Agg>Response). Datetimes round-trip via
ToUniversalTime().ToString("o") so .NET's spec matches Hono's.
Api/<Plural>Controller.cs — [ApiController] with one route per
shape; converts wire-DTOs to commands, dispatches via IMediator,
returns DTOs. DomainExceptionFilter turns precondition failures
into HTTP 422 (RS-15) and missing-aggregate into 404.
Api/ValidationProblem.cs — the faults that never reach a controller.
Model binding and DataAnnotations run before the action, so
DomainExceptionFilter never sees them and MVC answered with its own
ValidationProblemDetails (rfc9110 type, errors as an object-of-arrays,
traceId inline, status 400 where the other backends send 422). Wired as
ApiBehaviorOptions.InvalidModelStateResponseFactory, this replaces it with
the emitted envelope — 422 + errors[] for an invalid body, 400 for an
unreadable one (RS-9's split). Program.cs pairs it with
UseStatusCodePages (404/405, which routing refuses with no body at all) and
a CustomizeProblemDetails hook that normalises the ProblemDetails the
framework builds itself (415).
Middleware/EmptyJsonBodyMiddleware.cs — an empty request body reads as
{} (RS-39), so a parameterless operation answers a body-less POST. It
swaps the body, its framing headers and IHttpRequestBodyDetectionFeature
— MVC's body binder reads the feature, not the stream. The other backends'
twins: hono emptyBodyAsEmptyObject, python JsonBodyMiddleware, java
EmptyJsonBodyFilter, the Phoenix BodyParser.
Program.cs — hosting entry:
AddDbContext<AppDbContext>overConnectionStrings:DefaultAddMediator(source-gen)AddSingleton<IDomainEventDispatcher, NoopDomainEventDispatcher>AddScoped<I<Agg>Repository, <Agg>Repository>per aggregateAddControllers().AddJsonOptions(...)— camelCase property namingAddCors(permissive default; pin to tighten in prod)AddSwaggerGen— spec at/openapi.json(aligned across backends)app.MapGet("/health", ...)+UseCors() / UseSwagger() / MapControllers()db.Database.EnsureCreated()on first scope (per-deployable DB, no race — seetools.md)
React frontend (platform: react)
generate system for deployables marked platform: react. No
single-deployable CLI entry — react frontends only make sense in
system mode where they can resolve a targets: peer.
File map
For a frontend webApp targeting api (which hosts Order + Product):
web_app/
├── package.json # deps: react, react-router, @tanstack/react-query, zod,
│ # @mantine/* (core, hooks, form, notifications, dates, modals),
│ # mantine-form-zod-resolver
├── tsconfig.json + tsconfig.node.json
├── vite.config.ts # @vitejs/plugin-react, dev/preview bind 0.0.0.0:<port:> (PORT overrides; the image passes --port 3000)
├── index.html
├── Dockerfile # multi-stage node:22-alpine; runtime: vite preview
├── .dockerignore # excludes e2e/, dist/, playwright-report/, test-results/
├── certs/.gitkeep
├── src/
│ ├── main.tsx # MantineProvider + QueryClientProvider + Router + Notifications + ModalsProvider
│ ├── App.tsx # AppShell + <Routes>: home, /<plural>, /<plural>/new, /<plural>/:id — each page a React.lazy chunk behind a Suspense (pack loader fallback)
│ ├── api/
│ │ ├── client.ts # fetch wrapper, ApiError
│ │ ├── config.ts # API_BASE_URL = /api (relative, same-origin); window.__LOOM_API_BASE__ or VITE_API_BASE_URL override
│ │ ├── order.ts # Zod schemas + RQ hooks
│ │ └── product.ts
│ └── pages/
│ ├── home.tsx # link cards per aggregate
│ ├── orders/
│ │ ├── list.tsx # /orders — Mantine <Table> from useAllOrders
│ │ ├── new.tsx # /orders/new — Mantine form on Create<Agg>Request
│ │ └── detail.tsx # /orders/:id — fields + parts + operation buttons (modal-form per op)
│ └── products/
│ ├── list.tsx
│ ├── new.tsx
│ └── detail.tsx
└── e2e/ # standalone Playwright suite (separate package.json so the
│ # runtime image stays slim — @playwright/test isn't pulled into prod)
├── package.json # @playwright/test, @types/node
├── tsconfig.json # includes ../src/api/**/*.ts so page objects can use response types
├── playwright.config.ts # baseURL = http://localhost:3001 (override via E2E_BASE_URL)
├── smoke.spec.ts # auto-generated: every aggregate's list page loads
└── pages/ # auto-generated page-object classes
├── order.ts # OrderListPage / OrderNewPage / OrderDetailPage
└── product.ts
Stores (store Cart { state … action … })
A top-level ui member store Cart { state { … } action … } (a shared
client-side container, sibling to page/component — named-actions-and-stores.md
§3) emits one Zustand module per store at src/stores/<snake>.ts:
export const useCart = create<CartState>((set) => ({ …fields, …actions })).
The action bodies reuse the same :=/+= statement lowering a page action
does, targeting Zustand's set((s) => ({ … })) instead of a useState setter
(zustand is added to the generated package.json). At a use site a
page/component references the store by dotted name — Cart.lines (a
store-field read) and Cart.clear() (a store-action call). Each consuming
shell derives its store dependency from those resolved refs (derive-don't-
stamp), imports useCart, and hoists one selector binding per used member
(const lines = useCart((s) => s.lines)); the body / action handlers reference
the bare local. The persist: lifetime ladder ships on the SPA frontends —
store <Name> persist: memory|local|session|url { … } is real grammar, and
React / Vue / Svelte / Angular honour it, so the blanket
loom.store-lifetime-unsupported is retired (a bad value is rejected earlier as
loom.store-lifetime-invalid). Feliz honours it too, differently: a Feliz
store has no module of its own — it folds into the single Elmish Model, so the
ladder rides that fold (generator/feliz/store-persist.ts). init seeds each
persisted field through a StorePersist.load<Store><Field> () loader, an
updateWithPersist wrapper mirrors the Model back after every message, and the
url tier adds a popstate Elmish subscription so back/forward moves the state.
Keys and shapes match the JS builders byte-for-byte (loom.store.<Name> + a JSON
object keyed by the bare field name; one query param per field, empties dropped),
so the same blob and the same URL round-trip across frontends. Every frontend now
implements the ladder; the one target that does not is Phoenix LiveView, which
refuses a non-memory lifetime rather than degrading silently
(loom.store-lifetime-liveview-invalid — a server-side per-process struct has no
browser storage, and URL state belongs to the page's handle_params). What
survives of loom.store-lifetime-target-unsupported is FIELD-scoped, on the two
frontends whose persistence crosses an untyped boundary per field: on Feliz a
type with no total F# conversion (datetime / duration / guid / enum / entity /
value object, and arrays of them), and on Flutter one with no total Dart
conversion (json / File / entity / value object / optional). Both are tracked as
one gap row in src/diagnostics/unsupported-register.ts. A persist: url store reflects its fields into
query params, which carry only scalars, so an array / entity / value-object field
there is rejected (loom.store-url-field-invalid). sync: remains reserved.
Validator gates: a store action can't call a
view-scoped effect (navigate/toast — loom.store-action-view-effect), a
page can't write store state inline (loom.store-state-inline-write), and a
store→store call cycle is rejected (loom.store-action-cycle).
Phoenix LiveView also ships stores (the loom.store-on-liveview-unsupported
gate was lifted): a store Cart { … } emits a dedicated
lib/<app>_web/stores/cart.ex module — a defstruct carrying the state fields
(with their defaults) plus one public pure function per action
(def clear(%__MODULE__{} = state), do: %{state | …}). Each LiveView page that
touches the store seeds one per-process assign in mount/3
(|> assign(:cart, %Cart{}) + an alias <App>Web.Stores.Cart); a Cart.count
read renders @cart.count (template) / socket.assigns.cart.count (handler),
and a Cart.clear() call renders |> update(:cart, &Cart.clear/1) (0 args) or
|> update(:cart, fn c -> Cart.add(c, sku) end) (with args). Because each store
is its own per-page assign, a store action calling a different store's
action is gated (loom.store-cross-store-on-liveview-invalid) — same-store
action→action composition is fine (a pure in-module call).
Vue/Svelte/Angular store-module emit is a fan-out follow-up and throws loudly until ported.
Per-aggregate detail
src/api/<aggregate>.ts — built by api-builder.ts:
- Zod schemas for every value object, enum, and per-route DTO:
<Vo>Schema,<Enum>SchemaCreate<Agg>Request, per-op<Op>Request, per-find<Find>Query<Part>Response,<Agg>Response,<Agg>ListResponse
- React Query hooks:
useAll<Agg>()→useQuery(["<plural>"], …), parses with<Agg>ListResponseuse<Agg>ById(id)→useQuery(["<plural>", id], …),enabled: !!iduseCreate<Agg>()→useMutation, invalidates["<plural>"]on successuse<Op><Agg>(id)→useMutation, invalidates["<plural>", id]and["<plural>"]on successuse<FindName><Agg>(query)→useQueryper user-declared find (the autoallfind is the dedicateduseAll<Agg>hook above)
All hooks parse the response with the matching Zod schema before returning, so callers get type-checked, validated data.
src/pages/<plural>/list.tsx — built by body-walker.ts +
pages-emitter.ts (the legacy pages-builder.ts archetype renderer
has been removed; bodies — scaffolded or hand-written — all route
through the walker now):
- Mantine
<Table>with one column per primitive / enum field - Each row links to
/<plural>/<id> - "Create" button routes to
/<plural>/new data-testidon every interactive element (see table below)
src/pages/<plural>/new.tsx:
- Mantine
<form>over theCreate<Agg>Requestschema - One Mantine input per required aggregate field — see Mantine input mapping below
useForm+zodResolver(Create<Agg>Request)for validation- Submit calls
useCreate<Agg>(); success navigates to detail; error surfaces via@mantine/notifications
src/pages/<plural>/detail.tsx:
- Loads via
use<Agg>ById(id) - Mantine
<Card>with field display (one per primitive / enum / VO field) - Sub-
<Table>percontainscollection (master-detail) - One Mantine
<Button>per public operation; click opens a Mantine modal whose form binds to the matching<Op>Requestschema - Submit calls the matching mutation hook; success closes the modal and shows a notification; error surfaces via notification
- React Query invalidation runs on success so the page reflects the new state automatically
Mantine input mapping
Form-input emission walks the field type:
| Type | Mantine component | Notes |
|---|---|---|
int / long |
<NumberInput allowDecimal={false}> |
|
decimal |
<NumberInput decimalScale={2} fixedDecimalScale> |
|
money |
<TextInput inputMode="decimal"> |
Precise-decimal field — value is a Decimal from decimal.js; number-mode inputs lose precision via 1e10 notation. |
string / guid |
<TextInput> |
|
bool |
<Switch> |
Manual checked / onChange since Mantine <Switch> accepts event-based onChange. |
datetime |
<TextInput type="datetime-local"> |
Native datetime input — Mantine's <DateTimePicker> is harder to drive from Playwright; users can swap via .loomignore for richer UX. |
enum |
<Select allowDeselect={false}> |
Explicit value / onChange / error (Mantine Select calls onChange with (value, option), not an event, so getInputProps can't be spread directly). allowDeselect={false} keeps required fields from being cleared by a click on the already-selected option. |
valueobject |
<Fieldset legend> with one input per VO field |
Sub-inputs use the name.subField form for nested binding. |
array<part> |
(omitted from the create form) | Parts are added via operations, not the create form. |
data-testid map
The body-walker sprinkles a stable data-testid on every
interactive element so the page-objects-builder can write
selector-free test code. Walker-stdlib primitives accept an explicit
testid: named arg; the walker threads it through the rendered
component.
| Element | testid pattern |
|---|---|
| List page root | <plural>-list |
| List "Create" button | <plural>-list-create |
| List row | <plural>-row-<id> |
| List row link to detail | <plural>-row-<id>-link |
| List cell | <plural>-row-<id>-<field> |
| New page root | <plural>-new |
| New form input | <plural>-new-input-<field> (nested for VOs: …-<field>-<voField>) |
| New "Create" submit | <plural>-new-submit |
| Detail page root | <plural>-detail |
| Detail field display (primitive) | <plural>-detail-<field> |
| Detail enum badge | <plural>-detail-<field> (on <Badge tt="unset">) |
| Detail VO sub-field display | <plural>-detail-<field>-<voField> |
| Operation button | <plural>-op-<opName> |
| Operation modal form | <plural>-op-<opName>-form |
| Operation modal input | <plural>-op-<opName>-input-<field> |
| Operation modal submit | <plural>-op-<opName>-submit |
| Contained-part subtable | <plural>-detail-<containment> |
| Contained-part row | <plural>-detail-<containment>-row-<id> |
| Contained-part row cell | <plural>-detail-<containment>-row-<id>-<field> |
| Route-level loading fallback (shell) | route-loading (role="status") |
Route-level code splitting
Every routed page is its own chunk; only the shell (layouts, sidebar,
NotFound, error boundary) is in the entry bundle. App.tsx binds each page
as const OrderList = React.lazy(() => import("./pages/orders/list")) and
wraps each layout's <Outlet /> — plus the whole <Routes>, for layout: none pages — in <React.Suspense fallback={<RouteLoading />}>, where
RouteLoading renders the pack's own loader (Loader / CircularProgress /
Spinner / Loader2) with data-testid="route-loading". The generated
smoke spec waits for that fallback to clear before asserting the page did not
crash; the page objects already wait on each page's root testid. Vue
(component: () => import(…)) and Angular (loadComponent) split the same
way — on vuetify each .vue file also imports the components its template
renders (import { VBtn, VCard } from "vuetify/components", derived from the
emitted markup) rather than createVuetify registering the whole library, so
those components ride the page's chunk; SvelteKit splits per +page.svelte on its own. Pinned by
test/generator/_frontend/lazy-route-pages.test.ts.
Page-object classes
page-objects-builder.ts emits one TS module per aggregate at
<deployable>/e2e/pages/<aggregate>.ts. Each module exports three
classes that mirror the page set:
export class <Agg>ListPage {
static readonly url = "/<plural>";
constructor(public readonly page: Page);
goto(): Promise<this>;
create(): Promise<<Agg>NewPage>;
row(id: string): Locator;
open(id: string): Promise<<Agg>DetailPage>;
expectRow(id: string): Promise<void>;
}
export class <Agg>NewPage {
static readonly url = "/<plural>/new";
constructor(public readonly page: Page);
goto(): Promise<this>;
fill(input: Partial<Create<Agg>Request>): Promise<this>;
submit(): Promise<<Agg>DetailPage>;
}
export class <Agg>DetailPage {
constructor(public readonly page: Page, public readonly id: string);
goto(): Promise<this>;
field<K extends keyof <Agg>Response>(name: K): Promise<string>;
// For each `contains <name>: Part[]`:
<name>Row(id: string): Locator;
<name>Count(): Promise<number>;
// For each public operation `op(p1: T1, …)`:
op(input: <Op>Request): Promise<this>;
}
The fill() body branches per type — selectOption for enums (via
the dropdown), .fill() for text inputs, .click() toggles for
bools, slice-to-YYYY-MM-DDTHH:mm for datetime — and walks
nested VO fields.
Design pack selection
The file map above is for the default design: "mantine". Picking a
different pack swaps the rendered component library, the
package.json dep set, and the theme bootstrapping — the rest of the
project (routing, RQ hooks, page objects, smoke tests) is identical.
| Family | Path | Versions shipped | Component library | Latest (bareword resolves to) |
|---|---|---|---|---|
mantine |
designs/mantine/ |
v9 | @mantine/* |
mantine@v9 |
shadcn |
designs/shadcn/ |
v4 | Tailwind + Radix primitives | shadcn@v4 |
mui |
designs/mui/ |
v7 | @mui/material |
mui@v7 |
chakra |
designs/chakra/ |
v3 | @chakra-ui/react |
chakra@v3 |
vuetify |
designs/vuetify/ |
v1 | Vuetify 3 — Vue only | vuetify@v1 |
shadcnVue |
designs/shadcnVue/ |
v1 | Tailwind + Radix Vue primitives — Vue only | shadcnVue@v1 |
flowbite |
designs/flowbite/ |
v1 | Flowbite Svelte — Svelte only | flowbite@v1 |
shadcnSvelte |
designs/shadcnSvelte/ |
v1 | Tailwind + bits-ui primitives — Svelte only | shadcnSvelte@v1 |
angularMaterial |
designs/angularMaterial/ |
v1 | Angular Material — Angular only | angularMaterial@v1 |
primeng |
designs/primeng/ |
v1 | PrimeNG — Angular only | primeng@v1 |
spartanNg |
designs/spartanNg/ |
v1 | Spartan/ui + Tailwind — Angular only | spartanNg@v1 |
coreComponents |
designs/coreComponents/ |
v3 | HEEx core components (baseline) — Phoenix LiveView only | coreComponents@v3 |
daisyui |
designs/daisyui/ |
v1 | HEEx + daisyUI (Tailwind) — Phoenix LiveView only | daisyui@v1 |
Pack selection per platform when design: is omitted:
| Platform | Default family |
|---|---|
react, static, fullstack dotnet (with ui:) |
mantine |
vue |
vuetify |
svelte |
shadcnSvelte |
angular |
angularMaterial |
phoenixLiveView |
coreComponents (default; daisyui also available) |
node, backend-only dotnet |
none (no UI mount) |
Picking a pack also locks in a stack (a coherent React + router +
Zod + Vite + TypeScript dep bundle). Each pack version declares its
stack in its pack.json; the bundler reads that declaration and
emits the matching package.json. See
design-packs.md for the full pack authoring
surface, including the stack catalogue and the recipe for
adding a new pack version.
A bareword design: "mantine" resolves to the family's current default
version; pinning is via family@version (e.g. design: "mui@v7").
The per-version folder under designs/<family>/<vN>/ is what the
bundler actually loads.
Svelte frontend (platform: svelte)
The second frontend generator (src/generator/svelte/). Emits a
Svelte 5 / SvelteKit static SPA per svelte deployable —
@sveltejs/adapter-static with ssr = false and an index.html
fallback, served by vite preview in docker exactly like the React
SPA. Pages flow through the same shared markup walker the React
generator uses (src/generator/_walker/walker-core.ts) with
svelteTarget + a svelte-format design pack (shadcnSvelte default,
flowbite); see D-SVELTE-FRONTEND in decisions.md.
| Concern | Emission |
|---|---|
| Routing | SvelteKit file routing — /orders/:id → src/routes/(app)/orders/[id]/+page.svelte (one chunk per route, by SvelteKit's own splitting); route groups (app) (chrome) / (bare) (layout: none) carry the layout selectors. A named layout <Name> lowers to its own route group (<name>)/+layout.svelte, rendering the header/sidebar/footer slots around {@render children()}; pages selecting it via layout: <Name> route into that group (src/generator/svelte/layouts-emitter.ts). |
| Data | @tanstack/svelte-query v6 factories with the React hook names (useAllCustomers, …) — createQuery(() => opts) returns a runes-reactive object with the .data/.isPending/.mutate surface. Zod schemas are byte-identical with the react modules (shared src/generator/_frontend/zod-schemas.ts). |
| State | let x = $state<T>(init); walker := writes lower to plain assignments; page title: becomes a $effect. Money page-state imports decimal.js so Decimal initial values compile (matches the react fix). |
| Forms | Hand-rolled runes + zod helper (src/lib/forms.svelte.ts): createForm(schema, defaults) → values (deep-reactive bind targets), errors (dotted-path map), submit, applyServerErrors (RFC 7807 422 decode). Field templates bind form.values.<path>. |
| Operation modals | Page-scope {#snippet <op>OpModal(form)} blocks after the markup — one component per .svelte file means module scope lands in the template; primitive-modal renders {@render <op>OpModal(<op>Form)}. |
| Realtime | realtime channels emit a RealtimeHandlers.svelte that subscribes via a $effect to src/lib/api/realtime.ts (shared transport contract src/generator/_frontend/realtime.ts), surfacing a realtime-toast on events — parity with the react realtime client. |
| Extern hatch | extern function → a typed signature stub src/lib/extern/<name>.signature.ts re-exported through $lib/<name>. extern component → a forwarding <Name>.svelte wrapper rendering <Impl {...props} /> against a typed <Name>.props.ts (slots typed as Snippet). |
| e2e | Same testid-keyed Playwright surface as react: page objects from the shared _frontend builders (api imports root at src/lib/api), smoke spec, fixtures, config. test e2e … against <svelte deployable> lowers to a Playwright ui spec, gated at runtime by generated-svelte-e2e.yml (vite preview + the spec). |
| Embedding | Every backend host can embed the SvelteKit SPA (framework: svelte on the ui binding). dotnet/java fullstack hosts mount it under ClientApp/ (Dockerfile copies build/ → wwwroot); phoenix mounts it under assets/ served at /app, with kit.paths.base = "/app" so asset URLs resolve (the fleet-wide base-path fix also covers react/vue). |
| CI | generated-svelte-build.yml — per {example × pack}: npm install + svelte-check --fail-on-warnings + vite build (npm run test:svelte-build locally); generated-svelte-e2e.yml adds the runtime preview + Playwright gate. |
Vue frontend (platform: vue)
The structural mirror of the React frontend, driven by the SAME shared
markup walker (src/generator/_walker/) through vueTarget and the
SAME _frontend/ api/workflows module builders (only the
TanStack Query import specifier differs). Design packs: vuetify
(default) and shadcnVue (reka-ui + Tailwind 4, source-copy). See
D-VUE-FRONTEND and [docs/old/plans/vue-frontend-plan.md](https://github.com/Loom-Harness/Loc/blob/cadbe3ba12fb833565704cb9d628c8f41b2090a8/docs/old/plans/vue-frontend-plan.md).
File map (deltas vs the React project)
web_app/
├── package.json # vue, vue-router, @tanstack/vue-query, zod (+ vuetify/@mdi or
│ # reka-ui/tailwindcss/cva/clsx/tailwind-merge/lucide-vue-next)
├── vite.config.ts # @vitejs/plugin-vue (+ @tailwindcss/vite and the @ alias on shadcnVue)
├── src/
│ ├── main.ts # createApp + router + VueQueryPlugin (+ vuetify instance / globals.css)
│ ├── App.vue # app chrome + <router-view/> + onErrorCaptured boundary
│ ├── router.ts # createRouter(createWebHistory) route table (each page `component: () => import(…)`, its own chunk) + NotFound catch-all
│ ├── theme.ts # createVuetify tokens, no global component registration (vuetify) / `export {}` stub (shadcnVue — CSS vars)
│ ├── lib/form.ts # useLoomForm — vee-validate useForm over the shared zod schema (local toTypedSchema) + per-field error map
│ ├── lib/format.ts # formatting FUNCTIONS (the React packs' format components, fn-style)
│ ├── lib/toast.ts # channels only: reactive pushToast() queue the app-shell host renders
│ ├── api/realtime.ts # channels only: EventSource client (broadcast wire → subscribeRealtime)
│ ├── components/RealtimeHandlers.vue # channels only: renderless on-mount switch → pushToast
│ ├── components/<Name>.vue # user components — typed defineProps SFC, walked <template>
│ ├── lib/<extern>.ts + lib/extern/<extern>.signature.ts # extern frontend fns (shim + signature)
│ ├── components/ui/ # shadcnVue only: source-copied SFCs + index.ts barrel
│ ├── layouts/<Name>.vue + layouts/DefaultLayout.vue # named layouts only: chrome moves out of App.vue, router nests
│ └── pages/**/*.vue # SFC pages — <script setup lang="ts"> + walked <template>
└── e2e/ # identical harness/page objects to React (testid/DOM only)
Key behaviours
- vue-query handles hoist as
reactive(useX(...))so nested refs (.data,.isPending) read uniformly in template + script. - Forms:
useLoomForm(schema, drafts)— vee-validate'suseFormover the shared zod schema (adapted by a locally-emittedtoTypedSchema, since@vee-validate/zod's peer pins zod 3 while the stack is zod 4); create forms, operationv-dialogs (the pack-ownedop-dialogtemplate), and workflow run-forms are wired. - Operation/find hook args, navigate, match-in-child-position, and
state reads/writes all flow through the
WalkerTargetseams — zero forked walker code. - Backend hosts (dotnet / java / phoenix) embed a
framework: vueui exactly like a React one (vue∈ STATIC_BUNDLE_FRAMEWORKS). - User components (
component <Name>(p: T)) emitsrc/components/<Name>.vue(typeddefineProps, walked body); anexterncomponent emits a typed<Name>.props.ts+ a<Name>.tsre-export shim (call sites importcomponents/<Name>without the.vue). Extern frontend functions emit the shared signature + conformance shim (tscis the fail-fast). - Channels (
on <channel>.<Event>) emit the SSE client, a renderlessRealtimeHandlers.vue, and apushToast()queue the app-shell renders (a<v-alert>stack on vuetify, anAlertstack on shadcnVue). - Find-filter live-refetch: a parameterised
findhook takes aMaybeRefOrGetter<Query>(queryKey trackscomputed(toValue(query))) and the list page passes() => ({ … }), so a bound filter input re-fetches (React re-renders, so its hook stays a plain object param). - A
componentbody can host aCreateForm/WorkflowForm(theuseLoomForm+ mutation wiring transplants from the page shell); operation forms (Action dialogs) inside a component also work — the op-dialog host + per-op LoomForm transplant from the page shell, with the instanceidExprread off the aggregate-typed prop (order.id→props.order.id). - Named layouts (
layout <Name> { header / main / footer }) restructure into nested vue-router routes: the layout SFC's inner<router-view />is themainoutlet,layout: nonemounts top-level, and the default chrome moves tosrc/layouts/DefaultLayout.vuewith App.vue a thin host. Default-only uis keep the flat chrome-in-App.vue shape. - A
slotparam on an extern component maps to a typed<Name>Slotscontract (fordefineSlots), kept out of<Name>Props(Vue slots are<slot>template content, not props). - No known gaps — full parity with the React page DSL, with compile-time
(
LOOM_VUE_BUILD) and runtime (LOOM_VUE_E2E) CI coverage.
Angular frontend (platform: angular)
The fourth frontend generator (src/generator/angular/). Emits a standalone
Angular 22 SPA per angular deployable — bootstrapApplication + appConfig
(no NgModules), built with the @angular/build:application builder and served
the same vite preview-style way as the other SPAs in docker. Pages flow
through the same shared markup walker the React / Vue / Svelte generators use
(src/generator/_walker/walker-core.ts) with angularTarget + the
angularMaterial design pack. See docs/old/plans/angular-frontend-plan.md.
Angular renders idiomatic Angular, not a transliterated React app: standalone
@Components, signals/computed for view state, typed Reactive Forms, and —
for server state — TanStack Angular Query (injectQuery / injectMutation),
the senior-Angular-idiomatic caching layer, so reads share a query cache (dedup +
caching) and mutations invalidate exactly the keys the generator knows they touch.
Several walker seams fork the shared (React-shaped) emission rather than reuse it —
each is opt-in (only angularTarget implements it), so the other JSX/markup
frontends (React/Vue/Svelte) stay byte-identical (pipeline-layering + the full
suite gate this).
| Concern | Emission |
|---|---|
| Pages | Each page becomes a standalone @Component under src/app/pages/<slug>.component.ts with an inline template. The route table (src/app/app.routes.ts) maps /orders/:id → the component via provideRouter, lazily (loadComponent: () => import(…), one chunk per page); a wildcard ** mounts NotFound. The app shell (app.component.ts) derives the sidebar from the page set. |
| State / derived | state { x: T = init } → readonly x = signal(init) (read x(), write x.set(…)); the := walker write lowers to .set(…). derived n = expr → readonly n = computed(() => …) (signal reads this.-prefixed in the class-field initialiser). |
| Events | Angular (click) binds a statement, not a function value (and forbids arrow functions) — the renderEventHandler seam inlines the lambda body ((click)='count.set(count() + 1)'). Button(to:) routes through renderNavigateExpr → router.navigateByUrl(<to>). |
| Data | TanStack Angular Query: per aggregate src/api/<agg>.ts ships an @Injectable HttpClient service (the raw requests) + factories the page-shell hoists as component fields — useAll<Agg>s / use<Agg>ById return injectQuery results (cached, keyed ["<tag>"] / ["<one>", id]; byId is enabled: !!id so it stays idle until the route param resolves), and useCreate<Agg> / use<Op><Agg> return injectMutation results whose onSuccess calls queryClient.invalidateQueries on exactly the affected keys. Signals are called (handle.data() / handle.isPending()); the renderQueryDataAccess seam defaults a collection read to [] ((handle.data() ?? [])). The QueryClient is provided once in app.config.ts (provideTanStackQuery). byId reads bind the route param from the ActivatedRoute snapshot. |
| Forms | Idiomatic typed Reactive Forms (the renderCreateForm / renderOperationForm / renderWorkflowForm seams), not react-hook-form: CreateForm(of:), standalone OperationForm(of:,op:) / OperationForm(<inst>.<op>), and WorkflowForm(runs:) each emit a [formGroup] / (ngSubmit) shell over a FormGroup of nonNullable FormControls (per-field Material inputs from src/generator/angular/form-fields.ts), submit → mutateAsync(getRawValue()) (op/workflow wrap as { id, input }) → navigate (the create mutation invalidates the list, so it refetches). DestroyForm(of:) → a confirm-delete mat-raised-button (renderDestroyForm seam) wired to useDelete<Agg>. So the pack ships no field-input-* / form-of templates — the Angular required-primitive surface is display/layout/input only. |
| Operations | Action(inst.op) → a "dumb template" button (renderAction seam): (click)="on<Op><Agg>()" + [disabled]="<localVar>.isPending()", with an async on<Op><Agg>() method that reads the record id inside (a ?.id guard + early return), awaits <localVar>.mutateAsync({ id, input }), then runs the optional then: effect (this.-prefixed). Modal { OperationForm(…) } → a signal-toggled inline Reactive Form (renderModal seam): the trigger captures the record id into an <op>Id signal and flips <op>Open; an @if (<op>Open()) block holds the op FormGroup; submit reads the id signal, mutateAsync({ id, input })s, then closes. |
| User components | A walked component <Name>(p: T) { body: … } emits a standalone @Component class at src/app/components/<Name>.ts (components-emit.ts) — assembled by the SAME shell that assembles a page (renderAngularPage in componentMode), so state signals, derived computeds, action methods, api-read hoists, Reactive Forms and the t / FORMAT_HELPERS / String / Decimal member lifts all apply. Params bind as plain @Input() fields (bare template reads, like a bound route param; NOT required: true — a call site may omit an arg, and the other frontends render that as undefined). A call site uses the component's OWN kebab selector — <app-tier-badge [label]='tier()'> — with the class in the page's standalone imports: [], since Loom stamped that selector itself; an extern component keeps the selector-free NgComponentOutlet form (its @Component({ selector }) is the author's, so Loom has no tag to spell) and its shim sits at src/components/ rather than src/app/components/. Only the tag form has a content-projection channel, so children passed at a call site — Panel("a", Text { "child" }) — reach a walked component's Slot { } (emitted as <ng-content>) and are still dropped on an extern one (loom.component-children-unsupported, D-ANGULAR-EXTERN-CHILDREN). Deferred (call site keeps the walker's give-up comment, never a dangling reference): a slot / action(T) param, and an api read whose ARG reads an @Input() (a read hoists as a class-FIELD initializer, which runs before Angular sets inputs; an arg-less read and a reactive find are supported). |
| Page stub | Only a page with no body (route/title-only) renders a title stub; every form / action / read primitive now renders a real body. The pageNeedsDeferredFeatures predicate is defence-in-depth (the shared react-hook-form formOfs / actionMutations sinks are never populated on Angular — each primitive forks via its render<X>Form seam), and validateRequired keeps any unsupported construct a compile error rather than a codegen crash. |
| CI | generated-angular-build.yml — per {case × pack} (minimal / scaffold / showcase × angularMaterial): npm install + ng build (the Angular CLI typechecks + bundles in one step; npm run test:angular-build locally). |
Feliz frontend (platform: feliz)
The fifth frontend generator (src/generator/feliz/). Emits a Feliz
(F#/Fable/Elmish) SPA per feliz deployable, built with dotnet fable + vite
(not the vite-only static pipeline of the JS frontends). Pages flow through the
same shared markup walker the other frontends use
(src/generator/_walker/walker-core.ts) via feliz-target.ts — but Feliz is
the one frontend whose embedded language is F#, not JS, so instead of
consuming the wire shape only, it supplies its own F# expression leaves
(src/generator/feliz/fs-expr.ts, the FS_LEAVES table) through the shared
emitExpr dispatcher. See docs/old/plans/feliz-frontend-build.md.
| Concern | Emission |
|---|---|
| Architecture | Elmish MVU — a Model record, a Msg union, and an update fn per page (update-emit.ts); page-level state {} fields become Model fields and := writes lower to Msg dispatch + an update arm. |
| Expressions | Rendered to F# via FS_LEAVES (fs-expr.ts) — ==→=, null→None, list [ a; b ], anonymous records `{ |
| Data / wire | Decodes the JSON wire shape into F# records (wire.ts); a paged .all decodes the envelope's items to a 'T list (so the Model holds a list, page 1). |
| Design | The daisyUI pack — real Tailwind + daisyUI build (styles.css + tailwind.config.js), a design: "<theme>" theme picker over the built-in daisyUI theme set (default corporate), and a persistent app-shell navbar for multi-page routed UIs. |
| Forms | Controlled string form state on the Model + per-field blur validation with inline errors (#1944); required/whole-form submit gate. |
| User components | A walked component <Name>(p: T) { body: … } emits an F# function of its props record — `let |
| CI | generated-feliz-build.yml — real dotnet fable + vite build + a Playwright smoke against the built bundle. |
Known frontier (M-T1.16): modal open-state, typed in-flight form state, enum DU wire decoder, per-page sub-models, multi-param routes; and the interactive-Table sort/pagination/filter seams degrade (Elmish needs Set-Msg/update plumbing) rather than emit.
Flutter mobile (platform: flutter, framework: flutter)
The mobile axis — a new development branch outside the web-target matrix, not
a sixth web SPA. src/generator/flutter/ emits a Dart/Flutter (Material 3)
app on Riverpod per flutter deployable. Like Feliz it is self-hosting (own
SDK build, not the vite static pipeline) so it hosts only its own
framework: flutter UI.
One Dart source, three build surfaces. The emitted project's Makefile
builds web (make web → flutter build web, the surface compose serves via
the emitted nginx Dockerfile), Android (make apk), and iOS
(make ipa) from the same UI — "web-vs-native is a build target, not a modelling
mode." The native android//ios/ folders are SDK-owned boilerplate and are
not vendored; make prepare (flutter create --platforms=…) materialises
them on demand. Compose serves only the web surface (mobile artifacts aren't a
compose concern), and CI gates only the web build — see the CI row. Structurally Flutter is a Feliz clone: a
non-JSX, function-call-tree target (Column(children: [ … ])) that rides the
same shared markup walker (walker-core.ts) through flutter-target.ts +
a Dart expression-leaf table, and — like Feliz — supplies its own interChildSeparator
seam because Dart list literals are comma-separated.
| Concern | Emission |
|---|---|
| Architecture | Riverpod — each page with state {}/actions projects an immutable <Page>State + a <Page>Notifier extends Notifier<State> (riverpod-emit.ts); named actions become notifier methods (state = state.copyWith(…)), and the page is a ConsumerWidget binding ref.watch/ref.read. App root wraps ProviderScope. |
| Wire models | lib/models.dart — a Dart class + fromJson/toJson per aggregate/VO/event; discriminated payload unions emit a Dart-3 sealed class + tag-switching factory (exhaustive switch, the analogue of Loom's match). |
| Data / reads | QueryView resolves to a Riverpod FutureProvider (reads-emit.ts) that GETs the collection (unwrapping the paged {items} envelope) or a FutureProvider.family for byId; the widget renders AsyncValue.when(loading/error/data) with empty: folded in. For lowers to .map(...).toList(). |
| Forms | CreateForm/OperationForm/DestroyForm render self-contained StatefulWidgets in lib/forms.dart (forms-emit.ts) — typed field widgets by wire type (TextFormField/SwitchListTile/DropdownButtonFormField/showDatePicker, value objects flattened), a foreign-key X id becomes a runtime-loaded dropdown (initState GETs /<target-collection>, options labelled by the target's derived display), GlobalKey<FormState> validation, http POST/PUT/DELETE via apiUri, pop-on-success. |
| Design | The procedural flutterMaterial pack (src/generator/flutter/pack.ts, Feliz-pack.ts model — emits Material widget trees, no .hbs). |
| CI | generated-flutter-build.yml — real flutter analyze + flutter build web on an interactive showcase (state + reads + forms + routing). Web only — no gate compiles the native apk/ipa surface, so native-only regressions aren't caught per-PR (tracked in GitHub issues, area:frontend). |
Modal { trigger: Button(…), OperationForm(of:, op:) } renders as a trigger ElevatedButton whose onPressed opens an AlertDialog wrapping the op-form widget (showDialog); the op-form pops its own route on success, dismissing the dialog. WorkflowForm(runs: <wf>) renders as a StatefulWidget (like CreateForm) that POSTs the workflow params to the command route /workflows/<wf>.
match await <api>.<Agg>.<op>() (async effect) projects an async Riverpod Notifier method (Future<void> <action>(String id) async): it POSTs the instance op to /<coll>/$id/<op>, reifies a non-2xx ProblemDetails back into the error variant (clobbering the wire type tag), then a Dart-3 switch over result['type'] reifies each arm via fromJson and runs the arm body as a state.copyWith write. The page-shell binds the action as an id-capturing closure (final <a> = () => notifier.<a>(id);) so the button's bare <a>() call is unchanged (riverpod-emit.ts).
View effects from an action body (navigate(<Page>), toast(<expr>)) ship through two generated OUT-OF-TREE bridges, because a ui action projects to a Riverpod Notifier method and a Notifier holds no BuildContext: lib/nav.dart installs a GlobalKey<NavigatorState> on MaterialApp and the statement renders navigateTo('/route'), and lib/toast.dart installs a GlobalKey<ScaffoldMessengerState> and the statement renders showToast(<expr>) (Object?, so a non-string argument coerces). Both files and both MaterialApp arguments are emitted only when some emitted Dart actually calls into them, so an app that uses neither is byte-identical to one built before the bridges existed. A REALTIME handler's toast is in the widget tree (LoomRealtime on MaterialApp.builder) and keeps ScaffoldMessenger.maybeOf(context) instead.
A scalar array form field (tags: string[] / scores: int[]) renders as a repeatable add/remove row list (one TextEditingController per row, managed in state; numeric arrays parse each row on submit).
Stores (store X { … }) project onto the same Riverpod triad a stateful page does (store-builder.ts), and the persist: local|session|url lifetime ladder ships (store-persist.ts): build() seeds each cell from its backing store, a ref.listenSelf mirror writes the whole state back after every transition, local/session ride shared_preferences with setPrefix('') (so the web key is the bare loom.store.<Name> the JS builders write) and url rides Uri.base.queryParameters + SystemNavigator.routeInformationUpdated with a LoomUrlStoreSync back/forward observer. session is the same backing CLEARED at boot — Dart has no per-tab store on every surface. A field type with no total Dart codec (json / File / entity / value-object / optional) is refused rather than silently dropped (loom.store-lifetime-target-unsupported, #flutter-field).
auth: ui ships (auth-gate.ts): a sessionProvider (FutureProvider<CurrentUser?>) over GET /auth/me, sign-in / sign-out redirects to the backend's own handshake via url_launcher, an AuthGate on MaterialApp.builder (spinner → sign-in prompt → routes; on the builder and not around MaterialApp, so its Material widgets have Theme/Directionality ancestors and the app never unmounts mid-probe), a page requires guard rendering ForbiddenView, and Action-button gating on a currentUser-only op requires.
Realtime (on <channel>.<Event>) ships (realtime.ts): one subscription against /realtime/events mounted on MaterialApp.builder, a SnackBar per toast(…), and ref.invalidate(<var>Provider) per refetch(<Agg>) — invalidating a .family refetches every live instance, so a server-paged table reloads the page the user is on. The transport is a conditional import: the browser's own EventSource on the web (package:web; package:http's browser client buffers the whole body, so it cannot stream) and a line parser over a streamed package:http response natively.
ProvenanceInfo renders a native ExpansionTile disclosure over the co-located <field>_provenance (a ProvLineage? on the decoded model — dart-model-emit.ts).
A user component Foo(params) { body } emits a Dart widget into lib/components.dart (component-emit.ts); an invocation Foo(a: x) renders as a widget constructor call and the page imports ../components.dart. A stateless component (value params, no own state) becomes a StatelessWidget (one final field per param, the walked body as build). A stateful component (state {} + named actions) becomes a StatefulWidget whose State holds an immutable <Comp>Model (the same data-class shape a Riverpod page projects), built in initState, exposes each param as a widget.<param> getter, and wraps each action body in setState — reusing the page path's renderNotifierStmt (a write is state = state.copyWith(field: value)). State is per-instance (each Foo(...) its own State), which a shared Riverpod provider would get wrong. A read-bearing component (a QueryView { of: … } body, no own state {}) becomes a Riverpod ConsumerWidget — exactly the shape a read-bearing PAGE takes: build(BuildContext context, WidgetRef ref) hoists final <var> = ref.watch(<var>Provider…) through the same renderApiHoisting seam, and collectFlutterReads scans component bodies so the provider it watches is emitted into lib/reads.dart. Only USED components are emitted; an extern component, a derived binding, an async-effect (match await) action, a store read, a byId read (the route id is a local only a page shell binds), or a stateful component that ALSO reads (that would need ConsumerStatefulWidget) falls back to the diagnostic comment.
An array-of-value-object form field (lines: LineItem[]) renders each row as a group of TextFormFields over the VO's scalar sub-fields (a List<List<TextEditingController>> in state), submitting a {sub: value, …} map per row — when every sub-field is text/numeric (a bool/enum/datetime/nested sub-field defers the whole array).
Known frontier: VO-array fields with non-scalar sub-fields, and the remaining user-component variants (extern / derived / stateful-plus-read / byId-read / store-read / async-effect actions), are deferred (fall back to a diagnostic comment — never broken Dart). Inline := state writes in render-tree lambdas are rejected upstream (loom.effect-in-lambda); named-action writes emit through the Riverpod Notifier — a nested target (draft.address.zip := v) folds into an immutable copyWith chain (state.draft.copyWith(address: state.draft.address.copyWith(zip: v))), so every wire model carries a copyWith. (End-to-end record-typed page state additionally needs the VO.create({…}) state initializer, a separate universal gap — see docs/old/proposals/nested-state-writes-copywith-frontends.md.) See docs/old/plans/flutter-mobile-implementation.md.
Phoenix LiveView fullstack (platform: elixir)
generate system for deployables marked platform: elixir.
Single project that both serves a context-derived API (when serves: is
populated) AND mounts a ui: rendered as Phoenix LiveView modules.
Owns its own Postgres database (needsDb: true). Plain Ecto/Phoenix —
the Ash foundation was removed (and with it the foundation: axis, which had
collapsed to a single value everywhere; a would-be foundation: clause no
longer parses).
File map
For a fullstack phoenixApp with contexts: [Sales] + matching
dataSources: [salesState], serves: SalesApi, ui: SalesAdmin:
phoenix_app/
├── mix.exs # phoenix, phoenix_live_view, ecto, ecto_sql, postgrex
├── .formatter.exs
├── Dockerfile # multi-stage hexpm/elixir → debian, mix release
├── .dockerignore
├── config/{config,dev,prod,runtime}.exs # Phoenix + Ecto config
├── priv/repo/
│ └── migrations/<ts>_create_<table>.exs # one per aggregate, stable ordering, FK indexes
# NOTE: no seeds.exs — `seed` datasets emit NOTHING on
# this backend today (B19 / M-T6.37); the layout adapter
# reserves the slot but no emitter writes it.
├── rel/{env.sh.eex,overlays/bin/server} # release scaffolding
├── lib/phoenix_app/
│ ├── application.ex # supervision tree: Repo, Endpoint, PubSub
│ ├── repo.ex # Ecto.Repo
│ ├── request_context.ex # ambient exec-context carrier (Plug → Logger.metadata: correlation_id/scope_id/actor_id) — see architecture/request-context.md
│ └── sales/ # one folder per BoundedContext
│ ├── customer.ex # Ecto.Schema per aggregate
│ ├── order.ex
│ ├── order_line.ex # entity-part as embedded_schema
│ ├── order_status.ex # enums as Ecto.Enum
│ ├── email.ex # a CONSTRAINED value object → a schemaless-changeset
│ # validator module. An UNCONSTRAINED VO (Money) emits
│ # no module at all — it is a :map column on the owner.
│ ├── events/order_confirmed.ex # plain defstruct modules
│ ├── workflows/place_order.ex # context fns wrapping Repo.transaction
│ ├── dispatcher.ex # in-process event router (when a channel carries a subscribed event)
│ ├── workflows/order_fulfillment_state.ex # saga-state Ecto.Schema (correlation-keyed)
│ ├── workflows/order_fulfillment/start_order_placed.ex # event-create starter handle/1
│ └── workflows/order_fulfillment/on_shipment_requested.ex # on(...) reactor handle/1
│ └── sales.ex # context module — schema list + public functions
├── lib/phoenix_app_web.ex # __using__ macro
└── lib/phoenix_app_web/
├── endpoint.ex # Phoenix.Endpoint
├── router.ex # `live "<route>", <Page>Live` per PageIR
├── controllers/realtime_controller.ex # broadcast channels only: chunked SSE at GET /api/realtime/events
├── components/
│ ├── core_components.ex # <.input>, <.button>, <.modal>, <.simple_form>, <.table>
│ ├── layouts.ex # use Phoenix.Component
│ └── layouts/{root,app}.html.heex
└── live/
├── customer_list_live.ex # one per scaffolded PageIR
├── customer_new_live.ex
└── customer_detail_live.ex
Per-aggregate detail
Aggregate IR maps onto Ecto/Phoenix:
| IR | Ecto/Phoenix construct |
|---|---|
aggregate X { … } |
Ecto.Schema (schema "<plural>" do … end) + a base_changeset/2; persisted via <App>.Repo |
field: T |
field :<snake>, <ecto-type> + validate_required for non-optional |
contains lines: OrderLine[] |
has_many :lines, <App>.<Ctx>.OrderLine (relational) or embeds_many :lines, … (embedded) |
derived total: Money = expr |
a def total(record) function over the struct (<lowered>) |
invariant <pred> when <guard> |
Two carriers. A message-less, unguarded SINGLE-FIELD rule takes an idiomatic native line (validate_number / validate_length / validate_format). Everything else routes to a custom validate_invariants/1 piped onto base_changeset, update_changeset AND the operation-persist path — cross-field comparisons, guarded rules, messaged rules (whose wire code rides the add_error metadata), and, since #3023, member reads, method calls, collection ops and derived reads (sku.trim().length > 0, lines.count > 0, lines.any(l => …), isBig == false). A derived read INLINES its defining expression, because an Ecto struct carries no computed field. A contained collection is normalised (%NotLoaded{} → []) before it is read. The one shape deliberately NOT enforced here is a reference collection (X id[]): its join rows are written after the changeset, so the changeset would read [] and the rule would pass for every input — enforcement in name only. That case, and anything else this carrier cannot evaluate (a domainService / resource call), now raises loom.elixir-invariant-unenforced (a warning) instead of being silently dropped, so an invariant on this backend is either enforced or reported. The shared judgement is src/ir/util/changeset-invariant-carrier.ts, read by both the emitter and the phase-⑦ gate. |
operation op(args) { body } |
a context function def <snake_op>(record, params) (precondition + put_change + Repo.update) |
valueobject Money { … } |
A :map (JSONB) column on the owner's schema — field :total, :map, add :total, :map in the migration — never an embedded_schema, never a custom Ecto.Type (mapTypeToEcto in vanilla/schema-emit.ts returns ":map" for a valueobject unconditionally). A VO carrying a check additionally gets a schemaless-changeset validator module (@types map + cast/3 + the invariant validators + new/1); an unconstrained VO gets no module at all. The wire shape stays the nested { amount, currency } object every backend agrees on — the column layout is a deliberate divergence, recorded in language-reference/03-domain-modeling.md § valueobject. |
event LineAdded { … } |
plain defstruct module under <Ctx>.Events.<Event> |
repository finds: find byCustomer(...) where ... |
a context query function def by_customer(customer_id) = Repo.all(from … where: …) |
workflow placeOrder(...) { ... } |
a context function wrapping Repo.transaction(fn -> with … end) |
emit OrderConfirmed { … } |
Phoenix.PubSub.broadcast(<App>.PubSub, "events", %Events.OrderConfirmed{…}); inside an in-process dispatch handler, emit re-enters <Ctx>.Dispatcher.dispatch(%Events.OrderConfirmed{…}) so choreography chains run. |
on(e: Event) reactor / event-triggered create(e: Event) by … (channel-carried) |
one <Ctx>.Workflows.<Wf>.On<Event> / .Start<Event> module with handle(event), routed by a per-context <Ctx>.Dispatcher that pattern-matches each event struct. Correlation persists through a <Wf>State Ecto.Schema keyed by the correlation field (create loads-or-allocates, on routes-or-drops + logs event_unrouted). An event-triggered-only workflow emits no run/2 / HTTP route / UI form page. See workflow.md §Triggers and channels.md. |
channel X { delivery: broadcast } (realtime SSE, for an SPA frontend) |
<App>Web.RealtimeController — a chunked text/event-stream response at GET /api/realtime/events, subscribed to the SAME "events" PubSub topic every emit above already broadcasts on (so no dispatcher decorator is needed, unlike Hono's realtimeTee). One frame/1 clause per carried event → event: <Type> + camelCase JSON data: carrying type; a 15s event: ping keep-alive. Routed through its own :sse router pipeline — the :api pipeline's plug :accepts, ["json"] would 406 an EventSource. A tenant-scoped event degrades to a refetch ticket (type + <Agg> id refs only). queue-only ⇒ nothing emitted (vanilla/realtime-emit.ts). |
abstract aggregate Party + extends (TPC) |
base emits no schema; each concrete is a standalone Ecto.Schema on its own table; the context module gains list_parties/0 (the union of the concrete list_<concrete>/0 reads). |
abstract aggregate Party + inheritanceUsing: sharedTable (TPH) |
the concretes share one table: each concrete Ecto.Schema declares schema "<base_plural>", a :kind string field defaulted to its own name, and every read self-filters on where: c.kind == "<Concrete>" so it reads/writes only its rows. The base owns no schema; the context module gains the same polymorphic list_parties/0 union reader. See phoenix-tph-emission.md. |
persistedAs: eventLog + apply(...) (event sourcing) |
Supported (src/generator/elixir/vanilla/eventsourced-emit.ts): an append-only <agg>_events stream + apply fold + rehydrator, the elixir sibling of the node/.NET/python/java event stores. |
shape: document persistence |
Supported (CRUD + finds/ops — DEBT-07; Route A) — src/generator/elixir/vanilla/document-emit.ts: the whole aggregate persists as one jsonb blob in an (id, data, version) table, where data is a typed embeds_one :data, <Agg>.Data embed cast via cast_embed (the same validate_required / invariant validators the relational base_changeset runs); reads merge data back over the id. Custom finds filter in memory (`Repo.all |
test "…" { … } |
ExUnit → test/<ctx>/<agg>_test.exs (use ExUnit.Case, async: true) + a once-per-project test/test_helper.exs. (src/generator/elixir/vanilla/tests-emit.ts) ports the full Loom idiom onto a pure domain core emitted on the aggregate module (domain-core-emit.ts): def create(attrs) = base_changeset |> Ecto.Changeset.apply_action(:insert) and def <op>(record, params) = precondition + in-memory mutation — both Repo-free. So Agg.create({…}) → {:ok, p} = Agg.create(%{…}), expect(create({bad})).toThrow() → assert {:error, _} = …, o.op(x) → o = Agg.op(o, %{…}), precondition toThrow → assert_raise, field reads → assert == (money/decimal via Decimal). Verified green under mix test with no DB. A value-object construction invariant (expect(Money{…}).toThrow()) lowers to the VO's validating constructor — assert {:error, _} = Money.new(%{…}) (F5; valueobject-emit.ts emits <VO>.new/1, and the aggregate base_changeset runs it via validate_vo so the invariant is enforced at the real create/update path, not just in tests). A config/test.exs is emitted so mix test can load (never copied into the prod image). See docs/audits/test-parity-generated-backends.md. |
seed [name] [raw] { … } |
Supported (src/generator/elixir/vanilla/seed-emit.ts, M-T6.37) — one <Ctx>.Seeds module per context that declares a seed block, run at boot after migrating and also reachable manually via the emitted priv/repo/seeds.exs (mix run priv/repo/seeds.exs). Domain-path rows go through <Agg>Repository.insert(%{…}) so the aggregate's base_changeset invariants run (a bad seed fails at boot rather than writing a corrupt row); raw rows bypass the domain and emit the shared cross-backend INSERT via renderSeedRowInsert, schema-qualified because Phoenix aggregates live in per-context Postgres schemas. Idempotent per D-SEED-IDEMPOTENCY: a __loom_seed marker table records each applied dataset (ship-once), default always runs and other datasets opt in via the LOOM_SEED env var. Closes B19 — the last silent gap in this table (verified 2026-08-23). |
Per-page detail
PageIR maps onto LiveView:
| IR | LiveView construct |
|---|---|
page X { route, body } |
defmodule <App>Web.<X>Live do use <App>Web, :live_view end at lib/<app>_web/live/<x>_live.ex |
state { step: int = 0 } |
socket.assigns.step; initialised in mount/3 via assign(socket, :step, 0) |
state.step := 1 (in lambda) |
assign(socket, :step, 1) inside generated handle_event/3 |
match { p => v; else => fallback } |
cond do p -> v; true -> fallback end (or <%= cond do … end %> in HEEx) |
requires <pred> (page) |
full handle_params/3 guard — when the predicate fails it put_flash(:error, "forbidden") + push_navigates to / (the read-side UI analogue of an operation's requires; liveview-emit.ts) |
navigate(<P>, {…}) |
push_navigate(socket, to: ~p"/route?…") |
on <channel>.<Event>(e) { toast(…) refetch(Agg) } (ui-level realtime, channels.md Part I) |
native — no SSE client. mount/3 if connected?(socket), do: Phoenix.PubSub.subscribe(<App>.PubSub, "events") (the same topic every domain emit broadcasts on); one handle_info(%<App>.<Ctx>.Events.<Event>{} = e, socket) clause per subscribed event type → toast(<expr>) becomes put_flash(:info, …), refetch(Agg) re-runs the page's list_<agg>s / get_<agg> load (no-op when the page doesn't display it), plus a handle_info(_msg, socket) catch-all. The reactor/saga path uses direct Dispatcher.dispatch/1 calls (never this PubSub topic), so it is untouched. A ui with no on handlers emits byte-identical output (liveview-emit.ts + realtime-liveview.ts). |
| Scaffolded body | pack.render("page-list" | "page-new" | "page-detail", vm) → HEEx inline in render/1 |
| Pack-emitted Playwright page object | e2e/pages/<x>.ts — same testid-keyed shape as React; HEEx HTML is selector-compatible |
The framework-specific seams (state read/write, hook hoisting,
match, navigate, helper imports) live behind the WalkerTarget
interface in src/generator/_walker/target.ts — see
page-metamodel.md §16
for the full mapping table.
Java backend (platform: java)
Spring Boot 4.1 / Spring Data JPA (Hibernate) / Postgres, built with
Gradle (Kotlin DSL, Boot plugin + BOM import, Java 25 toolchain; no
wrapper jar is committed — the generator emits text only, and the
Dockerfile/CI/dev environments provide Gradle ≥ 9.1 — required for the
Java 25 toolchain — or run gradle wrapper once). Emission
lives in src/generator/java/; the surface is src/platform/java.ts
(java@v1). Per-aggregate placement routes through the layout adapter
(byFeature default — package-by-feature; byLayer — package-by-layer),
which owns BOTH the package and the file path.
Per deployable it emits:
| Piece | Files |
|---|---|
| Project shell | build.gradle.kts + settings.gradle.kts (Boot plugin; jMolecules, springdoc, Flyway when migrations exist), Application.java, application.yml (datasource via SPRING_DATASOURCE_* env), multi-stage Gradle Dockerfile |
| Domain | typed-id records (@Embeddable, newId()), enums (DSL-cased constants — the wire), VO records running invariants in the compact constructor, event records implementing a DomainEvent marker (jMolecules-annotated), aggregate/part classes with package-private fields + record-style accessors, create(...) factory, pullEvents(), positional part _create factories |
| Persistence | JPA annotations mirroring the shared MigrationsIR schema (@EmbeddedId typed ids, flattened-VO @AttributeOverrides, unidirectional @OneToMany containments with nullable = false join columns, @ElementCollection join tables for X id[] + value collections, @MappedSuperclass TPC bases); repository triple — domain port (save/findById/getById/findAll/delete + declared finds), Spring Data interface with @Query JPQL finds, @Repository impl mapping misses to 404 |
| Migrations | MigrationsIR → Flyway db/migration/V<ts>.<n>__*.sql via the shared Postgres-SQL renderer |
| API | @RestController per aggregate (POST / 201 {id}+Location, GET /{id}, GET /, POST /{id}/<op_snake> 204, GET /<find_snake>, DELETE /{id}), DTO records in wireShape order (money/datetime as strings), wire validators from the shared invariant classifier → 422 RFC 7807 with errors[], @RestControllerAdvice (400/403/404/422/500 problem+json — 400 only for a malformed body, the domain floor is 422), springdoc /openapi.json brought to cross-backend parity by an OpenApiContractCustomizer (named <Agg>ListResponse array wrappers, RFC 7807 error responses, named enum components, empty request bodies for param-less ops, per-component required sets, Workflow operationId suffixes) |
| Auth | auth: required + user {} → typed User record, UserVerifier boundary + accept-all dev stub, 401 filter, ThreadLocal accessor; currentUser threads into ops as a trailing parameter |
| Workflows | POST /workflows/<snake> via a per-context @Service (loops over Repo.run(...) retrievals incl. the call-site page: tuple, workflow-level emit logging the domain_event envelope) |
| Retrievals / criteria | reified criteria → <Agg>Criteria Specification<T> factories (java consumes CriterionIR directly — the first backend to); run<Name> port methods: an exact-criterion-ref retrieval rides JpaSpecificationExecutor.findAll(spec, Sort), composed wheres fall back to @Query JPQL with order by; paged runs via the OffsetLimitPageRequest Pageable |
| Paged finds | find all(): T paged → Paged<T> envelope over Spring Data Pageable (1-based, page=1&pageSize=20 defaults) |
| Exception-less returns | operation f(): X or NotFound → sealed domain union + variant records, Jackson-polymorphic <U>Response wire DTO (type tag), controller switch: error variants → RFC-7807 ProblemDetail at their mapped status, success → 200 |
| Inheritance | TPC via @MappedSuperclass; TPH (sharedTable) via JPA SINGLE_TABLE — the base owns the shared table + @DiscriminatorColumn("kind"), concretes carry @DiscriminatorValue and share the base <Base>Id |
| Single containments | hidden owning _parent @OneToOne on the part (JPA has no unidirectional one-to-one with the FK on the part table) + inverse mappedBy with cascade/orphanRemoval on the root |
| Seeding | seed blocks → <Ctx>SeedRunner ApplicationRunner: domain rows through create(...) + the port save, raw rows as schema-qualified INSERTs, ship-once __loom_seed marker (default always, others via LOOM_SEED) |
| Capability filters | non-principal predicates → Hibernate @SQLRestriction (static SQL on every SELECT) on relational + shape: embedded roots, in-app findAll().stream() for shape: document; principal (tenancy) predicates → SpEL-principal JPQL clause AND-ed into the scoped findAll/findById overrides + finds/retrievals |
Fullstack (ui:) |
controllers move under /api, SpaWebConfig serves the SPA bundle (UI_DIR, index.html fallback), React project under ClientApp/, node stage in the Dockerfile; the auth filter guards /api/* only |
| Extern ops | per-op handler interface + throwing dev-stub @Component; service runs check<Op> → handler → invariants → save |
| Tests | test "…" → JUnit 5 classes under src/test/java |
| Observability | always-on catalog envelope as flat JSON on stdout (server_starting … server_drained, request bracket) |
Expression rendering is the JAVA_TARGET leaf table over the shared
ExprTarget dispatcher: BigDecimal method arithmetic with compareTo
comparisons, Objects.equals reference equality, Instant ordering via
isBefore/isAfter, find-anywhere regex via Pattern…find(), Streams
collection ops with type-directed sum reduction.
Both the ui: embedded-SPA mount and the D-PHOENIX-SURFACE hosts: UI
hosting are implemented (M-T6.5): a hosts: binding onto a
framework:-owning ui { } block embeds that framework's SPA
(react / vue / svelte) into the Spring project — controllers under
/api/*, SpaWebConfig serving UI_DIR with an index.html fallback,
the SPA under ClientApp/, and a node spa-build Dockerfile stage — the
dotnet hosts: twin, at full hosting parity.
Not yet implemented — every gap fails fast at validate time (never a
silent downgrade): the reserved axon event-store adapter and jooq
persistence adapter (both stubAdapter — the default JPA persistence
does emit persistedAs: eventLog),
resource-op clients, a principal-referencing
capability filter on a non-relational aggregate
(loom.context-filter-no-principal — each half ships alone; only the
actor + jsonb intersection is deferred), and provenance/audited (gated —
no runtime emitted; the node and .NET backends do implement these). See
docs/old/plans/java-backend-implementation.md for the execution record.
Value-object read-model fields ARE implemented. A VO-typed
workflow-instance field or projection row field emits
its <Vo>Response record co-located in the consuming package
(application.workflows for instance/projection reads), the read-model analogue of an
aggregate response's nested VO records. Only an entity (containment
part) read-model field stays gated
(loom.java-workflow-instance-field-unsupported /
loom.java-projection-field-unsupported) — a defensive backstop, since a
part type never resolves in workflow / projection scope.
Discriminated unions (operation returns; union finds take
the untagged optional-style path — see payloads.md), shape: document /
shape: embedded persistence, event-sourced (persistedAs: eventLog)
JPA streams, and non-principal capability filters on those non-relational
shapes are all implemented (no per-backend gate refuses them on java;
PLATFORM_SAVING_SHAPES.java carries all three shapes).
Names that are Java reserved words
A .ddd member, parameter, operation or enum value may be called case,
do, final, native, … — anything the .ddd grammar admits. Java has no
verbatim identifier (JLS §3.9), so the emitted host identifier is mangled
with a trailing underscore (case → case_, the spelling the emitters
already used for let bindings), and the wire spelling is pinned back on
wherever that identifier would otherwise have become the wire name:
| position | what is emitted |
|---|---|
| request / response / event / VO / projection-row / workflow-instance record component | @JsonProperty("case") String case_ — so the JSON body and the springdoc schema key both read case |
a find parameter |
@RequestParam("case") String case_ — the query key stays ?case= |
| a validation error | the advice's pointer mapper inverts the mangle, so the 422 names /case (Spring builds a binding path, which never goes through Jackson) |
the ?sort= whitelist |
the wire key, with one generated line translating it to the JPA property |
| the column | unchanged — @Column(name = "case"), quoted since M-T6.42/M-T6.43 |
| an enum value | @JsonProperty("case") case_ plus a generated <Enum>.Codec AttributeConverter, because @Enumerated(STRING) would otherwise persist Enum.name() and write case_ into the column |
Nothing about the wire, the OpenAPI document or the stored data differs from the other four backends; the mangle is visible only in the generated Java. Emission for a keyword-free model is byte-identical to before.
Python backend (platform: python)
FastAPI / SQLAlchemy 2 (typed declarative, async) / asyncpg / Postgres,
managed by uv (Python 3.12) and held to ruff check + mypy --strict + pytest by the LOOM_PYTHON_BUILD gate. Emission lives in
src/generator/python/; the surface is src/platform/python.ts
(python@v1; python is the only spelling — the fastapi platform alias
was retired the way phoenix/hono were). Async end-to-end: async def
handlers over a per-request
AsyncSession — repositories flush(), the session dependency commits
once after the handler returns, so multi-save workflows are atomic by
construction.
Per deployable it emits:
| Piece | Files |
|---|---|
| Project shell | pyproject.toml (uv, pinned deps, ruff/mypy/pytest config), app/main.py (lifespan: verifier/extern asserts → migrations → seeds; CORS; /health + /ready), app/settings.py, app/db/engine.py, python:3.12-slim + uv Dockerfile |
| Domain | NewType-branded str ids, StrEnums, VO classes with invariant ctors, frozen-dataclass events + dispatcher protocol, aggregate/part classes (private state, @property accessors, create factory, pull_events()) |
| Persistence | app/db/schema.py SQLAlchemy models (Mapped[...], dataSource-routed __table_args__ schema, Uuid(as_uuid=False) ids, flattened VO columns, join tables), per-aggregate repositories (find_by_id/get_by_id/all/declared finds/save diff-sync/to_wire from wireShape), where clauses lowered to SQLAlchemy predicates incl. correlated-EXISTS contains |
| Migrations | MigrationsIR → migrations/*.sql via the shared Postgres-SQL renderer + app/db/migrate.py, a __loom_migrations-tracked boot-time runner (also python -m app.db.migrate) |
| API | APIRouter per aggregate (POST / 201 {id}, GET /, GET /{id}, POST /{id}/<op_snake> 204, GET /<find_snake>, DELETE /{id} with 409), Pydantic wire DTOs in wireShape order, named <Agg>ListResponse array components, paged carriers, discriminated-union operation returns (union finds are untagged, optional-style), RFC 7807 handlers (+ §3.2 errors[] on 422), the shared per-route error matrix re-keyed to application/problem+json by an install_openapi post-processor |
| Inheritance / ES | TPC + TPH (kind-scoped shared table, base readers), persistedAs: eventLog append-only stream + applier folds |
| Sagas | app/dispatch.py in-process dispatcher when a channel routes a subscribed event — create(e) by … load-or-allocates the persisted correlation row, on(e) by … routes or drops + logs event_unrouted; handler emits re-enter |
| Auth | auth: required + user {} → app/auth/ (frozen User dataclass, verifier registry, middleware with the cross-backend bypass list) + a dev-stub verifier registered in main.py; current_user threads into ops/finds/workflows as a trailing parameter |
| Seeds / extern | app/db/seed.py (__loom_seed ship-once marker, LOOM_SEED gating, schema-qualified raw INSERTs); app/domain/<agg>_handlers.py extern registries (TypedDict requests, dev-stubs, boot verify) with routes running load → check_<op> → handler → assert_invariants → save |
| Fullstack | ui: embeds the React SPA (dotnet parity): routers under /api/*, ClientApp/ generation, wwwroot FileResponse fallback, multi-stage Dockerfile |
| Tests | test "…" → pytest under tests/ (synthetic admin actor threads into gated ops) |
| Observability | app/obs/ — flat-JSON catalog envelope (ts/level/event/request_id) on stdout, lifecycle bracket, request bracket middleware with x-request-id correlation, fault warns in the problem handlers |
Expression rendering is the PY_TARGET leaf table over the shared
ExprTarget dispatcher: native Decimal money arithmetic,
comprehension-shaped collection ops, re.search regex, snake_case
member folding.
Conformance: a pythonApi deployable ships in examples/showcase.ddd
and the e2e OpenAPI cross-check compares it pairwise against Hono /
.NET / Phoenix / Java (all five backends are diffed — strict in
conformance-parity.yml).
Resource verb clients (objectStore → boto3, queue → aio-pika,
api → httpx, mailer → aiosmtplib) are emitted under
app/resources/<sourceType>.py; workflow / saga <resource>.<verb>(...)
calls import + await them. (Cross-backend the mailer client is
nodemailer / MailKit / Swoosh / Jakarta Mail — see
resources.md for the full kind × backend matrix.)
Not yet implemented (fails fast / follow-ups): durable-channel
outbox tier, --trace domain instrumentation, and provenance/audited
(gated — no runtime emitted; the node and .NET backends do implement
these). shape: document / shape: embedded persistence (all three
shapes are in PLATFORM_SAVING_SHAPES.python) and when can-queries
are implemented.
Names that are Python keywords
The python twin of Java's reserved words
(eval item 6, ruling D-TARGET-RESERVED-NAMES).
Python has no verbatim identifier, and every .ddd member lands in an
identifier position — a keyword argument, an attribute, a def name — so one
funnel in src/util/naming.ts spells them all: pythonIdent(name) (the
snake_cased domain/ORM spelling, def → def_) and pythonWireIdent(name)
(the camelCase pydantic spelling, def → def_). The two names the outside
world sees are pinned back on:
aggregate Thing with crudish {
def: string
operation class(except: int) { … }
}
repository Things for Thing { find byDef(def: string): Thing? where this.def == def }
| position | what is emitted |
|---|---|
| domain class | def __init__(self, *, def_: str, …), @property def def_(self), def class_(self, except_: int) |
| row model | def_: Mapped[str] = mapped_column("def", Text) — the column stays def |
| pydantic request / response / VO model | def_: WireStr = Field(alias="def") — the JSON key, the OpenAPI key and a 422's loc stay def |
a find query parameter |
def_: Annotated[WireStr, Query(alias="def")] — ?def= |
| an enum value | lambda_ = "lambda" — the stored and wire value stay lambda |
the ?sort= whitelist |
{"def": "def_"} — wire key → ORM attribute |
Emission for a keyword-free model is byte-identical: pythonIdent equals
snake for every other name. Not yet funnelled: folded / query-time
projection read models and the auth.user claim record still spell a member
name bare (a keyword there remains a syntax error, not a silent defect).
Generated-helper collisions. A member may also take the name of a private
helper the entity emitter generates. The invariant check was the one that
collided: .NET emitted private void AssertInvariants() beside the
AssertInvariants property of a member assertInvariants (CS0102), node's
backing field private _assertInvariants: string duplicated the method, and
python's backing field self._assert_invariants overwrote the method (a
TypeError on the first invariant check). On all three the helper now steps
aside — AssertInvariants_ / _assertInvariants_ / _assert_invariants_ —
only when a member takes its name. Java (_assertInvariants beside the
field assertInvariants) and Elixir (functions and struct keys are separate
namespaces) never collided. Pinned by the python-reserved-words corpus row
(every compile tier + the wire-golden differential) and
test/generator/target-reserved-member-names.test.ts.
The per-target reserved-identifier table (every backend)
The Java and Python sections above are two instances of one contract. Every
target's reserved words now live in ONE table,
src/util/target-identifiers.ts — ts, csharp, java, python,
elixir, fsharp, dart — with ONE escaping path,
escapeTargetIdent(target, name) (C# @base, F# ``member``, a
trailing _ everywhere else) and a pure isReserved(target, name). The
per-language helpers (escapeCsharpIdent / escapeTsIdent / escapeJavaIdent /
escapePythonIdent / escapeElixirIdent in naming.ts, java's jid and
package segments, F#'s fsIdent, Dart's dartMember) are views of it, so no
backend carries a keyword list of its own. One table closes the drift the
per-backend copies had: Java's two copies disagreed on record / yield / sealed / permits; TS lacked the
strict-mode arguments / eval; C# lacked await (illegal inside the async
handlers every command runs in); python lacked the method receivers self /
cls and the builtin / BaseModel names a generated class body shadows
(str, dict, …).
A second table, escapeTargetMember, holds names a target reserves as a class
MEMBER although they are legal bindings — TS constructor and then (an
operation named then makes every aggregate a thenable, so await repo.getById(…) breaks), and Dart's inherited Object members (hashCode,
toString, noSuchMethod, runtimeType).
Scope collisions are a separate concern and stay with each backend's namer:
a .ddd name landing on a local the generated method declares for itself
(javaLocals, csLocalNamer, csParamIdent, dartComponentMember) is not
a keyword, and those namers build on the table rather than replace it.
The gate is test/fixtures/corpus/target-reserved-words.ddd (row ALL):
every table word the .ddd grammar admits, as a field, an enum value, an
operation, and that operation's parameter — ~180 names at once — on every
backend's compile leg (tsc, dotnet build /warnaserror incl. Dapper, gradle,
uv + ruff + mypy + pytest, mix compile --warnings-as-errors).
test/util/target-identifiers.test.ts keeps the table and the fixture from
drifting (a table word the grammar admits but the fixture omits fails), and
checks the tables with an authoritative source against it (python's keyword
module, the TypeScript scanner's reserved-word range).
| backend | positions newly escaped by this gate |
|---|---|
| node | every parameter declaration + param reference (break_: number); operation / helper method names then / constructor |
| .NET | enum members (@break, @base — the runtime name and the wire value stay break); domain-service, workflow-function and extern-hook parameters (@event, @base); CA1720 stays in the csproj NoWarn (member names are domain vocabulary) |
| python | receivers + builtin names via the existing funnel; the repository's SQLAlchemy / app.db.wire imports are detected by CALL, not by word (an escaped field and_ is not sqlalchemy.and_) |
| elixir | elixirLocal(name) for every parameter / bound op input (do_); elixirOpAction(name) for a controller action (def do_, and show / index step aside to show_op from the CRUD read actions); an OpenApiSpex schema with a reserved property key opts out of the derived struct (struct?: false) |
| java | already clean through jid (M-T6.36) |
Not covered yet — tracked by #4213: a FIELD named constructor on node
(its getter cannot carry that name), TYPE names that shadow a target's
globals / BCL / framework types (valueobject Promise / String / Symbol on
node; .NET handles Task-like names via dotnet-bcl-type-collision), and the
frontends (the fixture declares no ui).
System orchestration
generate system (in src/system/index.ts) runs each deployable
through its respective backend, scoped to its hosted contexts, and
writes everything to a flat tree:
<outdir>/
├── api/ # one folder per deployable
├── catalog_web/
├── web_app/
├── docker-compose.yml # postgres + every deployable + healthchecks
├── docker-compose.obs.yml # opt-in: prometheus + jaeger + the OTLP export env
├── monitoring/prometheus.yml # the overlay's scrape config, one job per backend
├── db-init/
│ └── 00-create-databases.sql # CREATE DATABASE per backend deployable
└── e2e/ # generated DSL-level e2e suite (vitest+fetch)
├── package.json
├── tsconfig.json
└── <System>.e2e.test.ts
Per-platform compose service shape
| Platform | Internal port | Env | Depends on db |
Healthcheck path |
|---|---|---|---|---|
dotnet |
8080 | ConnectionStrings__Default=Host=db;Port=5432;Database=<slug>;… |
yes | /ready |
node |
3000 | DATABASE_URL=postgres://…/<slug> |
yes | /ready |
react / static (also vue, svelte) |
3000 | VITE_API_BASE_URL=…/api (build-time base override; the bundle defaults to a relative /api) + VITE_API_PROXY_TARGET=http://<backend-svc>:<port> |
no | / |
phoenixLiveView |
4000 | DATABASE_URL=ecto://…/<slug>, SECRET_KEY_BASE, PHX_HOST, PHX_SERVER=true, PORT=4000 |
yes | /health |
Frontend same-origin in compose. A vite-served frontend's built bundle
fetches /api relative, and its image runs vite preview. So compose
injects VITE_API_PROXY_TARGET (→ the target backend's compose service,
e.g. http://api:3000, not localhost) and the generated vite.config.ts
proxies /api to it on both server (dev) and preview (the compose
runtime). Result: one origin, no CORS, no separate API host — the compose twin
of the k8s same-origin Ingress. (Local vite dev falls back to the baked
http://localhost:<port> target.)
The platform contract decides UI mount admissibility and DB ownership
via two PlatformSurface flags (src/platform/surface.ts):
mountsUi (true on react, static, phoenixLiveView) and
needsDb (true on dotnet, node, phoenixLiveView). The system
orchestrator consults these instead of hardcoding platform names, so
adding a new platform extends the registry + the two flags only.
Per-deployable databases
The init script db-init/00-create-databases.sql creates one
database per backend deployable. Each backend's connection string
points to its own DB. This sidesteps a real bug observed on .NET:
EF Core's EnsureCreated is all-or-nothing per database, so two
backends sharing one DB silently leave the loser-of-the-race's
tables uncreated. React deployables don't connect to a DB and don't
appear in the init script.
Every statement is idempotent (SELECT 'CREATE DATABASE x' WHERE NOT EXISTS (…) \gexec): Postgres runs docker-entrypoint-initdb.d only on an
EMPTY volume, so the compose stack also carries a one-shot db-bootstrap
service that re-applies the same file over TCP on every up, and each
database-backed service waits for it with condition: service_completed_successfully. A deployable added in a later
generation therefore gets its database on an existing volume (B-26 of the
banking evaluation — it used to crash-loop on database "x" does not exist).
Migrations
Schema changes flow through a platform-neutral MigrationsIR
(src/ir/types/migrations-ir.ts),
one per entry of the committed migration chain (migrations/ beside
the .ddd, recorded by ddd migrate add). The builder
(src/system/migrations-builder.ts)
derives each module's delta against the chain's head for every
(subdomain, storage) pair owned by a deployable (migrationsOwner
enrichment in src/ir/enrich/enrichments.ts). Backends only translate
steps to their syntax via the per-platform emitters listed below — they
never recompute the schema themselves. Shared SQL rendering for the
Postgres-backed emitters lives in
src/generator/sql-pg.ts.
| Backend | Emits | Applied by |
|---|---|---|
| Phoenix | priv/repo/migrations/<ts>_<name>.exs (Ecto DSL) |
mix ecto.migrate at boot via the existing release config |
| Hono | db/migrations/<version>_<name>.sql + db/migrations/meta/_journal.json |
the generated db/migrate.ts: await migrate(db, { migrationsFolder }) in index.ts reads the journal + .sql files and tracks state in __drizzle_migrations as Drizzle's migrator does, and applies a -- loom:no-transaction file (a CREATE INDEX CONCURRENTLY unit) outside a transaction. npm run db:migrate runs the same migrate out of band (tsx db/migrate-cli.ts, a pool from DATABASE_URL); drizzle-kit migrate is not wired, since it runs every file inside one transaction |
| .NET | Migrations/<Version>_<Name>.cs (migrationBuilder.Sql(@"...")) |
db.Database.Migrate() in Program.cs after builder.Build(); no ModelSnapshot is emitted — Loom owns SQL generation, so dotnet ef migrations add is never run and the runtime migrator is happy without one |
Phoenix stays in Ecto DSL because its output is Elixir. Hono and
.NET share src/generator/sql-pg.ts for bit-identical Postgres DDL.
Every generate system renders the whole chain: one file per backend
per recorded generation, the first of each subdomain its "Initial".
Recording a new property with migrate add adds a single
ALTER TABLE … ADD COLUMN … per backend. A source with no chain yet
renders each subdomain's "Initial" derived from the model.
Regenerating is deterministic: the same source and chain render the
same files into any output tree. Renames are declared in a migration
block (see migrations.md).
test e2e lowering
A test e2e "name" against <deployable> { … } block lowers via
src/system/e2e-render.ts into typed fetch calls in
<outdir>/e2e/<System>.e2e.test.ts:
| DSL form | Lowered to |
|---|---|
api.<aggregate>.create({ … }) |
__post(\${base}/ |
api.<aggregate>.getById(idExpr) |
__get(\${base}/ (.idauto-appended for knownlet`-bindings) |
api.<aggregate>.<op>(idExpr, body?) |
__post(\${base}/ |
api.<aggregate>.<find>(args) |
__getQuery(\${base}/ |
expect(<x>).<matcher>(…) lowers to the native matcher;
expect(<call>).toThrow() becomes
expect(() => <call>).toThrow().
What the generators don't do
Out of scope for v1 (intentional):
- Production identity provider:
auth: required+ auser {}block emit a first-class auth surface on every backend — a typed principal, a request boundary (401),requires403 gates on operations / pages,currentUser/tenancy capability filters, and an OIDC turnkey verifier — but the default verifier is an accept-all dev stub. Wiring a real IdP (or replacing the stub) is the deployment's job; seeauth.md. (Fine-grained RBAC beyond predicaterequiresis not modelled.) Pagination on— stale, corrected 2026-09-10. The implicitfindAllfind allis paged by default (M-T2.6): enrichment synthesises it with apaged<T>return rather thanT[](src/ir/enrich/enrichments.ts, theallFindIR), and the four shapes that stay bareT[]each have a reason recorded at the site —persistedAs: eventLog(the read folds a stream, there is noLIMIT/OFFSETquery),shape: document(one opaque JSONB blob, no queryable column to page orORDER BY),shape: embedded(backend support for paging its queryable root diverges, so it stays uniform rather than paged on some), and an inheritance subtype (the polymorphicfind all <Base>reader concatenates each subtype'sall(), which cannot be page-sliced correctly before the merge). The IR carriespagedonly where a controller honours it, so the flag is never a lie.- Multi-target frontends: a
reactdeployable has exactly onetargets:. Hosting against several APIs is deferred. Typeahead lookups for— stale, corrected 2026-09-10. AnX idform fieldsX idfield renders a Select picker populated from the target's own list hook whenever the target declaresderived display: string(prepareFormFieldVM→field-input-id-select,src/generator/_walker/form-fields-vm.ts); the option label is read off the wiredisplayfield, so a compound (firstName + " " + lastName), member-access or conditional display works without the picker knowing the source expression. Several packs make it searchable client-side (mantine's<Select searchable>). It falls back tofield-input-id-textin exactly two cases — the target aggregate is unresolved, or it declares noderived display— and the text input's placeholder then names which. What is still out of scope is a server-side typeahead: the picker loads the target's list, so a target with more rows than a page is not searchable beyond what it loaded.- Ordering on
X id[]collections:Target id[]is a set (D-REFCOLL-SET). On all five backends the join table is only the composite(owner, target)key — noordinalcolumn — and every backend reads the rows back sorted by the target FK id (Drizzle/EF/Dapper/SQLAlchemyORDER BYit, JPA's argument-less@OrderBy, Ecto'spreload_order: [asc: :id]). So the order is stable, but it is an id order the domain never chose: treatparty[0]as "some element ofparty," not "the first element ofparty." When position is part of the domain (a battle slot, a draft pick number), model it as an explicit ordinal field on a separate child aggregate instead of relying on collection order. - Server-side rendering: client-only Vite. Next.js variant would be a separate platform.
- Generated CI pipelines: project-init concerns, not derived from
the
.dddsource. (k8s manifests are no longer on this list — the opt-ingenerate system --k8sflag emits a Helm chart + raw manifests; seekubernetes.md.)
These are all addressable as either generator extensions or
.loomignore-pinned customizations.