← Back to home

Platforms — the backend registry

The platform: slot on a deployable picks which backend renders it. Every platform is implemented by a PlatformSurface in src/platform/, registered in src/platform/registry.ts. This doc covers the registered set, how a platform: value is resolved, and what each platform contributes.

For per-construct generator output (DTO shapes, route files, repository shapes), see generators.md — that's the one with the per-platform file maps. This doc covers the registry contract: which platforms exist, what flags they carry, and how backend versioning works.

Registered platforms

platform: keyword Surface file Default port Needs DB Mounts UI
node (default and only version node@v5; Hono web framework) src/platform/hono/v5/index.ts (shared surface factory: src/platform/hono/v4/index.ts) 3000 ✓ ✗
dotnet (default dotnet@v10) src/platform/dotnet.ts 8080 ✓ ✓ (when ui: is declared)
elixir (default elixir@v1; legacy aliases phoenix / phoenixLiveView desugar to it) src/platform/elixir.ts 4000 ✓ ✓ (fullstack)
python (default python@v1) src/platform/python.ts 8000 ✓ ✓ (when ui: is declared — dotnet-style dual mode)
java (default java@v1) src/platform/java.ts 8081 ✓ ✓ (ui: embedded-SPA mount; hosts: gated)
react src/platform/react.ts 3001 ✗ ✓
vue src/platform/vue.ts 3003 ✗ ✓
svelte src/platform/svelte.ts 3002 ✗ ✓
angular src/platform/angular.ts 3004 ✗ ✓
flutter (mobile axis — Dart/Flutter + Riverpod, self-hosting flutter build web) src/platform/flutter.ts 3006 ✗ ✓ (hosts only framework: flutter)
static aliased to react.ts 3001 ✗ ✓

flutter is the mobile axis, not a sixth web SPA. Like Feliz it is a self-hosting frontend (own SDK build, not a vite static bundle), so it hosts only its own framework: flutter UI and dispatches through src/generator/flutter/. It emits a Dart/Flutter (Material 3) app on Riverpod: wire models (sealed class for payload unions), ConsumerWidget pages, Notifier state + actions, FutureProvider reads for QueryView, and StatefulWidget forms (CreateForm / OperationForm / DestroyForm). From the one Dart source the emitted Makefile builds three surfaces — web (make web, the compose-served surface), Android (make apk), and iOS (make ipa); the native folders are materialised on demand (make prepare). The web build is compiled per-PR by generated-flutter-build.yml (flutter analyze + flutter build web); the native surface has no CI gate yet.

Resolving a platform: value

The grammar accepts two forms:

deployable api1 { platform: node,        ... }              // bareword
deployable api2 { platform: "node@v5",   ... }              // pinned

Resolution happens in two parts (see parseBuiltinPlatformRef in src/platform/registry.ts):

  1. Bareword backend — resolves through BUILTIN_PLATFORM_LATEST to today's default version. Currently: node → v5, dotnet → v10, elixir → v1, python → v1, java → v1. Older in-tree versions can stay resolvable through an explicit pin (BUILTIN_PLATFORM_EXTRA_VERSIONS in src/platform/metadata.ts); no backend registers one today. A removed version is listed in RETIRED_BACKEND_VERSIONS with its successor: node@v4 (zod 3 / TS 5) was retired, and pinning it is a loom.platform-version-unknown validation error naming node@v5. Frontend platforms (react, vue, svelte, angular, static) intentionally aren't versioned at the platform layer — their version lives on the design pack / stack axis (see design-packs.md).

    What vN means. A backend package's Loom version tracks the package's own dependency-major evolution, not the platform's name and not the JS runtime. The retired node@v4 was cut around Hono 4 + zod 3 + TS 5; node@v5 — today's default and only version — is the cross-major successor carrying zod 4, TypeScript 6, vitest 4 (src/platform/hono/v5/pins.ts), with Hono itself still on 4.x because there is no Hono 5. Elsewhere the number does line up with the defining framework (dotnet@v10 tracks .NET 10, mantine@v9 tracks Mantine 9). What node@v5 is definitely not is "Node.js 5". (Sources never spell this out: every deployable uses the bareword platform: node, and @v5 is only the internal qualified ref the resolver fills in.)

  2. Pinned family@version — looked up directly in the registered backend surfaces. Unknown versions are a validation error that lists the available pins (backendVersionsForFamily).

Both forms resolve to the same PlatformSurface instance the system orchestrator dispatches against.

Realization axes — picking how a backend renders

Beyond which backend, a deployable can pick how that backend realises the code, via an optional block on platform: — the realization axes (full design in proposals/platform-realization-axes.md; in-flight tracker in plans/realization-axes-rollout.md):

deployable api { platform: dotnet { persistence: dapper }                  ... }
deployable web { platform: node   { persistence: mikroorm, directoryLayout: byFeature } ... }

Each axis has a per-platform menu of real adapters; an out-of-menu or not-yet-implemented value is a validation error that lists what's available. The matured axis today is persistence::

Platform persistence: Default
dotnet efcore, dapper efcore
node drizzle, mikroorm drizzle
elixir ecto ecto

Event-sourcing (persistedAs: eventLog + apply(...)) emits on node, .NET, Python, Java and Elixir (append-only per-aggregate <agg>_events stream, fold-on-load). A dedicated Marten document/event-store backend is 3rd priority (if ever): D-DOCUMENT-AXIS (see decisions.md) currently pins no new Marten backend — the event log lives on the existing relational stores.

The only other user-selectable axis is directoryLayout: byLayer | byFeature (the fully adapter-consumed axis — it routes each artifact's path via layout.pathFor). The former style / application, transport, and runtime axes were removed — style is now one fixed emission per backend (cqrs on dotnet, layered elsewhere), kept internally but no longer user-selectable, and the name-only transport / runtime registries were deleted whole. So a platform { … } block accepts only persistence: and directoryLayout: today (ddd.langium; validator RealizationAxis). See the D-REALIZATION-AXES supersession note in decisions.md.

Phoenix backend (plain Ecto/Phoenix)

platform: elixir emits plain Ecto.Schema / Ecto.Changeset / Ecto.Repo over Phoenix.Endpoint + LiveView. The former foundation: ash is removed, and with it the foundation: axis itself — it had collapsed to a single value (vanilla) on every backend, so it no longer exists on the grammar. A would-be foundation: clause no longer parses.

The document sub-case below is the one feature with a partial story:

Feature elixir (vanilla) Gate (fail-fast)
Event-sourced storage persistedAs: eventLog ✓ emits (incl. every apply(…) fold shape²) —
Event-sourced workflow (eventSourced saga) ✓ emits (per-correlation stream + fold) — (every backend emits it; no per-backend gate)
Provenanced fields (runtime trace) ✓ emits —
shape: document aggregate ✓ CRUD + finds/ops/functions/returning-ops; small residual gated¹ loom.vanilla-document-unsupported (sub-case)
or-union-returning op with emit/add/remove body ✓ full bodies —
State persistence, unions, carriers, filters, stamping, inheritance ✓ emits —

¹ vanilla emits the document CRUD surface (an (id, data, version) jsonb table) plus custom finds (in-memory Enum.filter over the data map, incl. value-object-subfield reads), named operations (body over the data map → update/2), pure functions (over the data map), returning ops (: A or B → tagged tuple), paged and union finds, containment mutation, and collection reads over the aggregate's own in-memory lists (lines.sum(l => l.qty) / .count / .any(λ) over a containment embeds_many, .contains over a scalar array) — DEBT-07 + Route A. It also applies capability filters (filter / policy / tenancy) on every document read, evaluated in-app over the rehydrated %<Agg>.Data{} embed. The residual that stays gated: provenanced ops, derived and dereferenced-entity reads, collection ops over a REFERENCE collection (X id[] — a join table a jsonb blob has no equivalent for), and value-object METHOD calls; host those on node/dotnet/python/java.

² An apply(e: E) { … } fold rebinds in-memory state (an ES aggregate has no state table), so EVERY fold shape emits — src/generator/elixir/vanilla/fold-stmt-emit.ts (M-T6.2, shared by the aggregate and event-sourced-workflow folds):

Before M-T6.2 the compound-mutation shapes were silently dropped (a # unsupported applier statement comment that compiled green while losing the transition). Minting the contained-part id in the fold is non-deterministic across replays — a shared limitation of the ES contained-part model on every backend, tracked as a cross-backend follow-up.

Every emitter is compiled against real Elixir/Ecto by elixir-vanilla-build.yml (one fixture per feature under test/e2e/fixtures/elixir-vanilla-build/), so the gate is verified, not paper.

Backend versioning

Backend families are discoverable through an injectable source (setBackendSource / resetBackendSource in registry.ts) so the playground can back it with a VFS impl instead of fs / node_modules. The in-tree set is exposed by defaultBuiltInBackends().

Each registered backend ships a manifest:

interface LoomBackendManifest {
  kind: "backend";
  family: BackendFamily;       // "node" | "dotnet" | "elixir" | "python" | "java"
  loomVersion: string;         // e.g. "v5"
  core: string;                // semver range against @loom/core
}

Today every backend family registers exactly one version. Adding a second version for a family means:

  1. Build the new surface (e.g. src/platform/hono/v6/index.ts), typically by feeding a new pin set through the existing surface factory and applying upstream migration steps.
  2. Add an entry in inTreeBackends (src/platform/registry.ts) with the new manifest + surface.
  3. Optionally flip BUILTIN_PLATFORM_LATEST[family] once the new version is the recommended default. Pinning via platform: "<family>@<version>" is the opt-out for projects that want determinism across toolchain upgrades.

The PlatformSurface contract

Every surface implements:

Member Role
name Discriminator matching the Platform IR / grammar token.
defaultPort Used when the deployable doesn't specify one.
needsDb Drives db-init script + depends_on: db wiring.
mountsUi Drives validator acceptance of ui: on this platform.
emitProject({ contexts, deployable, sys, migrations, emitTrace }) Returns Map<path, content> for the deployable's project tree.
composeService({ deployable, sys, slug }) Returns the docker-compose stanza (env, healthcheck path, internal port).

emitProject is the platform's entire output surface. It MUST be self-contained — no cross-platform calls. The system orchestrator in src/system/ composes the resulting per-deployable file maps into one tree and writes the cross-cutting docker-compose.yml, db-init/, and .loom/ artefacts on top.

platform: node does not mount a UI — a settled non-goal

node is the only backend with mountsUi: false, and that is deliberate, not a gap: the Hono emitter serves JSON only. It emits no static-asset middleware (serveStatic appears nowhere in src/platform/hono/), so there is nothing for a ui: binding to be served by. A React/Vue/Svelte/Angular UI against a node backend is its own deployable — an nginx-served bundle with targets: pointed at the API — which is the shape every shipped example uses. The four mountsUi: true backends (dotnet, java, python, elixir) each host a bundle from their own web server; node's runtime has no equivalent story that does not amount to reimplementing one.

Re-open this only with the static-serving emission in hand, not the flag: flipping mountsUi alone would admit a ui: binding that generates a deployable serving 404s for every page.

Known inconsistency (recorded, not fixed): node's descriptor still declares hostableFrameworks: STATIC_BUNDLE_FRAMEWORKS. That field is read only by the loom.ui-framework-unhostable rule, which is itself guarded by platformMountsUi(...) — so on node it is unreachable data that reads as a capability claim the emitter does not honour. Emptying it touches the adapter-metadata.ts mirror and its consistency test, so it is a separate change.

Cross-references