Part 01 — Why one DSL doesn't scale: the case for decomposition
The argument has to start somewhere honest. The meta-ide-dsl prototype — one TypeScript file, one class RequirementsIde, six decorator families bundled together — works. It compiles into a complete VSCode extension. For one DSL, in one team, on one release cadence, the shape is enough. If the decomposition argument starts by claiming the prototype is broken, every reader who has actually shipped a single-DSL extension will close the tab. Nothing is broken. Something stops scaling, and that is a different claim.
The claim made here, and defended over the next three articles, is that the single-class shape stops scaling along three independent axes: along release cadence (the moment two contributors disagree on when to ship), along ownership (the moment two teams disagree on who owns what), and along ecosystem reuse (the moment a third party wants to consume one decorator family and not the rest). The first axis arrives within a quarter of any production deployment. The second arrives the first time a colleague asks to take over the snippets work. The third arrives the first time a sibling DSL — say, an admin DSL or a workflow DSL — wants the same hover behaviour without copy-pasting the spec class.
This article narrates those three axes against the existing prototype, then states the decomposition thesis in two paragraphs at the end. The technical content of the next twenty-one articles — the kernel, the fourteen micro-DSLs, the two hosts, the composition case study — depends on this article landing one judgment: that the single-class shape cannot be patched, only re-cut.
The RequirementsIde class as a closed system
Before counting failure modes it helps to read the prototype as it stands. The full file is at requirements-ide.spec.ts — about two hundred and fifty lines, organised into six sections:
@Language({ id: 'requirements', extensions: ['.req.ts'], scopeName: 'source.requirements', /* ... */ })
export class RequirementsIde {
@Token('decoratorKeyword', /@(Feature|Satisfies|Refines|FeatureTest|Verifies|Expects|Exclude)\b/)
decoratorKeyword!: Token;
@Token('priorityLiteral', /\bPriority\.(Low|Medium|High|Critical)\b/)
priorityLiteral!: Token;
// ... five more @Token declarations
@Rule('featureClass', 'decoratorKeyword classDecl')
featureClass!: Rule;
// ... two more @Rule declarations
@Snippet('feat', /* multi-line body */, 'New Feature skeleton satisfying one Requirement')
featureSnippet!: Snippet;
// ... two more @Snippet declarations
@LspFeature('diagnostics')
async diagnostics(doc: TextDocument): Promise<Diagnostic[]> { /* ... */ }
@LspFeature('hover')
hover(doc: TextDocument, _pos: Position): Hover | null { /* ... */ }
// ... two more @LspFeature declarations
@Executor({ command: 'requirements.compliance', label: 'Requirements: run compliance --strict', taskKind: 'test' })
async runCompliance(_file: string): Promise<ExecutionResult> { /* ... */ }
}@Language({ id: 'requirements', extensions: ['.req.ts'], scopeName: 'source.requirements', /* ... */ })
export class RequirementsIde {
@Token('decoratorKeyword', /@(Feature|Satisfies|Refines|FeatureTest|Verifies|Expects|Exclude)\b/)
decoratorKeyword!: Token;
@Token('priorityLiteral', /\bPriority\.(Low|Medium|High|Critical)\b/)
priorityLiteral!: Token;
// ... five more @Token declarations
@Rule('featureClass', 'decoratorKeyword classDecl')
featureClass!: Rule;
// ... two more @Rule declarations
@Snippet('feat', /* multi-line body */, 'New Feature skeleton satisfying one Requirement')
featureSnippet!: Snippet;
// ... two more @Snippet declarations
@LspFeature('diagnostics')
async diagnostics(doc: TextDocument): Promise<Diagnostic[]> { /* ... */ }
@LspFeature('hover')
hover(doc: TextDocument, _pos: Position): Hover | null { /* ... */ }
// ... two more @LspFeature declarations
@Executor({ command: 'requirements.compliance', label: 'Requirements: run compliance --strict', taskKind: 'test' })
async runCompliance(_file: string): Promise<ExecutionResult> { /* ... */ }
}The shape says something specific. Everything the IDE knows about the requirements DSL is in one place: the language identity, the lexical tokens, the grammatical rules, the snippet templates, the LSP behaviours, the executor that wires VSCode tasks. Reading the file is reading the IDE. There is no place else to look. The five-minute onboarding is open the file, scroll. The pedagogy is excellent and the meta-ide-dsl series is right to land it as the first artefact.
What the shape also says, and what is harder to see when the file is your daily work, is that every change to any of those six concerns goes through the same file. Adding a new token requires editing this file. Tweaking the hover behaviour requires editing this file. Renaming the executor command requires editing this file. Re-styling the snippet template requires editing this file. The file is a closed system not in the formal sense — anyone can edit it — but in the architectural sense: there is no way to express I want to depend on the snippets layer without depending on the executor layer. The unit of dependency is the file.
That property is fine until it isn't. The next four sections name the four moments where it stops being fine.
Failure mode #1 — the release cadence
Consider a small but realistic situation. The team that owns the requirements DSL ships a new snippet — say, a snippet for a new @CrossLinks decorator the requirements DSL just added. The snippet is two lines. The change to RequirementsIde is a single new @Snippet block. The PR opens, gets reviewed, gets merged. So far, so good.
But RequirementsIde is also the file that declares the @LspFeature('diagnostics') handler — a handler that, in a separate work stream, is being progressively refactored to use a new validation engine. The diagnostics refactor is half-done; some validation rules have moved, some have not, and the file has a couple of // TODO comments where the refactor will land next sprint. Merging the snippet PR is harmless to the snippets, but it forces a rebase against the half-finished diagnostics work. If the rebase touches the same imports — and the meta-ide-dsl prototype declares all stubs at the top of the file, so it will — the snippets PR is now blocked on the diagnostics decision.
This is not a hypothetical. It is the same coupling that drives every monorepo-wide build to slow down: any change to any file in the file's import graph triggers a rebuild of everyone downstream. Splitting the file into per-decorator-family files would not help, because the type contract still passes through one class declaration. The release cadence of the snippets work is bounded above by the release cadence of the slowest concern in the file. With six concerns in one file, the slowest concern wins.
The defence usually offered for this is "we do not release at that granularity". The defence works for one team writing one DSL. It stops working the day the snippets author and the diagnostics author are not the same person. The decomposition argument is not that we want to release at finer granularity for its own sake; it is that we want to be able to release at the granularity at which work actually happens.
Failure mode #2 — the ownership boundary
A second scenario, slightly larger. The requirements DSL ships, gets used internally, and a sibling team — the workflow team, building a workflow DSL — looks at the hover behaviour and asks for the same. Hovering on a workflow stage reference should show the stage's title, gates, and current transitions. The behaviour is conceptually identical to hovering on Requirements.FEATURE-156 shows the feature's title and ACs: same pattern (a typed reference token), same resolution shape (workspace-wide lookup), same result format (markdown content).
In the single-class shape, the workflow team's only options are:
- Copy the hover code. Take the
@LspFeature('hover')body fromRequirementsIde, duplicate it into a newWorkflowIdeclass, change the resolution function. Twelve months later, when someone fixes a bug in the requirements hover, the workflow hover keeps the bug. - Extract a helper. Move the hover body into a shared utility, leave a thin per-DSL wrapper. This works for one shared concern. It scales poorly: by the time three sibling DSLs share completion, four share diagnostics, two share refactoring — and each shared helper takes its own arguments and exports its own types — the "shared utilities" file is the shape we are trying to leave.
- Make the requirements DSL a dependency of the workflow DSL. Architecturally absurd: the workflow DSL has nothing to do with requirements semantically; pulling its IDE definition just to inherit one decorator pattern coupling-by-dependency is the worst possible bargain.
Each option is bad in a recognisable way. The right option is the one the single-class shape forecloses: publish the hover behaviour as its own micro-DSL, with a contract the workflow DSL can consume directly. That option requires the hover behaviour to exist as something other than a method body inside a class. It requires it to have a name, a package, a versioned contract, an index.ts, and a package.json. The single-class shape cannot give it any of those things.
Ownership questions arrive earlier than people expect. They do not arrive when teams scale past Conway's threshold; they arrive the first time the hover author and the snippets author have different opinions on a code review.
Failure mode #3 — the ecosystem reuse
A third scenario, the one that pushes the argument from inconvenience to architectural necessity. Imagine the requirements DSL has been deployed for six months, the team has shipped two updates, and an outside contributor — someone in another company, using the requirements DSL because it is open-source — submits a PR. The PR adds a new completion provider for the @Satisfies(...) argument list: instead of the existing static suggestion list, the new provider walks a Linear or Jira board to suggest live requirement IDs.
The contributor has done good work. The provider is well-tested. The behaviour is genuinely useful. But integrating it into RequirementsIde means adding an external HTTP dependency, an authentication concern, and a new failure mode (network down, rate-limited, auth expired) into the same class that owns the language identity, the syntax highlighting, the snippets, and the executor. Suddenly the requirements DSL extension can fail to activate because Linear is unreachable. The maintainer is right to refuse the PR — not because the behaviour is wrong, but because the boundary is wrong.
Worse: the contributor has no way to ship the behaviour as a standalone extension. The hover, completion, and codelens hooks they would compose against are not exposed; they are method bodies in a class they cannot extend. The behaviour either fits in RequirementsIde or it does not exist. The single-class shape forces the question of what counts as a stable extension point and answers it with the whole class is one extension point and you cannot subset it.
Healthy ecosystems do not work like this. The reason VSCode itself is the platform we are targeting is that VSCode publishes hundreds of fine-grained extension points, each individually consumable. Our meta-DSL should produce the same shape one level up: each VSCode capability becomes a publishable, consumable, replaceable extension point in our suite. That requires the kernel + micro-DSL + host architecture this series describes.
Why "just use folders" is not enough
The natural first counter-proposal is to keep the single class but split the file. Move @Token declarations into tokens.ts, @Snippet declarations into snippets.ts, @LspFeature declarations into lsp.ts, re-export from one entry point. The class still exists; it is just defined across several files now.
This solves nothing. The release cadence problem persists because the entry point still re-exports a single class, and changes to any file change the public surface. The ownership problem persists because the type of the class is still monolithic; you cannot publish the snippets file without also publishing the dependency on the rest. The ecosystem problem persists because external consumers still see one class, with one extension story.
The decomposition we need is not a folder boundary; it is a package boundary. A package boundary is what a package.json declares: an installable unit, a versioned API, a separate test suite, a separate publish step. Two packages can be owned by two teams, released on two cadences, consumed à la carte, and replaced individually without the consumer's package.json care. A folder boundary is just a typing convenience; a package boundary is a release contract.
The architectural test is concrete: if a contributor wanted to fork only the snippets, could they? Under a folder split, no — the snippets file imports the class declaration that imports everything else. Under a package split, yes — the snippets package declares its own public API, its own version, its own dependency on the kernel. That single test is what the rest of the series is engineered around.
The decomposition thesis
The thesis takes two paragraphs.
First paragraph — the shape. The meta-DSL is decomposed into one shared kernel and N micro-DSLs. The kernel owns the metamodel (@Concept, @Property, @ChildLink, @ReferenceLink), the AST runtime, the PatchBus mutation contract, the Banner idempotence pattern, and the local EditLog. Every micro-DSL depends on the kernel; no micro-DSL depends on another micro-DSL; the kernel depends on nothing in the suite. Each micro-DSL owns one concern (Language, Syntax, Completion, Snippets, Hover, Diagnostics, CodeLens, Commands, Views, Formatter, Symbols, Refactoring, Projection, Generator) and is published as its own package. Suite-level shape comes from hosts — the LSP host, the Custom Editor host, the Extension host — which import several micro-DSLs and compose them. No host hard-codes a specific micro-DSL; each host depends on the contribution interfaces declared in the kernel and discovers concrete contributions at startup.
Second paragraph — the consequences. Release cadence becomes per-package: snippets ship when snippets are ready. Ownership becomes per-package: the hover author and the snippets author are co-owners of two packages, not co-owners of one file. Ecosystem reuse becomes per-package: the Linear-aware completion lives as a third-party package consuming the same kernel and the same Completion micro-DSL contract. Composition is at consumption time, not declaration time: the user assembles their IDE by adding micro-DSLs to their package.json, in the same idiomatic way they assemble a webpack config or a bundle. The single-class shape becomes one specific consumption — a thin top-level package that depends on a chosen set of micro-DSLs and ships them together — but it is no longer the only shape.
That is the whole thesis. The next article makes it concrete by describing the kernel.
What this article verifies
This article verifies the four acceptance criteria of FEAT-MICRODSL-01 declared in assets/features.ts:
- monolithicSpecCounterexamplePresented — the section The
RequirementsIdeclass as a closed system shows the prototype shape and identifies the closed-system property. - concernCouplingFailureModesEnumerated — the three failure mode sections name the release-cadence, ownership, and ecosystem-reuse axes, with one worked scenario each.
- decompositionThesisStated — the section The decomposition thesis states the shape and the consequences in two paragraphs.
- bridgeFromMetaIdeDslArticulated — the article opens by quoting the meta-ide-dsl prototype as the starting point, names the single-class shape as the inheritance, and frames decomposition as the next move.
What article 02 picks up
The thesis names a kernel without saying what is in it. Article 02 makes the kernel concrete by recalling Eric Evans' shared kernel pattern from Domain-Driven Design (chapter 14), stating three inclusion criteria — known and used by ≥3 micro-DSLs, stable, no VSCode dependency — and reviewing every candidate kernel concept against those criteria. The conclusion is the same five things this article named in the thesis paragraph: M2 decorators, AST, PatchBus, Banner, EditLog. The argument is that the list is complete — there is no sixth concept that meets all three criteria, and any candidate that fails one criterion belongs to a micro-DSL or a host. Read 02 next.