Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part 04 — SOLID in the monorepo patterns: principles derived, not imposed

Part 03 closed on a guardrail: every SOLID claim in this article will cite a file that exists in the monorepo. The rule exists because the alternative — arguing SOLID from textbook examples, then asserting the meta-DSL's architecture satisfies them — is the kind of reasoning that reads well and deceives easily. Code that already compiles and ships is a harder judge than a blog post's invented snippet. This article takes each of the five principles in turn, shows the monorepo file where it currently holds, then transposes that shape onto the meta-DSL's ide-forge pipeline.

One methodological note up front: SOLID is not the whole of the design. It does not tell us the shape of LanguageIR (Part 05's concern), nor which ports to split (Part 06 tightens that), nor what the seven emitters should do (Part 07 walks the artefact). SOLID is a set of guardrails against certain failure modes — rigidity, fragility, immobility, to use Martin's original vocabulary. The principles keep us honest; they do not do the work of design for us.

SRP — one decorator, one axis of variation

The Single Responsibility Principle, in its sharpest formulation, says a module should have one reason to change. In the requirements DSL, that principle materialises in the split between @Feature-related decorators (which declare what a Feature is) and the @Satisfies decorator (which declares what Requirements a Feature fulfils). Two decorators, two reasons to change: the Feature shape can evolve independently of the Requirement-link registry, and the registry can evolve — add bidirectionality, add traceability history — without touching the Feature class shape. When either side changes in packages/requirements, the other is untouched.

Contrast the counterfactual. A single @FeatureWithLinks({ id, title, priority, satisfies: [ReqA, ReqB] }) decorator would bundle both axes into one argument object. Every time the Requirement-link semantics grew a new field — a reason for the link, a strength, a date — the decorator signature would change, breaking every call site. Every time the Feature shape grew a field — a new priority level, a tag system — the same decorator signature would change, breaking the same call sites for the wrong reason. Two axes of variation sharing one module is the definition of the problem SRP names.

Transposing to the meta-DSL: each of @Language, @Token, @Rule, @Snippet, @LspFeature, @Executor earns its own decorator because each corresponds to an independent axis of the DSL's description. @Token changes when the lexical surface of the DSL changes (a new keyword is added); @LspFeature changes when the DSL's semantic services change (go-to-definition is added); @Executor changes when the DSL's runtime story changes (a new subcommand is added). No decorator's argument type mentions another decorator's concern. Read requirements-ide.spec.ts lines 107–117 (the @Language opts), lines 123–136 (the @Token block), lines 197–227 (the @LspFeature methods): three disjoint surfaces, three independent reasons to change.

OCP — open for extension, closed for modification, via a registry port

The Open-Closed Principle is the reason packages/requirements/src/cli/scaffolders/registry.ts is worth reading. The file is sixty-six lines of TypeScript, and it is the exemplar the meta-DSL's emitter registry will mirror directly. Five ideas are worth lifting out of it:

First, the TestScaffolder port is a pure interface: level, defaultDir, filenameSuffix, description, generate(feature, uncovered). Five fields, no inheritance, no lifecycle hooks. A new scaffolder is a new object literal implementing those five fields; nothing else.

Second, the registry itself — MapScaffolderRegistry in the same file — has a three-method surface: get, list, register. No "plugin loading", no dynamic discovery, no reflection. A consumer who wants to add a scaffolder calls register(mine); the registry stores it keyed on the level; the registry's clients (the requirements scaffold CLI command) look it up by level. Closed to internal modification; open to the external register call.

Third, the seven built-ins (UnitScaffolder, FunctionalScaffolder, E2eScaffolder, A11yScaffolder, I18nScaffolder, VisualScaffolder, PerfScaffolder) are exported as a readonly array BUILT_IN_SCAFFOLDERS, and the default registry is constructed from it via createRegistry(). A project that wants to replace the unit-test scaffolder with a bespoke one calls register(myUnit) after creating its registry; the built-in is overwritten by key; nothing else changes. Open for extension, closed for modification, in practice.

Fourth, the factory createRegistry(...) takes an optional array of initial scaffolders. This is the hook tests use to build a registry with exactly the scaffolders under test — no global-state bleed, no pollution between test cases. A small detail that pays off in both OCP (tests extend cleanly) and ISP (tests depend on the minimal surface).

Fifth, the file has no runtime side effects beyond the DEFAULT_REGISTRY constant. Import it once from a test, pass it to a function under test, observe the result. No module-level I/O, no lazy-initialised singleton, no way for one test to leak state into another.

The meta-DSL's emitter registry will be this shape, with one renamed axis. An Emitter port declares:

Proposal (design-in-public):

interface Emitter {
  readonly name: string;
  readonly outputs: readonly string[]; // file paths this emitter writes
  emit(ir: LanguageIR, fs: FileSystem): Promise<void>;
}

The default registry is populated with seven emitters — one per output concern, mirroring the seven built-in scaffolders — and the ide-forge build command iterates over them. A DSL author who wants to replace the grammar emitter (say, to target Sublime Text's grammar dialect) calls register(mySublimeGrammarEmitter) before invoking build; the default is overwritten; nothing else changes. OCP by construction, pattern lifted directly from a file that already works.

Liskov — emitters as substitutable strategies

The Liskov Substitution Principle, in practice, is less a statement about inheritance (the classical formulation) and more a statement about behavioural contracts: any implementation of an interface must satisfy the interface's behavioural guarantees, not just its type signature. A caller holding TestScaffolder must be able to swap in any of the seven built-ins — or any user-supplied scaffolder — without changing the caller's logic.

The TestScaffolder port enforces this through the generate method's contract, stated in the port's JSDoc: "Returns '' when uncovered is empty so the consumer can filter." Every one of the seven built-ins satisfies that guarantee; a user-supplied scaffolder that returned '// nothing to scaffold\n' instead of '' would technically type-check but would break the consumer's filtering logic — an LSP violation, even though the types line up.

For the meta-DSL, the relevant contract is the Emitter.emit(ir, fs) method. The behavioural guarantees the meta-DSL will hold to:

  • An emitter must write only to paths declared in its outputs array. No surprise files, no writes outside the sibling-project tree.
  • An emitter must be idempotent — running it twice on the same IR and filesystem must produce the same files with the same contents. No timestamps, no random UUIDs, no append-on-rerun.
  • An emitter must be pure in the sense that it uses the injected FileSystem port for every write; no direct node:fs call is permitted. This is the LSP-compatible way to state "the emitter tolerates substitution of the filesystem port with an in-memory one", which Part 06 builds on for testing.

Three guarantees, stated in the port's JSDoc (once the package exists), enforced in tests via an EmitterContract suite that every built-in and every user-supplied emitter must pass. Liskov, in TypeScript's nominal-structural hybrid, is not automatic — the language gives us type conformance but not behavioural conformance, and the contract suite closes the gap.

ISP — split ports, pay for what you use

The Interface Segregation Principle tells us that a client should not be forced to depend on methods it does not use. The monorepo's packages/requirements/src/ports/external.ts splits the FileSystem port into exactly the primitives analysis needs (readFile, writeFile, exists, glob) — not a wrapper around node:fs/promises as a whole. The split pays off in two ways: tests inject an in-memory filesystem with four methods rather than fifty, and changes to node:fs that do not touch those four primitives do not ripple into the ports.

The meta-DSL inherits this discipline with sharper splits because the forge has more distinct needs than the requirements analysis does. The provisional port list:

Proposal (design-in-public):

  • FileSystemreadFile, writeFile, exists, mkdirp. The extractor reads the spec; the emitters write the sibling project. No other file I/O.
  • Processspawn(command, args, options). Used only by the executor emitter's tests (to stub out requirements compliance); not invoked by ide-forge build itself.
  • Clocknow(): Date. The spec compilation records a build timestamp in package.json's "builtAt" field (proposed); tests inject a frozen clock to make manifest snapshots stable.
  • Loggerinfo, warn, error. A build emits a sequence of human-readable messages; tests capture them and assert on them.

Four ports, each with three-to-four methods. Compare to the shape a careless port design would produce — a single BuildEnvironment with readFile, writeFile, spawn, now, log, walk, chdir, env, exit, and a grab bag of conveniences — where a test wanting to assert one log message has to stub out the whole environment. ISP in practice is the discipline of keeping each port small enough that a test against it is obviously right.

DIP — depend on the IR, not on the source file

The Dependency Inversion Principle — high-level modules depend on abstractions, not on low-level details — is the one that most directly shapes the meta-DSL's architecture.

The monorepo's packages/typed-fsm/src/analysis/state-machine-extractor.ts reads TypeScript source via ts-morph and produces a plain-object state-machine IR. Everything downstream of the extractor — the JSON renderer, the Mermaid renderer, the compliance check — depends on the IR type, not on ts.SourceFile. A test that wants to verify the Mermaid renderer builds an IR literal in-memory, passes it to the renderer, and asserts on the output. No .ts file needs to exist on disk; no TypeScript AST needs to be parsed; the renderer's tests run at the speed of pure-function tests.

The meta-DSL takes the same stance. The forge has exactly one module — the extractor — that depends on ts-morph. Every other module depends on LanguageIR:

Proposal (design-in-public):

spec.ts ──ts-morph──> Extractor ──> LanguageIR ──> Emitter₁, …, Emitter₇

The dependency arrow from emitters to IR, never from emitters to spec.ts, is the whole of the DIP claim. Emitters know nothing about TypeScript, about decorators, about ts-morph. They know about LanguageIR.tokens, LanguageIR.rules, LanguageIR.snippets, LanguageIR.lspFeatures, LanguageIR.executors — five arrays of plain-object records, serialisable to JSON, testable without any compiler in the room.

This is also, not coincidentally, how packages/ssg-site/src/build/static.ts organises the SSG pipeline: the static builder depends on the ResolvedPage type produced upstream by the content loader, not on the raw markdown file. When an emitter's test wants to verify "every @Token regex appears in the emitted grammar", it builds a LanguageIR with three token records and checks the grammar JSON contains three patterns. No spec.ts parsing, no compiler, no filesystem beyond the in-memory port. DIP in the monorepo is not an aspiration; it is the shape of every pipeline that already works.

Diagram
Figure 1 — The forge's dependency graph. Only the extractor touches ts-morph. Every emitter, and every test, depends on LanguageIR as a plain-object contract.

The god-object counterexample

To make the principles' combined force concrete, imagine the anti-design — a class IDEBuilder that does everything.

Proposal (design-in-public) — counterexample, not to be implemented:

class IDEBuilder {
  constructor(private readonly specPath: string) {}
  async build(outDir: string): Promise<void> {
    const source = await fs.readFile(this.specPath, 'utf-8');
    const ast = ts.createSourceFile(...);
    const tokens = this.extractTokens(ast);
    await fs.writeFile(path.join(outDir, 'grammar.json'), this.renderGrammar(tokens));
    await fs.writeFile(path.join(outDir, 'package.json'), this.renderManifest(ast));
    const child = spawn('npm', ['run', 'compile'], { cwd: outDir });
    // ...
  }
}

This class violates every principle at once.

  • SRP — parse, extract, render, write, spawn. At least five reasons to change.
  • OCP — adding a Sublime grammar emitter requires editing IDEBuilder.build. Not closed.
  • LSP — no substitutable abstractions at all; you cannot swap the grammar renderer without inheritance tricks.
  • ISPIDEBuilder couples its callers to its full surface; a test that wants to check manifest rendering alone has to set up the whole object.
  • DIP — depends directly on fs and ts; untestable without a filesystem and a TypeScript compiler in the room.

The counterexample is not a strawman; it is the shape a two-hundred-line prototype naturally takes when the principles are not held to from the start. The only defence against this shape is the discipline of splitting before the pressure to stop splitting arrives. Parts 05 and 06 continue that discipline: the IR gets its own article, the test strategy gets its own article, because treating them as afterthoughts of "the build pipeline" is exactly how god objects are born.

What SOLID does not give us

A closing honesty note. SOLID tells us how to arrange the modules. It does not tell us what the modules' contents should be. The shape of LanguageIR — whether it carries the raw @Token regexes or pre-processed TextMate scope names, whether snippet bodies are strings or structured templates, whether LSP features are declared per-method or per-capability — is a design call no principle adjudicates. Part 05 makes that call and defends it. The SOLID guardrails this article pins down are what stop the design call, once made, from being contaminated by unrelated concerns that slid in because nothing was keeping them out.

Build counterpart

The SOLID principles inventoried here are taken up, strategy by strategy, across the emitter half of the companion build series, Ide.Dsl — Build. Build 04 — EmitterRegistry + grammar emitter instantiates open/closed through a registry mirroring ScaffolderRegistry; Build 05 — Manifest emitter and Build 06 — Snippets, commands, task provider each carry one single responsibility per emitter; Build 07 — LSP server emitter shows port segregation across handler files.

⬇ Download