← Back to home

Debugging generated Loom apps

Loom emits native debug metadata for every target that has a native debugger, so you debug in your own language and step back to the .ddd source that produced the code. Everything here is gated behind one flag — --sourcemap — and is fully additive: with the flag off, generated output is byte-for-byte unchanged.

There are two independent paths, and the first is the one you want almost always:

  1. Native editor debugging (§2) — the emitted .vscode/launch.json points your editor's own debugger (Node, .NET, JVM) at the emitted source maps. Set a breakpoint in the .ddd file, it binds; step through and the stack shows .ddd. This is real, live, stepping-and-inspection debugging, working today.
  2. CLI trace/breakpoint translation (§3) — ddd trace and ddd breakpoints translate positions between .ddd and generated code from the terminal, no editor required. Useful for a crash log from production, or scripting.

A third, standalone path — the ddd-dap debug adapter (§4) — is a DAP server built directly on the same maps; see its section for what it does and doesn't do today.

1. Generate the maps

Pass --sourcemap to generate system:

node bin/cli.js generate system app.ddd -o out --sourcemap

This adds, alongside the normal output:

The .ts.map sidecars name their .ddd by absolute path in sources, and a debugger reads the file from there — so by default they do not inline the source text. Inlining it would repeat the whole .ddd once per generated file (112 sidecars, 763 KB, on the ERP example — versus 95 KB without, and a 201 KB .loom/sourcemap.json that covers more files). Add --inline-sources when the maps will be read somewhere the .ddd files are not, and each sidecar carries its own sourcesContent:

node bin/cli.js generate system app.ddd -o out --sourcemap --inline-sources

The browser playground always inlines — its VFS has no filesystem behind sources for devtools to read.

In the browser playground: the Run/boot path always bundles the in-browser Hono backend with an inline Source Map v3 chaining back to .ddd — no toggle to find. Open DevTools → Sources, find the backend's blob module, and set a breakpoint on a .ddd line; it binds via the inline map. The Files pane and the git-backed workspace store stay byte-identical to a sourcemap-off generate: the playground generates the source you see/persist and the mapped boot bundle from two separate generate calls of the same source, so the maps never leak into what you view, hand-edit, or download (see web/src/build/strip-sourcemap.ts). One caveat: a hand edit to a generated file that carries a map sidecar isn't reflected in the mapped boot (the freshly-generated version is what DevTools can chain back to .ddd) — everything else in the workspace, including hand edits to files the flag doesn't touch, bundles as edited.

Open the generated out/ directory in VS Code. The emitted .vscode/launch.json already contains a config per backend. Pick one from the Run and Debug panel, set a breakpoint in the .ddd file, and run.

What each config looks like

A Node deployable named honoApi (slug hono_api):

{
  "type": "node",
  "request": "launch",
  "name": "Debug honoApi (node --enable-source-maps)",
  "program": "${workspaceFolder}/hono_api/index.ts",
  "cwd": "${workspaceFolder}/hono_api",
  "runtimeArgs": ["--enable-source-maps"],
  "outFiles": ["${workspaceFolder}/hono_api/**/*.ts"],
  "resolveSourceMapLocations": ["${workspaceFolder}/hono_api/**", "!**/node_modules/**"],
  "skipFiles": ["<node_internals>/**"]
}

A .NET deployable named dotnetApi:

{
  "type": "coreclr",
  "request": "launch",
  "name": "Debug dotnetApi (.NET)",
  "program": "${workspaceFolder}/dotnet_api/bin/Debug/net10.0/DotnetApi.dll",
  "cwd": "${workspaceFolder}/dotnet_api",
  "console": "integratedTerminal",
  "stopAtEntry": false
}

A Java deployable named javaApi:

{
  "type": "java",
  "request": "launch",
  "name": "Debug javaApi (Java)",
  "mainClass": "com.loom.javaapi.Application",
  "projectName": "javaapi",
  "cwd": "${workspaceFolder}/java_api"
}

How the .ddd breakpoint binds

Source-map debugging is bidirectional: the debugger reads the map, so a breakpoint you set in the original source (.ddd) binds to the generated location, and a stack frame reported in generated coordinates displays as .ddd. This is the same mechanism that lets you debug TypeScript, Kotlin, or a .jsp — Loom just emits the map.

Assumptions each config makes

Each config assumes a prior build/boot step — it is deliberately not a preLaunchTask (that's your project's choice):

3. CLI trace / breakpoint translation (no editor)

Two commands translate positions from the terminal, reading .loom/sourcemap.json. Both default to <out>/.loom/sourcemap.json (or ./.loom/sourcemap.json); pass --map <path> to override.

ddd trace — generated stack trace → .ddd

Feed it a crash log / stack trace file; each recognized frame is annotated with the .ddd construct + source location it maps to. Unrecognized frames (framework internals) pass through unchanged.

node bin/cli.js trace crash.log --map out/.loom/sourcemap.json

The annotated log goes to stdout (pipeable, byte-identical for anything that didn't resolve); a one-line coverage verdict goes to stderr — annotated 3 of 7 stack frame(s). When NOTHING matched, that verdict names the frame files it saw, what the map covers, and the usual cause:

ddd trace: no frame matched the sourcemap (0 of 3 stack frame(s)).
  frame files: /app/dist/index.js
  out/.loom/sourcemap.json covers 19 generated file(s), e.g. api/domain/issue.ts, …
  A BUNDLED frame (dist/…, *.min.js, a single-file build) names the bundle, not the
  generated file the map is keyed by. Run the process from the generated sources, or
  resolve the bundle's own source map first (`node --enable-source-maps`), …

A bundled production stack is the common case: the map is keyed by the generated source paths, so a dist/index.js frame can never match one.

This is the path for Python and Elixir (no native #line), and for any production stack trace you have as text but not a live process.

ddd breakpoints — .ddd line → generated location(s)

The reverse: resolve a .ddd source line to the generated location(s) it produced.

Granularity is not uniform — know which half you are on. The map is construct-granular by default (source-map-debug-kickoff.md §2): the recorder writes one whole-file region per emitted file, and only executable bodies layer finer regions on top of it. So:

A .ddd line holding… resolves to
an operation header the method's real generated line — domain/order.ts:155
a statement inside an operation, or a workflow body its own generated line (file:line), plus a :column on TS/Hono
a property, invariant, or derived field of an aggregate file:1 — the enclosing whole-file region, once per file the construct fanned out into (42 of them for one property, on examples/acme.ddd)
a repository find declaration file:1, likewise
an aggregate Order { header file:1, which for a whole-declaration line is the right answer
anything inside a valueobject — the header, a member, an invariant nothing at all: No generated location maps to …. The emitted file (e.g. Domain/ValueObjects/Money.cs) is never recorded, so the valueobject has no map entry to fall back on

The file:1 rows are not a resolver failure: those constructs record no region of their own, so the enclosing file region is the only true thing the map knows. The valueobject row is a plainer omission — no region is recorded at any granularity. Both are tracked as mission M-T8.29 in docs/new-plan/T8-dx-tooling-ai.md.

A :column suffix appears only where a fine expression region exists — today that means TS/Hono statement bodies (see M-T8.2 for the fan-out to the other four backends).

node bin/cli.js breakpoints app.ddd --line 42 --map out/.loom/sourcemap.json
# hono_api/domain/order.ts:55:12

A .ddd line that fans out to several generated files lists them all, narrowest mapping first; a line with no mapping is reported as such (exit 0 — a valid answer, not a failure).

Enclosing regions are dropped when the line maps something of its own. A statement inside an operation is also covered by its aggregate's whole-file regions, so the raw fan-out answered label := note with the real domain/order.ts:29:18 plus order.test.ts:1, order.ts:1 and order.routes.ts:1 — breakpoint sites on an import statement. Those coarse targets are listed only for a line that has no finer mapping (an aggregate Order { header, a plain property), where they are the answer.

An OPERATION's declaration line is not one of those coarse lines. Every operation body records a region of its own carrying the member's origin (declarationSubRegion, src/generator/_trace/sourcemap.ts), so operation complete(note: string) when status == InProgress { resolves to the method's real generated line — domain/workOrder.ts:155, not domain/workOrder.ts:1, which is what an engineer asking for a breakpoint on that line meant (finding F-021). Statement lines inside the member are unaffected: their own regions are narrower in origin terms and still win, column and all.

This does not extend to declarative members. A property, an invariant, a derived field, a repository find and a valueobject member record no region of their own — only operations and workflows do — so their declaration lines still answer file:1, per the granularity table above (finding F-6, mission M-T8.29). ddd trace reads the same map and degrades the same way in reverse: a stack frame landing inside a generated property annotates to the enclosing aggregate rather than the field, which is coarse but not wrong.

4. The ddd-dap debug adapter

packages/ddd-dap is a standalone Debug Adapter Protocol server built on the same .loom/sourcemap.json. It exists so an editor can drive .ddd-native breakpoints/stack-frames through one adapter regardless of target language.

Today it ships the remap layer: the initialize / setBreakpoints / stackTrace requests, answered by resolving .ddd ↔ generated positions through the map. Launch it over stdio pointed at a generated map:

npx ddd-dap --map out/.loom/sourcemap.json
# or: LOOM_DAP_MAP=out/.loom/sourcemap.json npx ddd-dap

What it does not do yet: delegate to a running target debugger. Live stepping (launch/attach/continue/stepIn against an actual running backend) requires the adapter to spawn and proxy the target's own debugger (js-debug / coreclr / JDWP) and remap only line/scope on top — the documented, editor-verified frontier (see docs/old/proposals/source-map-and-debugging.md §6E). For live stepping today, use the native editor path in §2 — it drives each target's real debugger directly and needs no delegation.

See also