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:
- Native editor debugging (§2) — the emitted
.vscode/launch.jsonpoints your editor's own debugger (Node, .NET, JVM) at the emitted source maps. Set a breakpoint in the.dddfile, it binds; step through and the stack shows.ddd. This is real, live, stepping-and-inspection debugging, working today. - CLI trace/breakpoint translation (§3) —
ddd traceandddd breakpointstranslate positions between.dddand 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:
out/.loom/sourcemap.json— the generic, cross-target map (.dddspans ↔ generated file regions) thatddd trace/ddd breakpoints/ddd-dapall read. Frontends record one region per emitted page file and one per user component: react, vue, svelte, angular and flutter do (flutter's components share one pooledlib/components.dart, so each one is anchored inside it rather than claiming the whole file); feliz records nothing yet — ledger rowsourcemap-feliz-flutter-not-emitted, whose flutter half closed in wave C2.Per-target native debug metadata, woven into the generated projects themselves:
Target What ships Consumed by Node / Hono Source Map v3 .ts.mapsidecars (//# sourceMappingURL) pointing back to.dddthe Node inspector, VS Code js-debug, browser devtools.NET #linedirectives → the PDB carries.dddline/column spansVS Code coreclr,dotnetdebuggerJava / Spring JSR-45 SMAP in the class file's SourceDebugExtensionany JDWP debugger (VS Code java) — the same mechanism JSP debugging usesPython / Elixir no native #line— use the CLI trace path (§3)ddd traceFeliz (F#/Fable) no native metadata — but every page IS in .loom/sourcemap.jsonas a REGION of the singleweb/src/App.fs(one file holds the whole ui), soddd trace/ddd breakpointsresolve into itddd traceFlutter (Dart) nothing yet — neither native metadata nor .loom/sourcemap.jsonentries— out/.vscode/launch.json— one launch configuration per debuggable deployable (node / .NET / Java), pre-wired to the metadata above.
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.
2. Native editor debugging (recommended)
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.
- Set a breakpoint on, say, a
preconditionor a workflow statement in the.dddfile → it binds to the emitted guard / statement. - When it hits, the call stack shows
.dddframes for the code Loom generated (framework frames stay in their own language, honestly labelled).
Assumptions each config makes
Each config assumes a prior build/boot step — it is deliberately not a
preLaunchTask (that's your project's choice):
- Node — assumes the deployable is runnable as-is. The config uses
plain
node --enable-source-mapswith no--experimental-strip-typesflag, targeting the runtime the shipped docker image uses (node:24, where type-stripping is unflagged). A local host Node older than 23.6 needs the flag added by hand; older than 22.6 can't strip types at all. Use plainnode, nottsx:npx tsx --enable-source-mapsruns the project (esbuild probes extensionless imports) but its stack traces land on the generated.ts, never the.ddd— Node follows the source map of the module it actually loaded (tsx's in-memory transform), not the.ts.mapsidecar on disk. Plain Node chains to.dddbecause debug mode emits explicit.tsimport specifiers (src/generator/typescript/debug-imports.ts) and the emitted TypeScript stays erasable. So a debugging fix verified under tsx proves nothing about.dddresolution, and a new TS emitter construct must stay erasable. - .NET — assumes a prior
dotnet build(the config points atbin/Debug/<TFM>/<Assembly>.dll). - Java — assumes the project is importable by the editor's Java
extension (which builds it);
mainClassis the emitted@SpringBootApplicationclass.
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
docs/old/proposals/source-map-and-debugging.md— the full design: the Origin spine, per-target metadata, and the phased roadmap.docs/loom-artifacts.md— the.loom/output bundle, includingsourcemap.json.docs/tools.md— the fulldddCLI surface.packages/ddd-dap/README.md— the debug adapter package.