← Back to home

Architecture — system-level composition

Companion to page-metamodel.md. That doc covers the page DSL surface (pages, components, scaffolding, state, match, lambdas). This doc covers the system-level layered composition model: how domain, api contracts, storage instances, UIs, and deployables fit together.

The system DSL has five composable layers, each declared independently. Deployables are the explicit composition root.

context     →   domain primitives (aggregates / workflows / views)
subdomain   →   group of contexts                                  [domain]

api         →   contract derived from a subdomain                  [contract]
storage     →   typed physical store / service                     [infra]
resource    →   (context, kind) → storage binding + config         [infra]
ui          →   declares api dependencies, renders pages           [consumer]

deployable  →   composes platform + contexts + api + UI + resources  [composition]

Read any single declaration and you see its full picture; no implicit cross-references between layers.

File organisation is independent of this layering. A project is one system, but its members need not live in one file: any subdomain and every deployment declaration (storage / resource / channelSource / ui / deployable / theme / user / test e2e) may be written at the top level of any .ddd file in the import graph and composes into the project's single system — so a project can be split one-file-per-subdomain with the deployment in its own file. See proposals/implicit-system-composition.md.

Domain layer

subdomain and context define pure domain — aggregates, repositories, workflows, views. Persistence-agnostic; same domain runs against Postgres in prod, in-memory in tests.

subdomain Sales {
  context Orders {
    aggregate Customer { name: string, email: string }
    repository Customers for Customer {
      find byEmail(email: string): Customer?
    }
    aggregate Order { customerId: Customer id, total: decimal }
    payload Checkout { customerId: Customer id, items: int[] }
    workflow checkout { create(c: Checkout) { } }
  }
}

API contracts

api X from <Subdomain> declares a contract derived from a subdomain's domain. The api auto-exposes:

Domain entity Api operations
Aggregate <X> all, byId, create, update, delete
Repository <R> for X { find <name>(args): T } <name> (named query)
Workflow <w> <w> (mutation)
View <v> <v> (query)
api SalesApi from Sales

UIs reference apis via UI parameters (next section); backend deployables serves: apis.

Storage instances

storage X { type: T } declares a typed physical store or service — reusable across deployables, contexts, and bindings. type: names the built-in sourceType that realizes it (see resources.md). v0 type enum:

Category Types Bound to a resource kind:?
Transactional postgres, mysql, sqlite, inMemory yes — state / eventLog / snapshot / replica / cache
Cache redis yes — cache
Search elastic, meilisearch no — recognised, but no kind accepts them
Events kafka yes — eventLog
Analytics clickhouse, bigquery no — recognised, but no kind accepts them
Object store s3, localDisk yes — objectStore
Queue rabbitmq yes — queue
Queue (unbound) nats no — kind: queue takes rabbitmq only
External API restApi yes — api
Mailer smtp, ses, sendgrid yes — mailer

The rows marked no parse as a type: and bind to nothing: there is no search kind and no analytics kind, and queue admits rabbitmq alone — so every resource … use: <that storage> is refused by loom.kind-incompatible, and no sidecar or client is emitted. See resources.md.

storage primarySql   { type: postgres }
storage hotCache     { type: redis    }
storage files        { type: s3, config: { region: "eu-central-1", bucket: "app-files" } }
storage jobBus       { type: rabbitmq }

postgres has full persistence support; s3 / rabbitmq / restApi activate dev-compose sidecars + per-backend client emission (consumed from workflows — see resources.md). The remaining types parse + validate but don't yet activate generator output. A config { k: v } map carries vendor parameters, validated per sourceType.

Resource bindings

storage says what physical store exists; resource (renamed from dataSource) says which context's data of which kind lands where. The split (D-STORAGE-SPLIT) means a single storage instance can back multiple contexts (each in its own Postgres schema), and a single context can route different data kinds (state vs eventLog vs cache) to different stores. The full model — sourceType registry, capabilities, interfaces, and workflow-level consumption — is in resources.md; this section covers the persistence-routing essentials.

use: binds one of two things, and the distinction is about who knows the address. A storage is infrastructure Loom does not own, so its location is a fact only the author has (config: { baseUrl: … }). An api is infrastructure Loom does own — a sibling deployable in this system serves: it — so both the address and the operation set are derivable from the model. Binding the api (resource orders { for: Shipping, kind: api, use: OrdersApi }) is therefore how one Loom service calls another: compose injects the address and each backend emits a typed client against the callee's real operation set. See resources.md → Calling another Loom service.

resource ordersState {
  for: Orders, kind: state, use: primarySql
  // optional: schema, tablePrefix, isolationLevel, ttl, every, retain, shape, config, …
}
resource ordersCache {
  for: Orders, kind: cache, use: hotCache, ttl: 60
}

The surface kind: matches the storage's sourceType via an enforced compatibility matrix:

Kind Compatible storage types Aggregate predicate
state postgres, mysql, sqlite, inMemory at least one persistedAs: state aggregate (the default)
eventLog postgres, mysql, sqlite, inMemory, kafka at least one persistedAs: eventLog aggregate
snapshot postgres, mysql, sqlite, inMemory at least one persistedAs: eventLog aggregate (snapshot policy)
cache redis, inMemory any aggregate
replica postgres, mysql, sqlite any aggregate
objectStore s3 consumed from a workflow (files.put(…) etc.)
queue rabbitmq consumed from a workflow (jobs.enqueue(…))
api restApi consumed from a workflow (rates.get(…))

Defaults applied at emit time:

Backend deployables list which resources they wire up via the dataSources: clause (see "Backend deployables" below). The validators enforce that every hosted (context, persistence-kind) pair has a matching binding, that every listed binding actually routes data, and that knob/kind/ storage triples are coherent.

A derived view of the resolved routing is emitted to .loom/datasources.md — see loom-artifacts.md.

UI consumer

ui X declares pages, components, and api parameters — local handles for the api contracts the UI needs.

ui WebApp {
  api Sales: SalesApi             // local handle `Sales` of contract `SalesApi`
  api Mktg:  MarketingApi

  page CustomerList {
    route: "/customers"
    body: For { Sales.Customer.all.data, c => Card { c.name } }
  }

  page CustomerNew {
    route: "/customers/new"
    state { name: string = "" }
    body: Stack {
      Field { "Name", bind: name },
      Button {"Save",
        disabled: Sales.Customer.create.isPending,
        onClick: e => { Sales.Customer.create.mutate({ name }) }}
    }
  }
}

The <param>.<aggregate>.<op> body refs (e.g. Sales.Customer.all) are validated at parse-validate time:

The walker auto-injects React Query hooks at page top following a published naming rule:

DSL body ref Generated hook Local var
<agg>.all useAll<Plural>() <aggCamel>All
<agg>.byId(x) use<Single>ById(x) <aggCamel>ById
<agg>.create useCreate<Single>() <aggCamel>Create
<agg>.<finder> use<Finder><Single>(args) <aggCamel><Finder>

So Sales.Customer.all.data becomes:

import { useAllCustomers } from "../api/customer";

const customerAll = useAllCustomers();
// ... customerAll.data ...

User can read the generated file and trace any DSL ref to its runtime artifact. No magic.

Deployable composition

The deployable is the only place layers compose. Each declaration is self-contained.

Backend deployables

deployable salesApi {
  platform: node                       // runtime
  contexts: [Orders, Customers]        // domain contexts hosted
  dataSources: [ordersState, customersState, ordersCache]  // routing
  serves: SalesApi                     // contract this deployable implements
  port: 3000
}

The serves: field lists api contracts implemented by this backend. The contexts: field names which bounded contexts this deployable hosts. The dataSources: field lists the system-scope resource decls that route those contexts' persistence — see "Resource bindings" above.

Validators enforce:

Multiple contexts hosted by one deployable is the common case:

deployable monolithApi {
  platform: dotnet
  contexts: [Orders, Marketing]
  dataSources: [ordersState, marketingState, ordersAuditLog]
  serves: SalesApi, MarketingApi
  port: 8080
}

Frontend deployables

deployable webApp {
  platform: static
  targets: salesApi               // the backend(s) this frontend talks to
  ui: WebApp {                    // bind UI params to backends
    Sales: salesApi               //   `Sales` ← salesApi (which serves SalesApi)
    Mktg:  marketingApi
  }
  port: 3002
}

The ui: WebApp { Sales: salesApi, Mktg: marketingApi } block is the explicit composition: each UI api parameter (declared in ui WebApp { api Sales: SalesApi }) is bound by name to a backend deployable that supplies its contract.

Validator obligations:

Sugar form ui: WebApp (no compose-block) is only admissible when the UI declares no api parameters.

Fullstack deployables (platform: elixir)

An elixir deployable collapses backend + frontend into one project. It both serves: a context-derived API AND mounts a ui:, without a peer targets: link.

deployable phoenixApp {
  platform: elixir
  contexts: [Orders]
  dataSources: [ordersState]
  serves:   SalesApi
  ui:       SalesAdmin
  port:     4000
}

Validator obligations specific to fullstack platforms:

The platform contract knob mountsUi: boolean on PlatformSurface (src/platform/surface.ts) decides UI-mount admissibility — adding a new fullstack platform extends that field plus the Platform enum, nothing else.

End-to-end example

system Acme {
  subdomain Sales {
    context Orders {
      aggregate Customer { name: string }
      repository Customers for Customer { find byEmail(email: string): Customer? }
    }
  }

  api SalesApi from Sales
  storage primarySql { type: postgres }
  resource ordersState { for: Orders, kind: state, use: primarySql }

  ui WebApp {
    api Sales: SalesApi
    page Home {
      route: "/"
      body: Sales.Customer.all.isLoading
        ? Loader {}
        : For { Sales.Customer.all.data, c => Card { c.name } }
    }
  }

  deployable salesApi {
    platform: node
    contexts: [Orders]
    dataSources: [ordersState]
    serves: SalesApi
    port: 3000
  }

  deployable webApp {
    platform: static
    targets: salesApi
    ui: WebApp { Sales: salesApi }
    port: 3001
  }
}

What the reader gets from any single declaration:

Read this... ...and you know
subdomain Sales { ... } a logical grouping; the domain lives in its context children
context Orders { aggregate Customer { ... } } the domain — pure, persistence-agnostic
api SalesApi from Sales the contract — derived from a subdomain
storage primarySql { type: postgres } a typed physical store
resource ordersState { for: Orders, kind: state, use: primarySql } which context's data of which kind lands where
ui WebApp { api Sales: SalesApi, ... } the UI takes Sales of contract SalesApi
deployable salesApi what's served, which contexts hosted, which resources wire them up
deployable webApp what UI runs, which backend fills each api param

Scaffold expands to walker stdlib (Slice C2 / D1)

The scaffold subdomains: [M] directive (page-metamodel §10) keeps working — but as compile-time sugar. Synthesised pages now lower to explicit walker-stdlib bodies via src/ir/lower/walker-primitive-expander.ts, called at the end of lowerModel as the final sub-pass of lowering (see docs/technical.md phase ⑤c):

scaffold aggregates: Order
  ↓ (AST expander synthesises pages with scaffoldOrigin)
page OrderList { route: "/orders"  body: List { of: Order } }
  ↓ (IR-level scaffold expander rewrites body)
page OrderList {
  route: "/orders"
  body: Stack {
    Breadcrumbs { Anchor { "Home", to: "/" }, Text { "Orders" } },
    Toolbar {Heading { "Orders", level: 2 },
            Button { "New order", to: "/orders/new", testid: ... }},
    QueryView {of: Sales.Order.all,
              loading: Skeleton { count: 5 },
              error: Alert { "Couldn't load orders" },
              empty: Empty { "No orders yet." },
              data: rows => Paper { Table { rows, … } }},
    testid: "orders-list"
  }
}
  ↓ (single walker emit path)
src/pages/orders/list.tsx

There is one codegen path: the walker. The legacy archetype renderers (renderListPage, renderDetailPage, renderNewPage, etc.) and their per-pack templates (page-list.hbs, page-detail.hbs, etc.) were deleted in Slice D1.

A migrated examples/acme.ddd (PR #94) demonstrates the new shape end-to-end.

What the validator catches

Misalignment Error
api X from MissingSub "api 'X' references undeclared subdomain 'MissingSub'"
Two api X declarations "Duplicate api 'X'"
api X: NoSuchApi in UI "ui '' references undeclared api 'NoSuchApi'"
Body ref Sales.NoAggregate.all "Aggregate 'NoAggregate' not found in api 'SalesApi'"
Body ref Sales.Customer.allll "Operation 'allll' is not declared on aggregate 'Customer'. Available: all, byId, create, ..."
serves: on a frontend platform "'serves:' is only valid on a backend deployable"
Backend doesn't serve a bound api "Deployable 'X' does not 'serves: Y'"
Missing UI param binding "Deployable 'D' is missing a binding for ui parameter 'X: Y'"
Duplicate storage names "Duplicate storage 'X'"
Duplicate resource names "Duplicate resource 'X'"
Backend hosts an aggregate with no matching resource "Deployable 'X' hosts aggregate 'C.A' (persistedAs: state, needs resource kind: state) but lists no matching resource."
resource kind ↔ storage type mismatch "resource 'X' kind 'cache' is incompatible with storage 'pg' of type 'postgres'. kind 'cache' requires a storage of type inMemory or redis."
resource listed but covers no aggregate (warning) "Deployable 'X' lists resource 'Y' (kind: eventLog) for context 'C', but no aggregate is eventSourced — this binding routes no data."
Knob incompatible with kind "resource 'X': 'ttl' is only meaningful on kind: cache. Got kind: state."

Generator wiring (today)

Layer Generator-side
subdomain, context, aggregate fields Drizzle schema, Hono routes, .NET commands (existing)
api X from <Ctx> Per-aggregate api/<name>.ts with React Query hooks (existing scaffold output)
storage X { type: postgres } Drizzle config + Phoenix/Hono/.NET Postgres migrations via the platform-neutral MigrationsIR (src/ir/types/migrations-ir.ts + src/system/migrations-builder.ts); per-backend emitters in src/generator/<backend>/emit/migrations*.ts (one per backend — elixir, typescript, dotnet, python, java)
storage X { type: <other> } Parses + validates; no generator output yet
resource X { for: C, kind: state, use: Y, schema: "...", tablePrefix: "..." } EF Core ToTable("name", "schema"), Drizzle pgSchema("...").table(...), Ecto @schema_prefix "..." + schema "prefix_...". Schema defaults to snake(contextName) when omitted on a relational store.
resource X { ..., isolationLevel: <level> } Default isolation for transactional workflows in the bound context, overridden by per-workflow transactional(<level>).
resource X { ..., ttl/every/retain/readonly/keyPrefix: ... } Validated for shape and compatibility; emitters do not yet consume — the IR validator warns at emit time so authors don't believe no-op knobs have effect.
resource X { for: C, kind: objectStore|queue|api, use: Y } Per-backend client module + dev-compose sidecar; consumed from workflows via the verb vocabulary. See resources.md.
UI api X: Y parameter + body refs Walker hook injection (slices 11.24–11.25)
Deployable serves: / ui: X { ... } Validator + composition checks (slice 11.26)
Deployable contexts: / dataSources: IR-level coverage validator (every hosted (context, kind) pair must have a matching binding; every listed binding must cover at least one aggregate). migrationsOwner derives one backend per subdomain for schema-migration emission.

Slice trail

The shipped architecture is the result of slices 11.24–11.27