Chapter 22c — Modularity: Sub-Packages and Style Composition
A requirements package that scales is one where half your requirements can be loaded without the other half.
The previous chapter closed on the gate: compliance --strict as the ratchet that refuses to let the build leave the developer's machine until every Feature is satisfied, every Approved Requirement has a satisfier, and every Critical AC is verified. The gate works. The 778 tests pass. The 22 Requirements and 25 Features of @frenchexdev/requirements itself, plus the 108 Features and 1 Requirement of this CV repository, plus the 46 Features of @frenchexdev/ssg-site, all cohere into a single traceability graph that the CLI can walk in under two seconds on a laptop.
And yet something is starting to creak. The 108 Features under requirements/features/ sit in a single flat directory. The 1 Requirement — NoJavaScriptRequirement — sits in a directory built for a population it never grew into. The 46 Features under packages/ssg-site/requirements/features/ already live in their own sub-package, but the arrangement is accidental, not designed: they ended up there because the ssg-site extraction of Phase 7c moved a chunk of rendering code, and the Features tagged to that code followed. The result is three directories with no shared vocabulary of which-lives-where, no single Style, no way for a consumer of the frontend to install only the Features that matter to them.
This chapter is about the second architectural move the package has to make. The first, in Chapter 00, was naming the thing — Requirement as a typed class, not a rhetorical noun in frontmatter. The second is dividing the thing — splitting a flat package into bounded sub-packages, each with its own Style, its own package.json, its own semver cadence, and its own compliance report, all composed into a single traceability graph at the repository root.
The move is not ornamental. A requirements package whose directory has more than a hundred files no longer answers the question "what is the shape of this system?" by the mere act of ls-ing itself. The answer has to be reconstructed by reading. Reading is slow, forgetful, and — once the headcount touches two or three — impossible to synchronise. Bounded contexts, in Eric Evans' original sense in Domain-Driven Design, are not a nice-to-have in a requirements DSL; they are the single mechanism that lets the DSL's own graph be readable by anyone who has not personally authored every node.
What follows is the operational plan to perform the split without breaking compliance --strict, plus the one new primitive — composeStyle() — that the split requires the DSL itself to ship.
The flat directory as a starting point
The repository today has three populated requirements directories. requirements/features/ holds 108 .ts files, each exporting a single abstract Feature class. requirements/requirements/ holds exactly one: no-javascript.ts. And packages/ssg-site/requirements/features/ holds 46 — for now, only the smoke Feature is fully wired up while the rest are in transit from Phase 7c Option A — but the directory is the destination for every ssg-site-bound Feature as the extraction completes.
Alt text: diagram of the current repository layout. The repository root has a requirements/features/ directory with 108 Feature files and a requirements/requirements/ directory with one Requirement file (NoJavaScriptRequirement). The packages/ssg-site/ workspace package has its own requirements/features/ directory with 46 more Feature files. A single requirements.config.json points at the root directories and declares DefaultStyle as the project Style. A red annotation box records what is missing: no platform bundle, no composed Styles, no bundle column in the compliance report.
Caption: the configuration today. One requirements.config.json, one Style, two disconnected Feature islands (root and ssg-site), and a Requirement directory that outgrew its population of one a year ago. The split this chapter describes replaces every arrow in this diagram.
The flat-directory arrangement was the right default when the package had fewer than a hundred items. A flat directory has two virtues that matter enormously at that size. The first is that the whole graph is visible by ls. A new contributor can run the command, see every file, read the filenames, and reconstruct in their head the approximate shape of the system without having to follow any import graphs. The second is that there is nothing to configure: the compliance scanner walks a single path, requirements/features/, and finds everything. No globs, no nested workspaces, no per-bundle Style lookup. The simplicity is what made the DSL usable during its first year.
Past a hundred items, those same two virtues invert. ls no longer helps — the eye gives up at file forty or so and the filenames blur into each other, especially when a project has a handful of naming conventions overlapping (some Features named by feature, some by implementation file, some by UI element). A crawler that walks one path now walks a path with more than a hundred files regardless of which subset of the system a given developer is working on: a frontend change causes the scanner to read all 108 files to find the six it needs, because there is no index that says these six are frontend, these thirty are build pipeline, these twelve are accessibility. And, worse, because there is no index, every Feature is implicitly classified as part of the same Style. There is one DefaultStyle composed for the whole repo, and the AgileStyle's INVEST validator — useful for front-end user stories — runs against the build pipeline's ISO-29148-shaped Requirements, producing either false positives or a permissive validator that pretends it did not see the mismatch.
This last point is the one that makes the split not merely convenient but necessary. The Style system, introduced in packages/requirements/src/style.ts, is parameterised per project — but project in the current codebase means repository, and the five shipped presets (DefaultStyle, IndustrialStyle, LeanStyle, AgileStyle, KanbanStyle) all compete for the same singleton slot in requirements.config.json. A repository that mixes frontend stories, build-pipeline compliance requirements, and cross-cutting accessibility rules does not have one Style; it has three. Without sub-packages, there is nowhere to put the three.
One further force pushes toward the split, and it is maybe the most revealing: the crawler has become a regression hazard. The compliance scanner as currently configured runs on every git push. When the Feature count crossed ninety, the scanner took just under a second — fast enough to be invisible. When it crossed a hundred and eight, it took one-point-eight seconds. It still feels fast, but the scaling is linear in the Feature count, and it is linear not just in the scanner's time but in the typecheck that runs immediately before. The Feature list has grown by a factor of three since the first @frenchexdev/requirements release, and the next factor of three — three hundred Features, which a project of this ambition will reach by late 2026 — would push the scanner past the five-second mark, which is the internal threshold past which a precondition gets silently skipped by developers who are in flow. A slow gate is a bypassed gate, as Chapter 13 noted. The flat directory's one-to-one relation between repository size and scan time was the silent clock that kept running. The split cuts the clock into four parallel clocks, each of which can run concurrently on a modern laptop with four cores, and the wall-clock time of the aggregated scan stays near the single-bundle time of the largest bundle — which is, by construction, smaller than the whole.
The symptoms therefore compound. Visibility loss: the eye gives up. Classification loss: filenames no longer map to domains. Style loss: one vocabulary pretends to speak for three. Time loss: the linear scaling bites. Each is survivable on its own. The combination is what makes the flat directory a local minimum whose exit is a bounded-context split.
What modularity means for a Requirements DSL
Before drawing the target diagram it is worth being precise about what modularity does and does not mean in this context, because the word is ambiguous enough to accommodate at least three distinct architectures, only one of which is what the DSL needs.
The first sense is physical modularity: files on disk organised into separate npm packages, each with its own package.json, its own version, its own exports map. Physical modularity is what pnpm, Nx, Turborepo, and Rush exist to support. Its unit is the installable artefact. Its benefit is that a consumer who wants only the frontend's Requirements can pnpm add @frenchexdev/requirements-frontend and pay the bundle cost of only that slice. Its cost is the release engineering overhead — six package.json files instead of one, six tsconfig.json references to synchronise, six sets of exports to curate.
The second sense is logical modularity: bounded contexts in the Evans sense, a notion entirely independent of how the files are packaged. A logical module is a coherent slice of the domain with its own ubiquitous language — its own words for its own things — that can be understood without reference to the other slices. Two Features that both mention navigation but mean by that word two different things (the site's top-bar navigation vs the terminal UI's arrow-key navigation) live in two different logical modules even if they sit in the same npm package.
The third sense is Style modularity: each bundle runs its compliance against its own RequirementStyle vocabulary. The AgileStyle's INVEST validator fires on frontend story-shaped Features; the IndustrialStyle's SIL-leveled risk taxonomy fires on the platform bundle's safety-critical Requirements; neither fires on the other. Style modularity is specific to this DSL — it does not map onto any general notion from DDD or from npm — and it is the one modularity that the current package cannot express at all.
These three modularities interact, and one of the easy errors when planning a split is to collapse them. A developer accustomed to Nx might reach for physical modularity as the only instrument and discover that the act of creating packages/requirements-frontend/ does not, by itself, give the frontend Features a distinct Style. A developer coming from DDD might build beautiful bounded contexts entirely inside a single package and wonder why the build is still slow and the bundle still pulls in things that do not ship. The move this chapter describes combines all three, in that order: logical modularity names the bounded contexts, Style modularity gives each context its own vocabulary, physical modularity turns each context into an installable artefact.
It is tempting, at a fourth level of abstraction, to try and unify them — to declare a single concept of "module" that instantiates all three dimensions in one stroke. This is the wrong move. The three dimensions are orthogonal. A bundle can be physically split without its Style changing (two ssg sub-packages sharing one SsgStyle). A Style can vary without the physical layout changing (the registry could host two Styles in one package). A bounded context can span packages (a cross-cutting concern like accessibility). Forcing the three to align one-to-one produces either too many packages (one per Style fork) or too few (one per organisational team, which is not the right grain for compliance). The discipline is to keep the three independent and declare the alignment where it exists, rather than insist on it by construction.
In practice, this separation of concerns is what makes the migration tractable. Step A of the migration — the inventory pass described in Section 7 — is purely logical: it draws the bounded-context boundaries without moving any files. Step B — the bundle-wise extraction — is physical: it moves the files across the boundaries the inventory drew. Step C — composing the Styles and wiring the config — is Style modularity: it gives each bundle its own vocabulary. The three steps correspond one-to-one to the three kinds of modularity. A migration that tried to do all three in a single commit would be unreadable in review; one that does them in sequence is obvious. That is the pragmatic payoff of keeping the modularities distinct.
There is a fourth temptation worth naming, if only to dispose of it: access control as a fourth modularity, where bundles are split because different teams should only be allowed to edit their own bundles. This is a social concern, not a structural one, and file-system permissions are not how engineering teams should express it. The correct place for that concern is the repository's branch-protection rules and code-owners file — which are orthogonal to how the packages are organised. A team whose responsibility is the frontend bundle can be the code-owner of packages/requirements-frontend/ without the bundle itself enforcing anything beyond its import graph. Bundles are for the compiler; code-owners are for the humans. Conflating them produces packages whose boundaries exist for political reasons and immediately rot when the political boundary moves.
The target sub-package layout
The target, at the end of the migration, is five packages plus the DSL core. The DSL core is @frenchexdev/requirements, which stays exactly as it is — its only obligation is to expose a new composeStyle() function (Section 5) so the bundles have something to compose against. The four domain bundles are requirements-ssg, requirements-frontend, requirements-cv, and requirements-platform. The fifth, requirements-platform, is the shared kernel — the cross-cutting Requirements every bundle depends on.
Alt text: layered diagram. At the top, @frenchexdev/requirements provides the DSL core — Feature, Requirement, and the new composeStyle() helper. Below it, @frenchexdev/requirements-platform holds the three cross-cutting Requirements that every bundle depends on: no-JavaScript, accessibility, performance. Below platform, three parallel domain bundles each own their own Features: requirements-ssg for the build pipeline, requirements-frontend for the client-side presentation layer, requirements-cv for the PDF generator. At the bottom, the repository root aggregates all four via requirements.config.json into one compliance report.
Caption: five packages, three tiers. The DSL core at the top carries only the types and the composition primitive. The platform shared-kernel carries only cross-cutting Requirements. Each domain bundle carries its own Features and can be installed independently by an external consumer who needs only that slice.
Three things about this layout are worth calling out before moving on. First, the shared kernel is the Requirements bundle, not a Features bundle. The domain bundles bring the Features; the platform brings the rules those Features have to satisfy. This is the inversion that matters: if you tried to put, say, a LinkValidationFeature into platform because accessibility depends on it, you would be pushing a deliverable into a kernel. A kernel holds rules, not deliverables. Features are deliverables. They belong in the bundle of the team that builds them.
Second, the root aggregator's job is strictly orchestration. It holds the requirements.config.json that enumerates the bundles; it triggers the CLI once to produce one compliance report; it is not itself a source of Requirements or Features. At the end of the migration it is a package.json with a scripts block and not much else.
Third, the ssg-site package that already exists under packages/ssg-site/ is not the same thing as requirements-ssg. The former is the SSG implementation itself — the build pipeline code. The latter is the bundle of Features and Requirements that describe what that implementation is meant to do. This distinction between the code that runs and the spec that names the thing the code runs has been the recurring structural move of this series since Chapter 00; it applies here too. A package can implement an ssg-specific Feature without itself being a requirements bundle. A requirements bundle has no runtime behaviour beyond exposing its Feature and Requirement classes to the compliance scanner.
The naming convention this establishes is worth stating explicitly because it is going to recur every time a new bundle is considered: the implementation package is named after the domain it implements (ssg-site, cv, etc.); the requirements bundle is named with the prefix requirements- plus the same domain (requirements-ssg-site or, more tersely, requirements-ssg). The prefix is structural — it says this package holds specs, not code — and it survives every subsequent operation: when someone scans the workspace for requirements bundles, they grep for @frenchexdev/requirements-*; when they scan for implementation packages, they look at everything else. The grep is cheap and exact. The cost is two packages per domain instead of one, but the cost is paid once per domain and the benefit is paid every time someone needs to distinguish what we promised from what we built — which is every day.
The shared-kernel pattern
Eric Evans' Domain-Driven Design, Chapter 14, introduces the Shared Kernel pattern as one of several ways two bounded contexts can relate. Its definition is precise: a subset of the model that two or more teams agree to share, keep synchronised, and treat as authoritative. Changes to the shared kernel require coordination across all consumers; changes within a bounded context do not. The price of the pattern is the coordination cost. The benefit is that the shared subset is not duplicated — both contexts speak the same words for the same things in the shared slice, even while they diverge in their own territory.
The requirements-platform bundle is the shared kernel of this split in exactly that Evansian sense. It will hold, at minimum, three Requirements:
NoJavaScriptRequirement— already in the repo at requirements/requirements/no-javascript.ts. It is cross-cutting: every bundle (ssg, frontend, cv) has Features whose behaviour contributes to the claim that the site's text renders without JavaScript. The build pipeline contributes by emitting pre-rendered HTML. The frontend contributes by treating JS as enhancement. The CV bundle contributes by generating a PDF that by definition does not execute JS. All three depend on this Requirement; none of the three exclusively owns it.AccessibilityRequirement— not yet authored, but implied by the existingAccessibilityFeatureunder requirements/features/accessibility.ts. The Requirement it is meant to satisfy is structural: every user-facing surface of the site must reach WCAG 2.2 AA, independent of which bundle produced the surface. Frontend Features satisfy it by passing axe, ssg Features satisfy it by emitting accessible HTML, cv Features satisfy it by tagged PDFs.PerformanceRequirement— implied by thePerformanceFeaturehierarchy. The Requirement is cross-cutting in the same way: page load budgets affect both the ssg (HTML size) and the frontend (script execution), and — depending on how strictly the PDF is counted as a page — also the cv bundle.
The triangulation of three bundles, three Requirements, all cross-cutting is the canonical Shared Kernel shape. Each domain bundle depends on platform. Each domain bundle produces Features that satisfy the platform's Requirements. The platform itself produces zero Features — it holds rules, not deliverables. Its satisfiers come from elsewhere.
Evans is careful in Domain-Driven Design to warn that the Shared Kernel pattern is one of several integration strategies, and that each strategy implies a different coordination regime. Customer-Supplier and Conformist are alternatives. Anti-corruption Layer is a fourth. The question which pattern fits depends on whether the two contexts share a model or merely talk to each other. The requirements-dogfood case is unambiguously Shared Kernel: the platform Requirements are part of the model each domain bundle uses — they appear by typed reference in each domain bundle's @Satisfies decorators — and their shape is authoritative across every bundle. No translation happens at the boundary. The edge is a direct typed reference, not an adapter. That is the signature of a shared kernel, and it is what distinguishes this integration from, say, a generic adapter pattern where each bundle would translate NoJavaScriptRequirement into its own local vocabulary before referring to it.
The coordination cost of the Shared Kernel, which Evans foregrounds, is real here too. A change to NoJavaScriptRequirement — say, a clarification of the EARS response clause, or an update to the fitCriteria list — affects every domain bundle's satisfier edges, and therefore every domain bundle's compliance report. The coordination is not onerous in a single-owner repository (the author decides, commits, runs compliance --strict), but the pattern's cost would rise sharply in a multi-owner setting, where changing the kernel Requirement would require agreement across every team that depends on it. The point of naming the pattern explicitly is that the coordination cost is not an accident of how the code happens to be laid out; it is the defining property of the Shared Kernel, and it is the thing the author of the next cross-cutting Requirement has to sign up for. Adding a Requirement to platform is a commitment to keep it stable, because every bundle now has an edge into it.
Alt text: a many-to-many graph. The platform bundle contains three Requirement nodes: REQ-NO-JS, REQ-A11Y, REQ-PERF. Three domain bundles — ssg, frontend, cv — contain Feature nodes. Dashed @Satisfies edges run from Features into Requirements. ProgressiveEnhancementBaselineFeature, BuildPipelineFeature, LinkValidationFeature, and NavigationFeature all satisfy REQ-NO-JS. AccessibilityFeature and PdfTaggingFeature satisfy REQ-A11Y. BuildPipelineFeature and CvBuildFeature satisfy REQ-PERF.
Caption: the shared-kernel pattern made concrete. The three platform Requirements are each satisfied by Features from multiple bundles — the definition of a cross-cutting concern. No bundle alone owns the claim that the site has no JavaScript, or is accessible, or is fast; all three contribute.
The three Features that currently satisfy NoJavaScriptRequirement — visible from a simple grep — are progressive-enhancement-baseline.ts, link-validation.ts, and build-pipeline.ts. That this many-to-one relation is already present in the current flat directory is what makes the Requirement a natural fit for the shared kernel. If only one Feature satisfied it, the Requirement would belong inside the bundle that owns that Feature. Three distinct satisfiers from what will become three distinct bundles is the signature of a cross-cutting concern, and cross-cutting concerns are exactly what kernels hold.
composeStyle() — the missing primitive
The DSL today ships five Style presets from packages/requirements/src/styles/: DefaultStyle (ISO 29148 + Volere + EARS), IndustrialStyle (IEC 61508/61511/62443 + 21 CFR Part 11 + GAMP 5), LeanStyle (Toyota / A3 / PDCA), AgileStyle (Scrum / XP / SAFe / BDD with an INVEST validator), and KanbanStyle (Anderson's Kanban Method with Classes of Service). Each is a constant-typed RequirementStyle object with its own vocabulary, validators, templates, reporter, and fit-criterion adapters. A project picks one at a time by editing requirements.config.json.
What the DSL does not ship is a way to combine two. A multi-bundle repository has three bundles, each wanting a narrowed version of the Default — the ssg bundle wants Default plus DocumentationQuality as a kind and a docs-review-passed status, the cv bundle wants Default plus PdfTagging as a kind and a relaxed risk taxonomy — and today the only way to produce those narrowed versions is to fork DefaultStyle wholesale. A fork is not composition. A fork drifts. The next time someone adds a kind to DefaultStyle (say SecurityCompliance), the ssg and cv forks stay ignorant of the addition. The gate on each forked Style passes; the gap between the forks and the upstream opens silently.
The missing primitive has a clear signature. The structural types that composeStyle() has to union are already present in packages/requirements/src/style.ts: requirementKinds is a readonly string[], statusWorkflow.transitions is a readonly { from, to }[], sourceKinds is a readonly SourceKindSchema[], riskTaxonomy.levels is a readonly string[]. The composition function takes a base Style and a partial vocabulary (plus optional overrides on templates, validators, and reporter) and returns a new Style whose arrays are unioned, whose validators are chained, and whose templates are merged.
The signature, in TypeScript:
// packages/requirements/src/style-compose.ts
import type {
RequirementStyle,
StyleVocabulary,
StyleValidators,
ValidationResult,
} from './style';
export interface StyleOverrides {
readonly id: string;
readonly version: string;
readonly vocabulary?: Partial<StyleVocabulary>;
readonly validators?: Partial<StyleValidators>;
}
export function composeStyle(
base: RequirementStyle,
overrides: StyleOverrides,
): RequirementStyle {
return {
id: overrides.id,
version: overrides.version,
vocabulary: mergeVocabulary(base.vocabulary, overrides.vocabulary),
validators: chainValidators(base.validators, overrides.validators),
templates: base.templates, // mergeable; omitted for brevity
reporter: base.reporter,
fitCriterionAdapters: base.fitCriterionAdapters,
};
}// packages/requirements/src/style-compose.ts
import type {
RequirementStyle,
StyleVocabulary,
StyleValidators,
ValidationResult,
} from './style';
export interface StyleOverrides {
readonly id: string;
readonly version: string;
readonly vocabulary?: Partial<StyleVocabulary>;
readonly validators?: Partial<StyleValidators>;
}
export function composeStyle(
base: RequirementStyle,
overrides: StyleOverrides,
): RequirementStyle {
return {
id: overrides.id,
version: overrides.version,
vocabulary: mergeVocabulary(base.vocabulary, overrides.vocabulary),
validators: chainValidators(base.validators, overrides.validators),
templates: base.templates, // mergeable; omitted for brevity
reporter: base.reporter,
fitCriterionAdapters: base.fitCriterionAdapters,
};
}The merge rules, dimension by dimension, are not arbitrary. Each is dictated by the semantic of the underlying vocabulary and by the principle that upstream changes must propagate.
For requirementKinds, verificationMethods, rationaleKinds, and sourceKinds (all flat collection types), the rule is union. The composed Style knows every kind the base knows plus every kind the overrides add. A bundle that says "we recognise DocumentationQuality as a kind" does not thereby lose Functional, NonFunctional, Constraint, Compliance, and UserStory; it extends the set. Unioning also means that adding a kind to DefaultStyle upstream automatically appears in every downstream composed Style — the propagation property that forking breaks.
For statusWorkflow.transitions, the rule is also union, but with a guard: the composed workflow's initial state must remain the base's initial (unless explicitly overridden), and the composed workflow's terminal set is the union of both. This keeps the workflow from accidentally becoming unrooted or from losing its terminality. A bundle can add a docs-review-passed → Approved transition without having to restate the entire DefaultStyle workflow.
For riskTaxonomy.levels, the rule is override-if-provided, union-otherwise. The risk taxonomy is the one dimension where a bundle may legitimately want to replace the base rather than extend it — a cv bundle whose risk profile is "did the PDF render correctly" does not want IndustrialStyle's SIL 1–4 gradations. If the overrides specify riskTaxonomy, the composition takes it wholesale. If not, the base's is preserved.
For statementPatterns, the rule is append with uniqueness: a bundle can add a new pattern, but it cannot silently shadow an existing pattern's definition. Two patterns with the same pattern field at compose time is a composition error, raised at module load, not at compliance-scan time. The asymmetry with the previous dimensions is deliberate — statement patterns are structural, and silently replacing one changes the shape of every Requirement that uses it.
For the validators dimension, the rule is chain. The composed validateStatement runs the base first, then the override; if the base fails, the composed fails; if the base passes and the override fails, the composed fails; only if both pass does the composed pass. This is the stricter of the two possible rules (AND vs OR), and it is the correct one: a validator expresses an invariant, and chaining preserves the invariant — a composed Style must uphold every invariant the base upholds, plus any new ones. The override can make the validator stricter but not laxer.
For the reporter dimension — the object that renders Requirements to Markdown or console — the rule is override if provided, inherit otherwise, with a narrow extension point: the overriding reporter can hook into the base reporter via a super-like call, letting the override render a bundle-specific header followed by the base's rendering of the body. The extension point is structural, not magic: the composed reporter's methods receive a baseReporter argument as the second parameter, and the implementation chooses whether to call it. This keeps the default case — a bundle that does not care about reporting — trivial, while leaving the opinionated case (e.g., the cv bundle wants PDF-targeted rendering) expressible.
The validator chain, specifically, deserves one more paragraph because it is the rule most likely to surprise. Consider a bundle whose Style wants to relax DefaultStyle's EARS-pattern validator — say, a lean bundle that wants free-form statements, not just the five EARS patterns. The chain rule forbids this outright: a composed Style cannot be laxer than its base. The right expression for "I want a different validator entirely" is not composeStyle(DefaultStyle, { validators: myLaxerValidator }) but a fresh Style built from scratch. Composition is for narrowing, not broadening. This is the discipline the rule enforces, and it is worth enforcing precisely because the alternative — a compose function whose override can silently weaken the base's invariants — would undo the propagation guarantee that motivated the primitive in the first place. If downstream can quietly turn off upstream's checks, the whole reason to compose disappears.
Alt text: composition tree. DefaultStyle at the top, three composeStyle arrows fanning down to SsgStyle, FrontendStyle, and CvStyle. Each leaf shows what the narrowed Style adds or overrides. SsgStyle adds a DocumentationQuality kind, a docs-review-passed status, and a build-time validator. FrontendStyle adds a story statement pattern and an INVEST validator. CvStyle adds a PdfTagging kind and overrides the risk taxonomy with bundle-specific levels.
Caption: every narrowed Style is one call away from the base. Adding a kind to DefaultStyle tomorrow appears in all three children automatically; the children cannot silently drift from the base because they do not own its vocabulary, they only union into it.
What goes wrong if you just copy-paste a Style
Consider the alternative, the one a team takes when composeStyle() does not exist. A developer who needs the ssg bundle to recognise DocumentationQuality as a kind opens default.ts, copies it to ssg-style.ts, renames the constant, and adds 'DocumentationQuality' to the kinds array. The change works. The compliance scanner, pointed at the new Style, accepts DocumentationQuality as a valid kind field on a Requirement. The ssg bundle's tests pass. The gate is green.
Three weeks later, a different developer adds 'SecurityCompliance' to DefaultStyle's kinds, because the main repo has a new Requirement about CSP headers. They update default.ts; they do not update ssg-style.ts because they do not know ssg-style.ts exists. A month later, a third developer in the ssg team authors a Requirement whose kind is SecurityCompliance. They import SsgStyle. The compliance scanner loads the Requirement under SsgStyle, checks the kind field against SsgStyle.vocabulary.requirementKinds, does not find SecurityCompliance there, and reports a structural error.
That is the good outcome of the drift. The developer sees the error, investigates, discovers the copy-paste, and fixes it. The bad outcome is more insidious: a developer in the ssg team authors a Requirement whose kind is Compliance — the base kind — but intends the security flavour of it. Because the ssg fork still has 'Compliance' in its kinds array (it was there from the original copy), the Requirement validates. But the reporter, looking for security-specific language in the narrated output, does not find any, because the reporter is the forked version of a three-week-old DefaultStyle's reporter — which has no notion of security compliance. The Requirement passes the gate. It reaches production. Nobody notices the inconsistency until someone runs the compliance scanner under the main repo's DefaultStyle and the ssg Requirement's kind is now legible as SecurityCompliance, and the ssg Style's rationale-kinds set does not include the base's new security-audit rationale, and the output of requirements show for the offending Requirement is missing three fields that the main repo's reporter expected to find.
The whole point of composition over copy is that upstream changes propagate. composeStyle(base, overrides) does not freeze a point-in-time snapshot of base; it holds a live reference. If the base's vocabulary grows, every composed child automatically grows with it. The validators chain; a stricter base-level check immediately applies to every child. The drift closes the moment the update lands in the base, not three versions later when someone happens to notice.
The pattern, when you step back, is nothing more than functional composition applied to a data structure, and the reason it is unusual in requirements tooling is that most requirements tooling does not have a typed data structure to compose. Jira has custom fields; Confluence has templates; neither has a RequirementStyle as a first-class value that composes under a known algebra. The DSL's type system puts the algebra in reach. composeStyle() is the function that reaches for it.
There is a deeper point under the vignette, which is that the primitive's presence changes the incentives around authoring a Style. Without composeStyle(), the rational move when you need a small variation is to copy, because the alternative — live with the base and add a one-off guard in your own code — scatters the customisation across files where nobody expects to find it. With composeStyle(), the rational move is to compose, because the customisation lives in one place, inherits from the base, and breaks loudly when the base diverges. A small architectural primitive can reshape what practitioners want to do, and this is one of the cases. The first bundle whose author uses composeStyle() pays the one-time cost of learning the merge rules; every subsequent bundle benefits from the same shape. The second-order effect, a year later, is that nobody in the project ever forks a Style, because nobody ever has a reason to.
Migration path — three reversible steps
The operational heart of the chapter is the path from 108 files in one directory to five coherent packages, executed without breaking compliance --strict at any point, and rollback-able at every step except the last. Three steps. The first two are reversible — you can undo them by running the inverse commands. The third is the commit, the single irreversible act; by the time you run it, you have evidence from the previous two that the split works.
Step A — inventory. No files move. Every existing Feature is tagged, in-place, with a bundle field that names which destination sub-package it is bound for. The tag is a string literal type with four possible values: 'ssg' | 'frontend' | 'cv' | 'platform'. It lives as a new optional field on the Feature abstract class in packages/requirements/src/base.ts. Optional, because during the migration some Features will not yet be tagged; the compliance scanner treats undefined as "un-tagged" and reports it as a gap.
The code change to Feature is small:
// packages/requirements/src/base.ts — additive
export type BundleTag = 'ssg' | 'frontend' | 'cv' | 'platform';
export abstract class Feature {
abstract readonly id: string;
abstract readonly title: string;
abstract readonly priority: Priority;
readonly enabled?: boolean;
readonly bundle?: BundleTag; // NEW — optional during migration
}// packages/requirements/src/base.ts — additive
export type BundleTag = 'ssg' | 'frontend' | 'cv' | 'platform';
export abstract class Feature {
abstract readonly id: string;
abstract readonly title: string;
abstract readonly priority: Priority;
readonly enabled?: boolean;
readonly bundle?: BundleTag; // NEW — optional during migration
}Alongside it, a new CLI subcommand:
npx requirements compliance --group-by bundlenpx requirements compliance --group-by bundleThe subcommand reads the same registry as the existing compliance, groups the Feature rows by their bundle tag, produces a bundle-wise report, and — when combined with --strict — fails if any Feature is untagged. This is the one-command test that the inventory is exhaustive. The developer iterates: tag ten Features, run the command, see that thirty more are untagged, tag them, repeat. The process is mechanical. No Feature changes its implementation; no file moves; the git diff is one line per Feature.
Step A is reversible. To undo it, delete the bundle field from the Feature base class and from every tagged Feature. The package returns to its pre-A state. No compliance report has been destroyed; no test has been rewritten; no import graph has been restructured.
What Step A actually accomplishes, beyond the visible tagging, is the production of a ground truth the rest of the migration will reference. The --group-by bundle report is, in effect, the logical modularity of Section 2 made concrete: when the output shows 46 ssg Features, 37 frontend Features, 8 cv Features, and 17 platform Features (numbers invented for the example — the real counts will fall out of the actual tagging), that list is the bounded-context decomposition of the repository. The four bundles' names are in one place; their populations are enumerated; the edges between them are visible (a Feature tagged ssg with a @Satisfies(FrontendRequirement) argument lights up immediately as a candidate peer-to-peer edge that the subsequent cross-bundle lint will flag).
The report is also the artefact that makes Step B decomposable. Without the inventory, a contributor about to extract the ssg bundle has to read every Feature file to decide whether it belongs in ssg. With the inventory, they filter by tag. The cost of the decision is paid once, under compliance-gate pressure (no Feature can be untagged, which means nobody can defer the decision indefinitely), and the output is a reliable classification that every subsequent operation reads from. This is the pattern Martin Fowler calls a branch by abstraction applied to package layout: introduce the abstraction (the bundle tag) first, let it live in parallel with the existing structure until exhaustive, then swap the structure underneath. The abstraction is cheap; the swap is mechanical; the combination is a safe refactor even at the 150-Feature scale.
Step B — bundle-wise extraction. One bundle at a time. Start with the smallest, platform (one Requirement, zero Features; the shortest path to a working package). Create the directory packages/requirements-platform/. Write its package.json (Section 12 walks through the exact shape). Move no-javascript.ts from requirements/requirements/ to packages/requirements-platform/src/requirements/. Compose platform.style.ts from DefaultStyle. Wire the new bundle into requirements.config.json under a new bundles: [] array (Section 8).
Crucially, at the end of moving each bundle, the root-level requirements/index.ts — the current flat aggregator whose first fifty lines were read in preparation for this chapter — is updated to re-export from the new sub-package:
// requirements/index.ts — after Step B for platform
export { NoJavaScriptRequirement } from '@frenchexdev/requirements-platform';
// … Features re-exported unchanged// requirements/index.ts — after Step B for platform
export { NoJavaScriptRequirement } from '@frenchexdev/requirements-platform';
// … Features re-exported unchangedExternal consumers who import { NoJavaScriptRequirement } from 'requirements' still work. The import path is the same. The file on disk has moved, but the surface has not. Run compliance --strict. Run test:all. If both pass, commit the bundle move. If either fails, the re-export is the one-line rollback: delete the re-export, restore the moved file, undo the package.json. Git reflog is the ultimate safety net; the re-export pattern is the first-line safety net.
Repeat for ssg, then frontend, then cv. After each bundle, test:all must be green; compliance --strict must be green; the re-export aggregator keeps the old import paths live. At the end of Step B, every Feature and every Requirement lives in its destination sub-package, and the old flat-import path still works. This is the design choice that makes the whole migration feasible: at no point does an external consumer have to update their imports while the migration is still in progress. Step B is reversible, file-by-file — each bundle move is one commit, and one git revert per bundle rolls it back.
Step C — delete the re-export aggregator. Once consumers have migrated their imports from requirements/index.ts to @frenchexdev/requirements-ssg etc., the aggregator serves no purpose. Delete it. Remove its line from any remaining import statement in the repository. Ship.
This is the irreversible step, and the reason it is placed last, after both inventory and extraction are complete and green: by the time the aggregator disappears, the sub-packages have run through at least one full test:all cycle on their own, their tests have exercised their own Features, and their compliance reports have produced aggregated output. The deletion is not a leap of faith; it is the final cleanup on top of a system that has already been running in the target configuration, just with an extra indirection. Removing the indirection is what makes the modularity real for external consumers — from this moment on, pnpm add @frenchexdev/requirements-frontend gives them the frontend slice and nothing else.
The reason irreversibility attaches specifically to Step C, and not to the earlier steps, is about external contract. Steps A and B are internal — their consequences stay inside the workspace, and no external consumer has a stake in either. External consumers see either the old flat import path (during and after B, because the re-export aggregator is still alive) or the new per-bundle import path (from C onwards). Step C flips that switch. Every external import of requirements (bare specifier) that was not migrated will break on upgrade. The irreversibility is therefore not a matter of the workspace can be restored — it can, trivially, via git revert — but a matter of the public API has changed. Once a version of the workspace has been published in which the aggregator is gone, reverting the revert creates a third version, not a return to the second. Semver is already this strict; the discipline just names it.
A practical consequence is that Step C should not ship in the same release as Steps A or B. Steps A and B are no-api-change refactors; they can ship under a patch version bump of each bundle. Step C is a major-version bump, because it removes a public import path. Separating them ensures that external consumers have at least one release cycle in which both import paths coexist — the old aggregator's re-exports and the new per-bundle paths — during which they can migrate their imports at their own pace. The major bump on Step C is then a clean break: anyone who migrated during the prior cycle upgrades transparently; anyone who did not gets a clear error pointing at the new path. This is exactly the deprecate-then-remove cadence that long-lived npm packages adopt, and it is worth adopting here even though the package currently has zero external consumers, because the discipline it encodes is the one that will matter when the first external consumer appears.
Alt text: swim-lane graph with three columns. Step A — Inventory: add a bundle field to the Feature base class, tag every Feature, verify with compliance --group-by bundle --strict, no files moved. The column loops back to itself with a reversibility arrow labelled "delete field". Step B — Extraction (one bundle at a time): create the packages/requirements-X directory, move files and compose the Style, re-export from the root aggregator, verify with compliance --strict and test:all. The column loops back with a reversibility arrow labelled "git revert bundle". Step C — Aggregator Removal: migrate consumer imports, delete requirements/index.ts, bump major on every bundle. An irreversible arrow leads to "multi-package release".
Caption: three steps, two reversibility points. The migration is an operational sequence, not a design decision made in one commit. Each step is verifiable in isolation; each reversal is a single git command. Only Step C is a point of no return, and by the time it runs the preceding two steps have already validated the end state.
Compliance across bundles
The current compliance --strict gate, described in Chapter 13, scans two configured paths — requirements/features/ and requirements/requirements/ — and produces one report. In the multi-bundle world, the gate has to understand that features and requirements live in more than one place, and that each bundle runs against its own Style.
The natural expression of this is a bundles array in requirements.config.json:
{
"$schemaVersion": "2026-04-14",
"bundles": [
{
"name": "platform",
"path": "packages/requirements-platform",
"style": "PlatformStyle",
"requirementsDir": "src/requirements",
"featuresDir": "src/features"
},
{
"name": "ssg",
"path": "packages/requirements-ssg",
"style": "SsgStyle",
"requirementsDir": "src/requirements",
"featuresDir": "src/features"
},
{
"name": "frontend",
"path": "packages/requirements-frontend",
"style": "FrontendStyle",
"requirementsDir": "src/requirements",
"featuresDir": "src/features"
},
{
"name": "cv",
"path": "packages/requirements-cv",
"style": "CvStyle",
"requirementsDir": "src/requirements",
"featuresDir": "src/features"
}
],
"testDirs": { "unit": "test/unit", "functional": "test/functional", "e2e": "test/e2e" }
}{
"$schemaVersion": "2026-04-14",
"bundles": [
{
"name": "platform",
"path": "packages/requirements-platform",
"style": "PlatformStyle",
"requirementsDir": "src/requirements",
"featuresDir": "src/features"
},
{
"name": "ssg",
"path": "packages/requirements-ssg",
"style": "SsgStyle",
"requirementsDir": "src/requirements",
"featuresDir": "src/features"
},
{
"name": "frontend",
"path": "packages/requirements-frontend",
"style": "FrontendStyle",
"requirementsDir": "src/requirements",
"featuresDir": "src/features"
},
{
"name": "cv",
"path": "packages/requirements-cv",
"style": "CvStyle",
"requirementsDir": "src/requirements",
"featuresDir": "src/features"
}
],
"testDirs": { "unit": "test/unit", "functional": "test/functional", "e2e": "test/e2e" }
}Each bundle entry is self-contained: it names the package path, the Style identifier (resolved through the existing StyleRegistry), and the bundle-relative feature and requirement directories. The testDirs remain global, because cross-bundle integration tests live outside any bundle.
The scanner's Phase 1 — AST extraction — iterates over bundles[] and, per bundle, runs a ts-morph Project rooted at packages/requirements-<name>/tsconfig.json. Phase 2 — registry build — composes the bundle-local registries into one root registry, tagging every entry with its bundle of origin. Phase 3 — gap detection — runs the same three passes but now reports gaps per bundle. Phase 4 — report rendering — produces one aggregated report with a new bundle column.
Alt text: sequence diagram. The requirements CLI reads requirements.config.json, which returns four bundle configurations. Four parallel scan invocations run — one per bundle, each against its own Style — and each produces a local registry and a gap list. An Aggregator stage merges the four registries, resolves cross-bundle @Satisfies edges, runs the cross-bundle lint, and emits a single compliance-report.json. The CLI exits 0 if and only if all four bundles are green and the cross-bundle lint is clean.
Caption: the gate is now an AND across bundles. A failure in any bundle fails the gate. The aggregator also resolves cross-bundle edges — an ssg Feature satisfying a platform Requirement must be reachable through the aggregated registry, not just the bundle-local one.
The key property the aggregator must uphold is that the merged registry is the same graph as the pre-split flat registry — cross-bundle @Satisfies edges resolve correctly through the aggregator, because at merge time every bundle has declared its satisfier links and the aggregator's job is to collate them. An ssg Feature satisfying a platform Requirement produces an edge whose source is in the ssg bundle's registry and whose target is in the platform bundle's; the aggregator resolves the target by looking it up in the platform registry, which it has already built.
Order matters in the aggregation step, and it is worth being precise about it. The aggregator runs the bundle scans in topological order of the dependency graph: platform first (because nothing depends on anything but the DSL core), then the domain bundles in any order (because they depend only on platform, not on each other). The platform registry is available by the time the ssg scan produces its cross-bundle edges, so the resolution is a lookup against already-built data. If the dependency graph acquired a cycle — a domain bundle depending on another domain bundle — the topological sort would fail, and the aggregator would abort with a structural error before attempting to merge. This is a good error: cycles in the dependency graph of requirements bundles are always wrong, because they say bundle A's definition of what-it-is depends on bundle B's definition, which makes neither bundle self-contained. The topological check catches this for free, as a consequence of how the aggregator already needs to order its scans.
A second property: each bundle's Style governs the validation of that bundle's Requirements, not of Requirements it imports from another bundle. When the ssg bundle imports NoJavaScriptRequirement from platform to use in a @Satisfies decorator, the Requirement's kind and statement were already validated under PlatformStyle at the moment the platform scan ran. The ssg scan does not re-validate it. This is the correct behaviour — a Requirement is validated once under its home Style — and it has a subtle consequence: the SsgStyle's vocabulary does not need to include every kind a domain bundle might observe; it only needs to include the kinds for Requirements the ssg bundle itself authors. If every cross-cutting kind lives in platform, where it is validated under PlatformStyle, the domain bundles' Styles stay small.
The --strict exit condition becomes: every bundle's gaps list is empty, and the cross-bundle lint pass (Section 9) reports zero violations. The failure message is now per bundle: the report shows which bundle has the orphan Feature, which bundle has the under-covered Critical AC. A developer working on ssg does not have to read through frontend gaps; the bundle column lets them filter.
Cross-bundle @Satisfies links
Once the graph spans bundles, the @Satisfies edges acquire a new dimension: which bundle does the source belong to, which bundle does the target belong to. Three possibilities exist, and they are not equivalent.
Any bundle → platform. This is the normal case. Every domain bundle's Features are expected to satisfy platform's cross-cutting Requirements. The ssg bundle's BuildPipelineFeature satisfies REQ-NO-JS; the frontend bundle's AccessibilityFeature satisfies REQ-A11Y. This is the entire point of the shared-kernel pattern. Any such edge is allowed without comment.
Peer → peer. An ssg Feature satisfying a frontend Requirement, or a cv Feature satisfying an ssg Requirement. This is unusual. It does not automatically mean the edge is wrong — sometimes a bundle genuinely does depend on another's rule — but it is at minimum a signal. If an ssg Feature is satisfying a frontend Requirement, the Requirement might be misfiled: if more than one bundle needs to satisfy it, the Requirement probably belongs in platform. A peer-to-peer edge should therefore produce a warning: "the Requirement this edge targets may be mis-scoped; consider moving it to platform".
Wrong direction — platform → domain. A platform Feature satisfying a domain bundle's Requirement. This is never right. Platform holds Requirements, not Features; the direction of dependency is fixed — domain depends on platform, not the other way around. Any such edge is a structural error and should fail the compliance gate even without --strict.
The new CLI pass for this:
npx requirements compliance --lint-cross-bundle-linksnpx requirements compliance --lint-cross-bundle-linksIts implementation is one iteration over the aggregated satisfies map. For each (featureId, requirementId) pair, it looks up the bundle tag on both ends. If requirementBundle === 'platform', pass. If featureBundle === requirementBundle, pass (the edge is within one bundle; no cross-bundle concern). Otherwise: warn (peer → peer) or error (platform → domain).
Alt text: three groups of @Satisfies edge patterns. Allowed: any bundle's Feature satisfying a platform Requirement, and any Feature satisfying a same-bundle Requirement. Warnings: peer-to-peer edges between domain bundles, such as an ssg Feature satisfying a frontend Requirement. Errors: a platform Feature satisfying a domain-bundle Requirement — never right, because platform holds rules, not deliverables.
Caption: three verdicts, one rule. Into platform: always OK. Between peers: a smell worth investigating. Out of platform into a domain: structurally impossible in this architecture.
The warn-vs-error distinction matters. A peer-to-peer edge is often legitimate during a transition — a Feature was authored before the Requirement it satisfies was promoted to platform, and the edge is a temporary cross-bundle link awaiting the Requirement's move. Making it a warning rather than an error lets the migration proceed without artificially blocking the gate; the warning log is the audit trail that this cleanup is still pending. A platform-to-domain edge, on the other hand, violates the whole architecture: it says the shared kernel depends on a specific domain, which makes the kernel not-a-kernel. That edge has to fail the gate.
The lint pass also provides a useful feedback loop into the design of the bundles themselves. A peer-to-peer warning that persists across multiple release cycles without resolution is evidence the Requirement at the target end should move to platform — the fact that two bundles need to satisfy it is the textbook definition of cross-cutting. Conversely, a Requirement that sits in platform but is only ever satisfied by one bundle has no reason to be in platform; it belongs in the bundle that actually uses it, where its maintenance cost is paid by the team that benefits from the Requirement. The lint's output, read historically across a quarter's worth of compliance reports, reveals the bundles whose Requirements are drifting in or out of the shared kernel — data the architect uses to renegotiate the boundary. Without the lint, the drift is invisible; with it, it is a per-commit signal.
One thing the lint deliberately does not do is flag absence of cross-bundle edges. A domain bundle with zero edges into platform is syntactically valid — it simply does not satisfy any cross-cutting Requirement. This is a design choice: some bundles genuinely are standalone (imagine a requirements-experimental bundle for prototypes), and forcing them to claim satisfaction of every platform Requirement would convert the cross-cutting claim into a rubber stamp. The rule is "any edge you draw must be allowed or warned or errored according to direction"; the rule is not "every bundle must draw at least one edge into platform". The gate is about validating what you did claim, not about demanding you claim the maximum.
Package dependency discipline
The pnpm workspace this repository uses — configured in pnpm-workspace.yaml — already supports catalogs, the mechanism for pinning shared dependency versions once and referencing them from every package. The current catalog holds typescript: ^5.9.3, vitest: ^4.1.0, @vitest/coverage-v8: ^4.1.0, tsx: ^4.21.0, @types/node: ^25.5.0, commander: ^13.0.0. Every package declares those dependencies as "typescript": "catalog:" rather than pinning its own semver range. Bump the catalog, every package gets the new version.
This pattern extends naturally to intra-workspace dependencies. A requirements bundle depends on the DSL core. The dependency range in the bundle's package.json should be "@frenchexdev/requirements": "workspace:*", which tells pnpm to resolve the dependency against whatever version of @frenchexdev/requirements currently lives in packages/requirements/. Not ^0.1.0; not latest; workspace:*. The star means "whatever is in the workspace". This is the right choice for intra-workspace dependencies for one specific reason: a drift between the DSL version a bundle depends on and the CLI version the repo runs produces the worst possible error class.
Consider the drift scenario. The bundle's package.json says "@frenchexdev/requirements": "^0.1.0". The workspace's current DSL is version 0.2.0, which has renamed @Implements to @Verifies (the real history of this package includes exactly this rename, at the transition from typed-specs to requirements-dogfood). The bundle's tests import @Verifies from @frenchexdev/requirements — which resolves, under the workspace's pnpm protocol, to the 0.2.0 code — but the bundle's TypeScript compiler, reading the bundle's node_modules/@frenchexdev/requirements/package.json version field, sees 0.1.0 stamped there. Any type-level mismatch between 0.1 and 0.2 manifests as an unreadable cascade of errors in the tests, which still import correctly at runtime but fail to typecheck against the declared version. workspace:* short-circuits the whole scenario: there is no declared version; the bundle tracks whatever is present.
Alt text: pnpm workspace dependency graph. At the top, @frenchexdev/requirements is the DSL core. It connects by workspace:* to @frenchexdev/requirements-platform, which in turn connects by workspace:* to the three domain bundles — ssg, frontend, cv. A separate node labelled pnpm-workspace.yaml catalog connects by dashed catalog: edges to every package, pinning shared external versions — TypeScript, vitest, commander, @vitest/coverage-v8 — from one place.
Caption: the two different kinds of workspace edges. Solid workspace:* for intra-workspace packages — always track local. Dashed catalog: for shared external dependencies — pinned once per workspace, inherited by every package. No package pins its own version of either.
The rule this produces is operationally tight: inside a @frenchexdev/requirements-* bundle's package.json, every dependency is either catalog: (shared external) or workspace:* (intra-workspace). No explicit semver ranges. The only places explicit ranges live are the root pnpm-workspace.yaml (for catalog) and the individual packages' own version fields (which npm requires). This is a discipline, not an automated rule — the CLI could validate it, and it probably should, but at this stage of the migration it is enforced by code review.
An automation would be straightforward. A CI check — run by compliance --strict, not as a separate tool — reads every packages/*/package.json, iterates over the dependencies and devDependencies objects, and fails if any entry resolves to a literal semver range rather than workspace:* or catalog:. The check is ten lines of TypeScript, and at some point it will be added to the compliance gate. The reason it is not there yet is that it would fire on the few remaining packages in the workspace that predate the discipline (for example, the @clack/prompts pin in @frenchexdev/requirements is currently a literal ^0.9.0 because clack is not yet in the catalog), and each such fire would need a targeted remediation before the check could be landed. That remediation — extending the catalog to include every shared external — is an ongoing chore that every package bump slowly knocks off. At the point where the catalog covers every dependency used by more than one package, the check goes from "would fire now" to "will pass cleanly", and it gets landed.
This sort of "the check exists in the roadmap, waits on a cleanup that is proceeding independently" is a recurring shape in this codebase, and one the series has encountered before — the cross-bundle lint of Section 9 has the same shape (it lands when the bundles exist), and the property-based tests of Chapter 13 had the same shape (they landed when the registry's shape stabilised). The pattern is: specify the invariant; wait until the invariant holds by construction; then turn the check on. Invariants that get turned on before they hold by construction produce a wall of noise that developers route around, and the signal is lost. The discipline is to defer the automation until the code has already, organically, moved into the shape the automation will enforce.
Testing discipline for bundles
Each bundle owns its own test/ directory. The convention, already honoured by @frenchexdev/requirements itself at 100% coverage on every file under src/, extends to every bundle: packages/requirements-ssg/test/unit/ holds unit tests for ssg Features; packages/requirements-frontend/test/unit/ for frontend; and so on. Each bundle's vitest configuration points at its own src/ and test/ directories; the coverage gate (100% lines/branches/functions/statements) runs per bundle.
Cross-bundle integration tests — tests that exercise the interaction between bundles, for example a test that asserts that an ssg BuildPipelineFeature and a frontend ProgressiveEnhancementBaselineFeature together satisfy REQ-NO-JS — live at the repository root under test/integration/. They import from multiple @frenchexdev/requirements-* packages by name, and they cannot sit inside any single bundle without entangling the bundles' dependency graphs.
The testing style remains what it has been since Chapter 11 — Dog-fooding the DSL's Own Tests: zero describe / it, every test is an @FeatureTest(Feat)-decorated class with @Verifies<Feat>('acName') methods. The rule is absolute across every bundle. A test file that reverts to describe / it fails the gate just as surely as a missing @Satisfies edge does, because the gate is, at its deepest level, a gate on the repository's compliance with its own requirements — REQ-DOG-FOOD being one of them.
A worked example of a cross-bundle integration test, drawn from the hypothetical post-migration state:
// test/integration/no-js-satisfied-across-bundles.test.ts
import { FeatureTest, Verifies } from '@frenchexdev/requirements';
import { BuildPipelineFeature } from '@frenchexdev/requirements-ssg';
import { ProgressiveEnhancementBaselineFeature } from '@frenchexdev/requirements-frontend';
import { NoJavaScriptRequirement } from '@frenchexdev/requirements-platform';
abstract class NoJsCrossBundleIntegrationFeature extends BuildPipelineFeature {
readonly id = 'INTEGRATION-NO-JS-CROSS-BUNDLE';
readonly title = 'Cross-bundle integration: NoJS is jointly satisfied';
// …
abstract crossBundleSatisfactionHolds(): ACResult;
}
@FeatureTest(NoJsCrossBundleIntegrationFeature)
class NoJsCrossBundleIntegrationTest {
@Verifies<NoJsCrossBundleIntegrationFeature>('crossBundleSatisfactionHolds')
verifiesJointSatisfaction() {
const satisfiers = getSatisfiers(NoJavaScriptRequirement);
// Assert that satisfiers contains at least one Feature from each of ssg and frontend.
}
}// test/integration/no-js-satisfied-across-bundles.test.ts
import { FeatureTest, Verifies } from '@frenchexdev/requirements';
import { BuildPipelineFeature } from '@frenchexdev/requirements-ssg';
import { ProgressiveEnhancementBaselineFeature } from '@frenchexdev/requirements-frontend';
import { NoJavaScriptRequirement } from '@frenchexdev/requirements-platform';
abstract class NoJsCrossBundleIntegrationFeature extends BuildPipelineFeature {
readonly id = 'INTEGRATION-NO-JS-CROSS-BUNDLE';
readonly title = 'Cross-bundle integration: NoJS is jointly satisfied';
// …
abstract crossBundleSatisfactionHolds(): ACResult;
}
@FeatureTest(NoJsCrossBundleIntegrationFeature)
class NoJsCrossBundleIntegrationTest {
@Verifies<NoJsCrossBundleIntegrationFeature>('crossBundleSatisfactionHolds')
verifiesJointSatisfaction() {
const satisfiers = getSatisfiers(NoJavaScriptRequirement);
// Assert that satisfiers contains at least one Feature from each of ssg and frontend.
}
}The bundle-level coverage gates compose by AND: the root gate is green if and only if every bundle's coverage is 100% on its own src/, and the root test/integration/ coverage (measured against the union of every bundle's src/) reaches the integration-specific target. The JSON output of compliance --strict aggregates these: each bundle has its own coverage block, and the root has an integration block that lists which cross-bundle edges were exercised.
A subtle point about the integration-layer coverage target: it cannot reasonably be 100%, because the integration layer's job is to test interactions between bundles, not to re-test each bundle's own internal code paths — that is the unit layer's job. The integration target is therefore lower (60-80% is a realistic range for this kind of suite), and its gate is on edge coverage rather than line coverage: every cross-bundle @Satisfies edge should be exercised by at least one integration test. This per-edge gate is new; it does not exist in the current flat repository because there are no cross-bundle edges. The gate's introduction is one of the things the migration buys; the suite of integration tests that exercises the edges is the other.
The scaffolding CLI will need a new subcommand to support this: requirements scaffold integration <edge>, which takes a cross-bundle @Satisfies edge — for example, BuildPipelineFeature → NoJavaScriptRequirement — and emits a stub integration test that imports both sides, constructs whatever minimum fixture the edge needs to be exercised, and asserts the satisfaction claim. The stub is boilerplate; the assertion body is developer-authored. The scaffolder just makes sure the imports cross the right package boundaries, the @FeatureTest decoration references the right class, and the test file is placed in test/integration/ rather than a bundle-local test directory. As with every other scaffolder in the package — the seven level-specific scaffolders already shipped — the emitted code uses @FeatureTest and @Verifies exclusively, not describe / it. The dog-food rule crosses bundle boundaries with everything else.
A worked example: extracting requirements-platform
The shortest migration to run first is the one for requirements-platform, because it has exactly one Requirement (no-javascript), zero Features, and the simplest Style (a near-passthrough composition over DefaultStyle). Walking through it end-to-end demonstrates every moving part of Step B.
Create the directory and package.json.
mkdir -p packages/requirements-platform/src/requirements
mkdir -p packages/requirements-platform/test/unitmkdir -p packages/requirements-platform/src/requirements
mkdir -p packages/requirements-platform/test/unitThe package.json:
{
"name": "@frenchexdev/requirements-platform",
"version": "0.1.0",
"description": "Shared kernel — cross-cutting Requirements for the @frenchexdev ecosystem",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./requirements": {
"types": "./dist/requirements/index.d.ts",
"import": "./dist/requirements/index.js",
"default": "./dist/requirements/index.js"
},
"./style": {
"types": "./dist/platform-style.d.ts",
"import": "./dist/platform-style.js",
"default": "./dist/platform-style.js"
}
},
"dependencies": {
"@frenchexdev/requirements": "workspace:*"
},
"devDependencies": {
"typescript": "catalog:",
"vitest": "catalog:",
"@vitest/coverage-v8": "catalog:"
},
"scripts": {
"build": "tsc",
"test": "vitest run --coverage"
},
"files": ["dist/**/*.js", "dist/**/*.d.ts"],
"license": "SEE LICENSE IN LICENSE",
"author": "Stéphane Erard <stephane@frenchexdev.fr>"
}{
"name": "@frenchexdev/requirements-platform",
"version": "0.1.0",
"description": "Shared kernel — cross-cutting Requirements for the @frenchexdev ecosystem",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./requirements": {
"types": "./dist/requirements/index.d.ts",
"import": "./dist/requirements/index.js",
"default": "./dist/requirements/index.js"
},
"./style": {
"types": "./dist/platform-style.d.ts",
"import": "./dist/platform-style.js",
"default": "./dist/platform-style.js"
}
},
"dependencies": {
"@frenchexdev/requirements": "workspace:*"
},
"devDependencies": {
"typescript": "catalog:",
"vitest": "catalog:",
"@vitest/coverage-v8": "catalog:"
},
"scripts": {
"build": "tsc",
"test": "vitest run --coverage"
},
"files": ["dist/**/*.js", "dist/**/*.d.ts"],
"license": "SEE LICENSE IN LICENSE",
"author": "Stéphane Erard <stephane@frenchexdev.fr>"
}Three things to notice. The exports map has three entry points — the default, the ./requirements sub-entry, and the ./style sub-entry — mirroring the pattern packages/requirements/package.json already uses for ./analysis, ./cli, ./ports. A consumer who only needs the Requirements can import { NoJavaScriptRequirement } from '@frenchexdev/requirements-platform/requirements' and skip loading the Style file entirely. The dependencies block lists @frenchexdev/requirements: workspace:* — the intra-workspace discipline from Section 10. The devDependencies use catalog: for the three shared externals.
Move the Requirement. The file requirements/requirements/no-javascript.ts moves unchanged to packages/requirements-platform/src/requirements/no-javascript.ts. Its imports already read from '@frenchexdev/requirements', so the move requires no edits. Add a barrel:
// packages/requirements-platform/src/requirements/index.ts
export { NoJavaScriptRequirement } from './no-javascript';// packages/requirements-platform/src/requirements/index.ts
export { NoJavaScriptRequirement } from './no-javascript';Compose the Style. Platform's Style is the simplest composition — it adds one kind (CrossCutting) and one rationale kind (cross-cutting-concern), and otherwise inherits DefaultStyle wholesale:
// packages/requirements-platform/src/platform-style.ts
import { composeStyle, DefaultStyle } from '@frenchexdev/requirements';
export const PlatformStyle = composeStyle(DefaultStyle, {
id: 'PlatformStyle',
version: '1.0.0',
vocabulary: {
requirementKinds: ['CrossCutting'],
rationaleKinds: ['cross-cutting-concern'],
},
});// packages/requirements-platform/src/platform-style.ts
import { composeStyle, DefaultStyle } from '@frenchexdev/requirements';
export const PlatformStyle = composeStyle(DefaultStyle, {
id: 'PlatformStyle',
version: '1.0.0',
vocabulary: {
requirementKinds: ['CrossCutting'],
rationaleKinds: ['cross-cutting-concern'],
},
});The composed Style inherits ['Functional', 'NonFunctional', 'Constraint', 'Compliance', 'UserStory'] from DefaultStyle and unions 'CrossCutting' to produce a six-element requirementKinds array. Its rationale kinds are DefaultStyle's six plus 'cross-cutting-concern'. The validators, templates, reporter, and fit-criterion adapters are DefaultStyle's, unchanged.
Wire into requirements.config.json. Add the platform entry to the bundles array exactly as shown in Section 8. The config now has one bundle. Running npx requirements compliance --strict at the repository root produces:
@frenchexdev platform compliance report
generated 2026-04-14T… · registry size: 1 REQ / 0 FEAT / 0 tests
┌────────────────────────────────┬──────────┬───────┬───────────┬──────┐
│ REQ-ID │ Priority │ State │ Satisfiers│ Gate │
├────────────────────────────────┼──────────┼───────┼───────────┼──────┤
│ REQ-NO-JS │ Critical │ Appr. │ 3 │ ✓ │
└────────────────────────────────┴──────────┴───────┴───────────┴──────┘
cross-bundle @Satisfies: 3 edges (all into platform — allowed)@frenchexdev platform compliance report
generated 2026-04-14T… · registry size: 1 REQ / 0 FEAT / 0 tests
┌────────────────────────────────┬──────────┬───────┬───────────┬──────┐
│ REQ-ID │ Priority │ State │ Satisfiers│ Gate │
├────────────────────────────────┼──────────┼───────┼───────────┼──────┤
│ REQ-NO-JS │ Critical │ Appr. │ 3 │ ✓ │
└────────────────────────────────┴──────────┴───────┴───────────┴──────┘
cross-bundle @Satisfies: 3 edges (all into platform — allowed)The three cross-bundle satisfiers — ProgressiveEnhancementBaselineFeature, LinkValidationFeature, BuildPipelineFeature — live under requirements/features/ at this stage of the migration, because only platform has been extracted. Their @Satisfies(NoJavaScriptRequirement) resolves through the aggregator (the Requirement is in the platform bundle; the Features are in the root). The cross-bundle lint sees three domain → platform edges and marks them as allowed.
Update the root re-export. The root requirements/index.ts line
export { NoJavaScriptRequirement } from './requirements/no-javascript';export { NoJavaScriptRequirement } from './requirements/no-javascript';becomes
export { NoJavaScriptRequirement } from '@frenchexdev/requirements-platform';export { NoJavaScriptRequirement } from '@frenchexdev/requirements-platform';External code that still reads import { NoJavaScriptRequirement } from 'requirements' continues to work; the import path is unchanged. Only the source-of-truth has moved.
Run pnpm install to let pnpm pick up the new workspace package. Run pnpm -r build to compile the new package. Run pnpm -r test to exercise the full test suite. Run npx requirements compliance --strict at the root — the whole gate must be green. If it is, commit. If it is not, revert: the whole change is one directory of new files plus one-line edits to the root aggregator and the config; git revert restores the prior state.
The platform extraction takes under an hour end-to-end. The ssg, frontend, and cv extractions will each take longer — tens of files rather than one — but the shape is the same. The investment is front-loaded: the platform extraction is where you discover that composeStyle() actually works, that the aggregator resolves cross-bundle edges correctly, that pnpm picks up workspace:* the way you expected. Every subsequent extraction benefits from that validation.
The empty-Features shape of the platform bundle is another reason to do it first. A bundle with zero Features has no @Satisfies-side authorship; its only role is to expose Requirement classes that other bundles reference. This makes the platform migration the one where the tooling — the aggregator, the cross-bundle lint, the composed Style validation — is exercised against the simplest possible input. If any of those pieces have bugs, they surface here, against a known-simple case, instead of against the ssg bundle with its forty Features and their tangled @Satisfies lists. Get platform green first; then each domain bundle's extraction is an exercise in moving files into a mould that has already been validated, which is categorically easier than the first migration.
There is also a version-control hygiene angle. The platform extraction's git diff is small enough to review in one sitting: one new package.json, one moved file, one new platform-style.ts, a one-line edit to the root aggregator, and a one-block addition to requirements.config.json. A reviewer can read all of it, understand all of it, and approve all of it in ten minutes. The ssg extraction's diff will be larger — forty moved files at minimum — but by then the shape of the extraction is already in the mainline; the reviewer looks at the forty moves and spot-checks that each one followed the pattern platform established. The cognitive load on review is therefore also front-loaded to the platform extraction, which is the smallest and simplest.
One subtle technical note that the walkthrough glossed: TypeScript's tsc -b (project references) needs each bundle's tsconfig.json to declare a references array listing the other packages it depends on. packages/requirements-platform/tsconfig.json needs a reference to packages/requirements/; packages/requirements-ssg/tsconfig.json needs references to both packages/requirements/ and packages/requirements-platform/. Without these references, the tsc -b build at the workspace root will not know to build packages in the right order, and the domain bundle builds will fail looking for artefacts that the dependencies have not yet emitted. pnpm handles the runtime dependency resolution via workspace:*; TypeScript needs its own dependency declaration via references. Both are required; neither substitutes for the other. The walkthrough omits the tsconfig.json for brevity, but a real extraction must wire it up.
Where the Style registry fits
One loose end the chapter has not yet tied down: the existing StyleRegistry and its global BUILT_IN_STYLES constant, exposed from packages/requirements/src/styles/, need to accommodate the new per-bundle composed Styles without forcing them into the built-in set. The registry's original design, described in the package-level CLAUDE.md at packages/requirements/CLAUDE.md, supports a createStyleRegistry() factory that merges built-ins with project-supplied custom Styles. The split exploits this factory exactly: the root requirements.config.json loads a registry seeded with the five built-ins plus the four bundle Styles — PlatformStyle, SsgStyle, FrontendStyle, CvStyle — each imported from its home bundle and handed to createStyleRegistry({ custom: [...] }).
The bundles[] entries in the config file reference Styles by their id field (the string constant each Style carries, e.g. 'PlatformStyle'). The loader resolves the id through the registry, which returns the concrete Style object. This late-binding is what lets the config be declarative: the bundle config says "use whichever Style has this id in the registry the CLI bootstraps", rather than requiring an import at config-parse time. The indirection matters because requirements.config.json is read before any TypeScript compilation has happened; it cannot directly import a .ts module. The registry is the bridge between the config's strings and the TypeScript module graph.
The same pattern works for a bundle that wants to ship a new Style — say, a frenchexdev-legal bundle whose Style is a heavy customisation for a legal domain rather than a narrowing of DefaultStyle. The bundle's package.json exports its Style from a ./style subpath; the root config adds { "name": "legal", "style": "LegalStyle", ... }; the registry factory is seeded with the imported Style object at CLI boot. No new CLI flags are needed; the mechanism is the same as for the narrowed Styles, because the registry does not distinguish compose-derived Styles from hand-written ones.
This is the kind of orthogonality that pays off silently in the long run. The composeStyle() primitive produces plain RequirementStyle values. The registry takes plain RequirementStyle values. The compliance scanner takes plain RequirementStyle values. No layer up the stack cares whether a particular Style was hand-written or composed, whether its vocabulary is inherited or authored, whether its validators are chains or fresh functions. Every layer treats Style as an opaque RequirementStyle. Which means a team that invests in an entirely custom Style gets the same tooling benefits as a team that composes a tiny narrowing over DefaultStyle. The tax on ambition is constant; the reward scales with how far the team wants to push the ontology.
An edge case: the empty bundle
The walked example used the platform bundle, which has one Requirement and zero Features. An even more extreme case — a bundle with zero of either — is worth thinking through, because it reveals how robust (or not) the aggregator is.
Consider a bundle called requirements-experimental, created to hold Features that are not yet ready to be claimed by any existing bundle. At creation time, the bundle is empty: it has a package.json, a tsconfig.json, a src/ directory with no files, a test/ directory with no files. The requirements.config.json has an entry for it. What should compliance --strict do?
The answer the aggregator has to give, and the answer that turns out to be correct, is: nothing, and green. An empty bundle has zero Features, so the "every Feature satisfies a Requirement" condition vacuously holds (there are no Features). It has zero Approved Requirements, so the "every Approved Requirement has a satisfier" condition vacuously holds. It has zero Critical ACs, so the "every Critical AC is verified" condition vacuously holds. The vitest suite has nothing to run, which is either zero tests passing (trivially green) or — depending on vitest's configuration — a non-zero exit code for "no tests found". The config has to disable the latter for empty bundles; otherwise the gate spuriously fails on a bundle whose very emptiness is the expected state.
The reason this case matters is that it is not hypothetical. During Step B of the migration, each bundle starts empty and gets populated file-by-file. If the gate fails during an empty intermediate state, the migration cannot proceed. The vacuous-satisfaction principle is therefore load-bearing: the aggregator must treat "zero of X" as a clean state, not an error, for every X the gate quantifies over. This is a small detail but a real one; most CI systems default to "zero tests found = error", on the theory that a test suite you forgot to run is worse than a suite you know failed. That default is wrong here; the empty bundle is a legitimate transient state, and the gate has to tolerate it.
The performance cost the migration pays back
A migration like this is an investment, and investments have costs that have to be weighed against returns. The returns this chapter has emphasised are legibility, Style granularity, and compile-time subsetting. The cost is primarily build-time.
The pre-split repository builds with one tsc invocation against one tsconfig.json. The compilation is fast because TypeScript's compiler reuses work: a single project with 150 Feature files and one large Requirement type graph re-parses each file once, type-checks the whole closure together, and emits. The post-split repository builds with five tsc -b invocations orchestrated by pnpm, each against its own tsconfig.json, each emitting its own dist/. The total CPU time is higher than a single-project build, because each sub-compilation has its own startup cost — parsing the config, loading lib files, etc.
In practice, the observed overhead on this repository's hardware is a 20–30% increase in total build time. The mitigation is that tsc -b supports incremental builds: a change to one file in the ssg bundle only recompiles the ssg bundle, not every bundle. Incremental builds post-split are typically faster than the pre-split full rebuild, because the work is scoped. The worst case — a change to the DSL core, which forces every bundle to rebuild — is the one where the post-split is slower than the pre-split, and even there the slowdown is measurable but not dramatic.
Compliance scan time is a different story. Pre-split, the scanner ran once over 150 files. Post-split, it runs once per bundle (four scans, each over 30-40 files), plus once over the integration layer. The total work is similar, but the scans can run in parallel if the implementation supports it — and the implementation should, because each bundle's scan is independent. Even without parallelism, the per-scan cost is smaller, and the wall-clock time when the scans run sequentially is roughly the same as the pre-split single scan. With parallelism, the post-split is faster. This is the scaling argument from the opening section cashed in: the split's performance benefit is not on today's 150 files but on the trajectory — as the Feature count grows, the pre-split scales linearly and the post-split scales with the largest bundle, which grows more slowly.
The test suite's runtime is the third component. Vitest can run tests per bundle in parallel; pnpm orchestrates this naturally (pnpm -r test runs each package's test script in parallel, up to the CPU count). Pre-split, vitest's parallelism was intra-suite (sharding test files across workers). Post-split, it is both intra-suite and inter-bundle. The total throughput is higher, not lower.
None of these performance observations are the reason to split. The reason is what the opening section named: a requirements package that scales is one where half the requirements can be loaded without the other half. Performance is a pleasant side effect of having done the split correctly, not the justification.
Closing: modularity is a compile-time property
The whole point of splitting — the reason this is worth the release-engineering overhead — is not organisational. It is not about which team owns which directory; that was already legible from the Feature tags, pre-split. It is not about coverage gates; those already ran per-bundle through vitest's project config. It is compile-time.
A frontend-only consumer, at the end of the migration, can pnpm add @frenchexdev/requirements-frontend and obtain exactly the frontend Features, the platform Requirements they depend on, and the DSL core. They do not pull in the build pipeline's Features; they do not pull in the CV's PDF-specific Requirements; they do not pull in the IndustrialStyle they will never use. Their dependency graph contains three packages rather than one large one. Their install is smaller. Their typecheck is faster. Their import paths are shorter.
This is bundle-size hygiene applied to requirements. The pattern is not new — every mature TypeScript ecosystem that has grown past a hundred exports has made the same move at some point. Three that are worth naming, because each handled the split well:
@tanstack/query-corevs@tanstack/react-query. Tanner Linsley's TanStack Query ecosystem began as a single package (react-query) and split, around its v4 release, into a framework-agnostic core plus framework adapters. The core holds the cache machinery, the query keys, the invalidation logic; the React adapter holds the hooks. Vue, Solid, and Svelte adapters depend on the same core but ship as separate packages. A consumer who works in React does not carry the Vue adapter's code. The split was executed with no breaking change to the React public API —react-queryusers kept their imports — and the benefit appeared immediately on the Vue and Solid sides, where adapters could ship independently of the React release cadence.@babel/corevs the babel plugin ecosystem. Babel's split between a small core that knows about ASTs and plugin pipelines, and hundreds of separate packages for each transform, is the canonical example. Every Babel plugin is its own npm package with@babel/coreas apeerDependency. A project that uses two transforms installs two packages plus the core; it does not install the other ninety. The architectural pressure driving Babel's split was identical to the one described in this chapter: the list of transforms was growing unboundedly, and a singlebabelpackage would have pulled in every one of them regardless of whether the consumer used them.@graphql-tools/mergeand siblings. The GraphQL Tools project splits what could have been onegraphql-toolspackage into a tree of sub-packages —@graphql-tools/merge,@graphql-tools/schema,@graphql-tools/utils,@graphql-tools/load, and many more — each with a narrow, self-contained surface area. A project using GraphQL Tools imports only the sub-packages it needs; the others do not reach itsnode_modules. The sub-package graph is deep (some depend on others), but each node in the graph is small. This is the shape the requirements ecosystem will have at the end of the migration.
The pattern all three share is: one DSL core, plus many bundles that depend on the core and on each other, with every bundle installable independently. The compile-time property this enables — half the bundles can be loaded without the other half — is the property the opening aphorism of this chapter names. A requirements package that scales is one where that property holds.
A telling contrast is what happens in ecosystems that never made the split. The early versions of Angular (pre-v4) shipped one large package that carried every feature; the bundle-size pressure this created on consumers drove the Angular team to a painful re-architecture in which the framework was split into a dozen packages (@angular/core, @angular/common, @angular/forms, @angular/router, …). The split was not free — consumer apps had to update hundreds of imports, and the public teaching materials had to be rewritten — but it had to happen, because the monolith's cost on every consumer was no longer acceptable. The earlier the split happens, the smaller the pain. A requirements package that splits at 150 Features pays a small cost now; one that waits until 500 pays a much larger one, because every one of those 500 Features has by then acquired external consumers whose imports will have to migrate.
The corollary, and this is the one worth internalising for the next time the question "should I split this package?" arises, is that the decision is rarely about whether to split and almost always about when. Packages that ship one concept on one problem stay monolithic forever (lodash's fp module notwithstanding, lodash itself is a single package for good reason — its concept is cohesive). Packages that ship a taxonomy of concepts over a diverse problem domain always split eventually. The requirements DSL is the second kind. It ships a taxonomy — Requirement, Feature, AC, Test; five Styles; seven scaffolders; two kinds of decorator — over a problem domain that spans every software project that has requirements to track. The monolith was always a transient configuration. The only question was when the transition would happen, and the answer, as of the 108-file mark, is now.
The discipline the split imposes is not free. Every new Feature has to be authored under the right bundle; every new Requirement has to consider whether it is cross-cutting and belongs in platform; every new Style narrowing has to be expressed through composeStyle() rather than copy-paste. The compliance gate acquires new failure modes (the cross-bundle lint), new configuration (the bundles array), new report columns (the bundle key). In exchange, the package stops being a single aggregated thing that every consumer has to take wholesale, and becomes a coordinated set of things every consumer can take the subset of.
The author-time ergonomics also change, and this is worth being explicit about because it will feel like friction at first. A developer writing a new Feature today opens a terminal, runs npx requirements feature new, and the wizard walks them through the fields. Post-split, the wizard has to ask an additional question up front: which bundle does this belong to? The question has four canonical answers (ssg, frontend, cv, platform) and the wizard can often pre-fill the correct one based on the satisfied Requirements (if the Feature @Satisfies a platform Requirement, the wizard guesses the domain bundle based on the Feature's title keywords; if the developer overrides, the override is respected). The extra question is one extra keystroke in the wizard. Its cost is small.
But the question carries a cognitive weight the pre-split wizard did not impose. The developer has to know the bundle taxonomy. They have to make a choice. They may get it wrong and have to re-tag the Feature later. The friction is real. The payoff — a correctly-classified Feature that the aggregator can place, validate, and cross-check against the right Style — is delayed, and the delay tempts the developer to rush the choice. The mitigation is that the choice is reversible (just edit the bundle field and re-run compliance), and the feedback loop is tight (the compliance report immediately reveals misclassification via cross-bundle lint warnings). Wrong choices surface fast, cheaply. The friction is, in a word, productive: it forces the developer to think about the domain boundary at the moment they are authoring the Feature, which is the one moment at which that thinking is cheapest.
The next chapter — Chapter 22d — will cover the fifth modularity the DSL has to support, one that this chapter has deferred: lifecycle discipline. Once the bundles are separate, their Requirements evolve on independent cadences. The platform bundle bumps a cross-cutting Requirement; the ssg bundle bumps a build-pipeline Feature; the frontend bundle bumps on its own clock. How each Requirement evolves — status, history[], VersionInfo, VersionPin, @Refines, detectVersionDrift — is the subject of Chapter 22d. For now, the migration described here produces a workspace in which four package.json files are independently versioned, and the only coordination they need is the workspace:* edge that Section 10 established.
Between this chapter and the next, a reader implementing the migration on their own repository should expect to encounter three or four surprises that the chapter has not named. The TypeScript project-references dance will take a half-day of fumbling before it clicks. The vitest config per bundle will need a resolve.alias block to point at the bundle-local source during tests, rather than the built dist/, so that coverage reports the right paths. The compliance CLI will need to learn about workspace:* — today it resolves @frenchexdev/requirements through standard node resolution, which does not understand the pnpm protocol. And the git history, post-migration, will be less legible than before, because a single commit that moved forty files now shows up as forty path changes in the log, obscuring the fact that the semantics did not change. Each surprise is tractable; none is in the critical path; all of them will appear in the first extraction and none in the subsequent ones. The upfront pain is real, is bounded, and is a one-time cost.
On the other side of that cost, the modularity becomes something the package has, not something the package aspires to. A new Requirement can be authored into platform; its cross-cutting status is declared by its location, not by annotation. A new bundle — say, @frenchexdev/requirements-blog for a future separation of blog-specific Features from ssg-wide build-pipeline Features — is a one-day extraction, because the pattern is established. A consumer taking only the frontend slice is a one-line pnpm install. Every one of these outcomes is a compile-time, deterministic, boringly-reliable consequence of the single architectural move this chapter describes. That is what modularity buys, and it is what the flat directory could never deliver.
Related reading
- Chapter 13 — Quality Gates and Compliance — the gate this chapter extends across bundles. Section 8 reuses its phase-1-through-phase-4 model, composed once per bundle and aggregated.
- Chapter 07 — Feature / Requirement Many-to-Many — the many-to-many relation whose cross-bundle variant Section 9 lints.
- Chapter 11 — Dog-fooding the DSL's Own Tests — the
@FeatureTest/@Verifiesdiscipline Section 11 insists every bundle inherits. - Chapter 00 — Named but Not Modelled — the first structural move; this chapter is the second.
- packages/requirements/src/style.ts — the
RequirementStyleinterface Section 5'scomposeStyle()operates on. - packages/requirements/src/base.ts — the
Featurebase class that Step A'sbundletag extends. - pnpm-workspace.yaml — the workspace manifest whose
catalog:mechanism Section 10 builds on.
← Previous: Chapter 13b — Developer Experience: the TUI Wizard and Live Feedback · Next: Chapter 22d — Lifecycle Discipline →