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

Chapter 20 — Requirements Meet DDD, Revisited

DDD had a slot called Policy. Typed-specs did not. The word Requirement now fills that slot exactly.

A year ago, requirements-meet-ddd.md drew a careful table. Feature mapped to Bounded Context. Acceptance Criterion mapped to Use Case. @Implements mapped to Port. Test mapped to Adapter. The mapping was not wrong — everything it said is still true. But it was reading a DSL that only modelled Features, and trying to stretch that one noun across the entire DDD vocabulary. The stretch was visible if you looked closely: the same row sometimes pointed at two different DDD concepts; the Feature cell carried both "Bounded Context" and, implicitly, "Domain Policy"; the table had no line for the thing DDD calls a rule that must hold.

This chapter revisits that table with the stratum the earlier post could not declare. The Requirement type from @frenchexdev/requirements is not a new label over an old model. It fills a slot the old model never had. Once you see where it goes, two things become visible that were invisible before: that a Feature usually satisfies several Policies at once, and that Policies themselves decompose into a tree. Both were already true in the CV repo before @frenchexdev/requirements existed. The DSL did not create them. It just made them declarable.

The DDD slots, listed carefully

Before the mapping, a careful listing of the DDD concepts this chapter is going to map onto. Eric Evans' 2003 book, the Blue Book, defined a small vocabulary. Twenty years of subsequent literature — Vernon's Red Book, Khononov's Learning Domain-Driven Design, Millett and Tune on implementing DDD, and the great many blog posts that came after — have refined it, but the slots themselves have been stable.

  • Bounded Context — a boundary within which a particular model applies. Words mean one thing inside; they can mean something different in a neighbouring context.
  • Aggregate — a cluster of Entities and Value Objects with one consistency boundary and one root Entity. External code refers only to the root.
  • Entity — an object with a stable identity across time and state change. Two Entities are equal iff their identity is equal, not their fields.
  • Value Object — an object defined entirely by its fields. Two Value Objects are equal iff their fields are pair-wise equal. Immutable by convention.
  • Domain Service — a stateless operation that belongs to the domain but does not fit inside any single Aggregate. Transfers, policy evaluations, cross-Aggregate calculations.
  • Use Case — a named interaction between an actor and the system that produces a domain-visible effect. Clean Architecture's Interactor is the equivalent shape. Most users think in Use Cases.
  • Policy — a rule that the domain must uphold. An invariant, a business constraint, a safety property, a regulatory obligation. Policies do not themselves do anything; they shape what Use Cases are allowed to do.

The earlier post's mapping handled the first six slots adequately. It handled the seventh — Policy — by absorbing it into the Feature row. The Feature was asked to be both the deliverable and the rule that deliverable existed to enforce. Two different things, one type. Hence the occasional strain in the prose.

Diagram
Diagram 1 — The DDD slots, with the Policy slot highlighted. Typed-specs modelled Use Case (as Feature). @frenchexdev/requirements adds the Policy slot (as Requirement). The other slots remain ordinary domain code.

The highlighted box is the one the earlier post could not draw. Everything else on the diagram was already in place. A package boundary was still a Bounded Context. Aggregates, Entities, and Value Objects were still ordinary domain code — orthogonal to the Requirements DSL, and deliberately so. A Domain Service was still a stateless operation. The Use Case node was what Feature modelled, and it is what Feature still models. The only addition is the Policy node above it, and the arrow from Use Case to Policy that the @Satisfies decorator now types.

Mapping the slots onto the DSL

With the slots listed, the mapping becomes mechanical. Four rows change. Three rows stay.

DDD Slot What it is Where it lives in code How the DSL touches it
Bounded Context Boundary of a model npm package / namespace Each package defines its own Requirement/Feature class graph. No cross-package subclassing.
Aggregate Consistency cluster Ordinary domain code Untouched. The DSL does not model Aggregates.
Entity Identity-bearing object Ordinary domain code Untouched.
Value Object Field-defined object Ordinary domain code Untouched.
Domain Service Stateless operation Ordinary domain code Untouched.
Use Case Named actor interaction requirements/features/*.ts abstract class XFeature extends Feature { ... }
Policy Rule / invariant / constraint requirements/requirements/*.ts abstract class XRequirement extends Requirement<S> { ... }

Three rows — Aggregate, Entity, Value Object, Domain Service — are marked "untouched" on purpose. The Requirements DSL is not an ORM and not a modelling framework for state. It deliberately stays out of the aggregate layer. A Money value object, an Order aggregate, a CustomerId entity identifier — these are ordinary TypeScript classes or Zod schemas or branded primitives, written however the rest of the codebase is written. The DSL would gain nothing by trying to prescribe their shape, and would lose the clean separation that lets a CV site and a cross-border contract-law engine use the same Requirements package without either pretending to be the other.

The row that the earlier post could not write is the Policy row. That row is what this chapter is about.

The Policy slot, concretely

A Policy, in DDD usage, is any statement of the form "the domain requires that X". Some examples from the blog-series scaffolding underneath this very post:

  • "Every decorated test must be traceable from a Feature AC back to a Requirement." — this is a Policy about the traceability graph's connectivity.
  • "Every Requirement must be expressible in five Styles without loss of meaning." — this is a Policy about the DSL's rhetorical surface.
  • "The Requirements package must validate itself with itself — zero describe, zero it." — this is a Policy about the dog-food invariant.

Read those sentences a second time. Each is a rule, not a deliverable. You do not build "every decorated test must be traceable"; you uphold it. The building is done by Features: a scanner, a CLI, a TUI explorer. The Features exist so that the Policies hold. That direction of the arrow — Feature upholds Requirement — is what @Satisfies encodes.

Concretely, each of the three sentences above corresponds to a Requirement class in the CV repo's Requirements package:

// requirements/requirements/req-discoverable-traceability.ts
@InStyle(RequirementStyle.Lean)
export abstract class ReqDiscoverableTraceabilityRequirement
  extends Requirement<RequirementStyle.Lean> {
  readonly id = 'REQ-DISCOVERABLE-TRACEABILITY';
  readonly title = 'Traceability must be discoverable without prior vocabulary';
  readonly priority = Priority.High;
  readonly state = RequirementState.Approved;
}

// requirements/requirements/req-dog-food.ts
@InStyle(RequirementStyle.Industrial)
export abstract class ReqDogFoodRequirement
  extends Requirement<RequirementStyle.Industrial> {
  readonly id = 'REQ-DOG-FOOD';
  readonly title = 'The DSL must validate itself with itself';
  readonly priority = Priority.Critical;
  readonly state = RequirementState.Approved;
}

// requirements/requirements/req-parallel-deliverable.ts
@InStyle(RequirementStyle.Kanban)
export abstract class ReqParallelDeliverableRequirement
  extends Requirement<RequirementStyle.Kanban> {
  readonly id = 'REQ-PARALLEL-DELIVERABLE';
  readonly title = 'Delivery proceeds per-feature, never on a release train';
  readonly priority = Priority.Medium;
  readonly state = RequirementState.Approved;
}

Three files, three Requirements, three Policies. None of them is a thing to build. Each is a rule that some other, concrete thing must uphold. In typed-specs, none of these had a home. You would have written them as comments in a README, or as tags on Features, or as Features with suspiciously verb-free titles (DogFoodFeature — satisfying? but what does it do?). The file extension would have changed over the years; the absence of a type would not.

@frenchexdev/requirements makes them typed classes that import cleanly, appear in the registry, show up in the compliance report, and can be passed by reference into @Satisfies(...) decorators. The slot is filled.

@Satisfies as the Use-Case-to-Policy arrow

The @Satisfies decorator, in the vocabulary of this chapter, is the arrow from a Use Case (Feature) to the Policies (Requirements) it upholds. It is many-to-many by construction: one @Satisfies(Req1, Req2, Req3) lets a single Feature declare several Policies at once, and multiple Features can list the same Requirement in their @Satisfies list.

The running example this series carries — FEATURE-TRACE-EXPLORER-TUI — is the clearest illustration. It is a Use Case, not a rule. Users run npx requirements trace explore, keys get pressed, a TUI renders, the graph gets walked. It is a thing the package does. But the reasons it exists are three separate Policies:

@Satisfies(
  ReqDiscoverableTraceabilityRequirement,
  ReqDogFoodRequirement,
  ReqParallelDeliverableRequirement,
)
export abstract class FeatureTraceExplorerTuiFeature extends Feature {
  readonly id = 'FEATURE-TRACE-EXPLORER-TUI';
  readonly title =
    '`requirements explore` — interactive TTY browser over the traceability graph';
  readonly priority = Priority.Low;
  readonly enabled = false;

  abstract traceExplorerBuildsGraph(): ACResult;
  abstract traceExplorerHandlesArrowKeyNavigation(): ACResult;
  abstract traceExplorerDrillsDownFromAnyNode(): ACResult;
  abstract traceExplorerOpensHelpOverlayOnQuestionMark(): ACResult;
  abstract traceExplorerJumpsBackUpWithBackspace(): ACResult;

  abstract traceExplorerRefusesToStartOnNonTty(): ACResult;
  abstract traceExplorerExitsCleanlyOnCtrlC(): ACResult;

  abstract traceExplorerUsesFileSystemPortForDiscovery(): ACResult;
  abstract traceExplorerUsesPromptPortForInteraction(): ACResult;

  abstract endToEndNavigatesReqToFeatToAcToTest(): ACResult;
}

Three arguments to @Satisfies. Each is a class reference (a Requirement subclass), not a string. The compiler knows what each one is. The scanner walks the decorator syntactically and builds a Feature → Requirement edge per argument. If you typo the class name, it is a compile error, not a runtime mystery.

Why three arguments, though? A Use Case could, in principle, satisfy one Policy. Many do. The question the earlier post's single-row mapping could not answer is: why do some Use Cases satisfy several? The honest answer is that Policies in any non-trivial system are not orthogonal dimensions the way CSS grid axes are. A single feature is typically the crossroads of several:

  • It upholds a functional policy ("the TUI must render the graph").
  • It upholds a meta policy ("the TUI must be testable under the same rules as every other Feature" — i.e., REQ-DOG-FOOD).
  • It upholds a process policy ("the TUI is a parallel deliverable — its Feature flag can be flipped independently of the rest").

Three different kinds of policy — functional, meta, process — all upheld by the same deliverable. @Satisfies takes a list because policies do not queue up one per Feature; they converge. If the DSL only accepted a single argument, the author would have to either pick one and hide the others in comments, or duplicate the Feature class — a cure worse than the disease.

In DDD literature this is unremarkable. A Use Case always sits inside several Policies at once. A PlaceOrder Use Case upholds an inventory policy, a credit-check policy, a pricing policy, an audit-logging policy. The earlier post's prose gestured at this ("the invariants that must hold inside") but the @Implements decorator, which only carried an AC name, had no slot for it. @Satisfies does.

@Refines as policy decomposition

Policies are not flat. A Policy like "the application must be accessible" is not a single rule; it is a family of rules — keyboard reachability, contrast ratios, screen-reader announcements, focus order, form labelling. Each of those is itself a Policy. In DDD terms, the Policies form a decomposition tree: parent Policies break into child Policies, children can break further, and the leaves are concrete enough to be directly upheld by a specific Feature.

The typed-specs DSL flattened the tree. The Feature list was a single-level array: navigation, accessibility, contrast, keyboard, with no declared relationship between accessibility, contrast, and keyboard. A reader who had internalised DDD would mentally group them, but the model did not.

@frenchexdev/requirements keeps the tree. The @Refines(Parent) decorator declares that a child Requirement specialises a parent Requirement:

// Root policy — coarse, general, aspirational.
@InStyle(RequirementStyle.Default)
export abstract class ReqDiscoverableTraceabilityRequirement
  extends Requirement<RequirementStyle.Default> {
  readonly id = 'REQ-DISCOVERABLE-TRACEABILITY';
  readonly title = 'Traceability must be discoverable without prior vocabulary';
  readonly priority = Priority.High;
  readonly state = RequirementState.Approved;
}

// First child — graph-browsing specialisation.
@Refines(ReqDiscoverableTraceabilityRequirement)
@InStyle(RequirementStyle.Lean)
export abstract class ReqBrowsableGraphRequirement
  extends Requirement<RequirementStyle.Lean> {
  readonly id = 'REQ-BROWSABLE-GRAPH';
  readonly title = 'The user must be able to walk any edge of the graph interactively';
  readonly priority = Priority.High;
  readonly state = RequirementState.Approved;
}

// Second child — help-discovery specialisation.
@Refines(ReqDiscoverableTraceabilityRequirement)
@InStyle(RequirementStyle.Lean)
export abstract class ReqInlineHelpRequirement
  extends Requirement<RequirementStyle.Lean> {
  readonly id = 'REQ-INLINE-HELP';
  readonly title = 'Help for each node must be reachable from the node itself';
  readonly priority = Priority.Medium;
  readonly state = RequirementState.Approved;
}

// Third child — vocabulary-free navigation specialisation.
@Refines(ReqDiscoverableTraceabilityRequirement)
@InStyle(RequirementStyle.Lean)
export abstract class ReqVocabularyFreeNavigationRequirement
  extends Requirement<RequirementStyle.Lean> {
  readonly id = 'REQ-VOCABULARY-FREE-NAVIGATION';
  readonly title =
    'A user with no knowledge of REQ/FEAT/AC terminology must still reach a test';
  readonly priority = Priority.Medium;
  readonly state = RequirementState.Approved;
}

The parent policy is aspirational: traceability must be discoverable. On its own, it is too coarse to gate against — what does discoverable mean, concretely? The three children break it down:

  • Browsable graph — there is a concrete walk-the-edges affordance.
  • Inline help — help for any node is a keystroke away.
  • Vocabulary-free navigation — a user who does not know the word "Requirement" still gets from a REQ node to a test.

Each of the three children is now specific enough to be upheld by a Feature. The parent policy is inherited satisfaction: any Feature that satisfies all its children transitively satisfies the parent. The compliance scanner uses the @Refines edges precisely for this — an Approved parent Requirement with no direct satisfier but whose children are all satisfied is not reported as an orphan. The tree is the thing that lets a coarse policy survive in the registry without blocking the gate.

Diagram
Diagram 2 — @Refines policy-decomposition tree. The root Requirement breaks into three specialised children; FEATURE-TRACE-EXPLORER-TUI satisfies each leaf directly. The parent is satisfied transitively through its children.

The diagram above shows why @Refines is not a cosmetic relation. If FEATURE-TRACE-EXPLORER-TUI were to @Satisfies(ReqDiscoverableTraceabilityRequirement) directly, the declaration would be technically valid but rhetorically vague — upholding discoverability is not a falsifiable claim. Upholding browsable-graph, inline-help, and vocabulary-free-navigation separately is falsifiable, one AC at a time. The refinement tree is how coarse Policies become testable without either being rewritten into something they are not or being pruned from the registry.

What the earlier post could not say

With the Policy slot, the many-to-many arrow, and the refinement tree in place, the earlier requirements-meet-ddd.md post becomes readable as a first draft of a mapping that had to wait for the stratum to be real. Three specific things that post could not say, and that this chapter can:

1. A policy is a typed class, not a comment. The earlier post had to gesture at policies through tooltips and prose ("the invariants that must hold inside"). The word invariant was doing heavy lifting. In the new DSL, the invariant is a file: req-discoverable-traceability.ts. It imports cleanly, it appears in the registry, and the compliance report enumerates it. The type system carries it.

2. A Use Case usually satisfies several Policies at once. The earlier post's @Implements decorator carried one generic argument — a Feature class — and one string argument — an AC name. There was no place for multiplicity in the relationship between Feature and Policy, because Policy was not a separate thing to be in a relationship with. The new @Satisfies(ReqA, ReqB, ReqC) makes the multiplicity primary: of course a Feature upholds several Policies, because Policies are the cross-cutting concerns (accessibility, performance, dog-food, i18n) that every Feature touches.

3. Policies decompose. The earlier post's twenty-item Feature Inventory mixed deliverables with disguised policy families — accessibility, contrast, keyboard all sitting at the same level, with no relation declared between them. The new @Refines arrow keeps the family tree: ReqContrastRequirement @Refines ReqAccessibilityRequirement is a single decorator. The tree then gets walked by the scanner, so that satisfying the leaves transitively satisfies the root.

None of these three things contradicts the earlier post. Each of them extends its mapping by filling in the row the earlier post could not name. The original table is still a good table. It simply had one missing row.

Pure DDD stays pure — a warning against over-reaching

A reader encountering the Requirements DSL for the first time will sometimes ask: can it model an Aggregate as well? A Value Object? Shouldn't the DSL be the single source of truth for the whole domain model?

The answer is no, deliberately. The DSL stays in the policy-to-use-case layer and does not reach down into the aggregate layer for a specific reason: the aggregate layer is already well-modelled by ordinary TypeScript. Branded primitives, discriminated unions, Zod schemas, readonly fields, factory functions — the language has a rich vocabulary for aggregates and value objects, and any DSL that tried to model them would be reinventing what the language already does well.

What TypeScript does not give you, out of the box, is:

  • A type that says this is a Policy.
  • A typed arrow that says this Use Case upholds these Policies.
  • A typed arrow that says this child Policy refines this parent Policy.
  • A registry that enumerates Policies, Use Cases, and their connections without executing any of the code.

Those four gaps are exactly what @frenchexdev/requirements fills, and it fills them by staying narrow. The package defines Requirement, Feature, @Satisfies, @Refines, @Verifies, and the ts-morph scanner that walks them. Aggregates, Entities, Value Objects, Domain Services — untouched. Tests written in vitest, Playwright, pa11y — untouched. Pure DDD stays pure. The DSL is the thin stratum on top.

This is why the Bounded Context slot in the mapping table is "npm package / namespace". A consuming project defines its own @Satisfies-linked Feature graph over its own Requirements; the DSL package itself only provides the types. Two Bounded Contexts using the same DSL do not share a Requirement graph — the Requirement classes live in the consumer's source tree, not in the DSL package. The boundary is where every DDD practitioner expects it to be: at the package seam.

A rule of thumb for the split

In practice, the question is this a Policy or a Use Case? comes up the first time a team starts modelling in the new DSL. The rule of thumb is short:

  • If the answer to "what does it do?" is a verb, it is a Feature. It renders the graph. It exports a PDF. It scans the source. It logs the attempt. The deliverable is the thing.
  • If the answer to "what does it require?" is a sentence, it is a Requirement. The traceability must be discoverable. The logs must not contain PII. The gate must be runnable locally. The rule is the thing.
  • If both are true of the same idea, split it. Accessibility is not a single concept — it is an ReqAccessibilityRequirement policy and a family of concrete Features (keyboard handler, focus ring, skip link). Writing it as a Feature alone was the typed-specs shortcut; the new DSL lets you write it as it actually is.

A short worked classification from the CV repo, to make the rule concrete:

  • Accessibility. Answer to "what does it do?" — nothing; it is a property the rest of the site must have. Answer to "what does it require?" — keyboard reachability, contrast ratios, screen-reader coverage. Verdict: Policy (with several child Policies via @Refines, and several Features that @Satisfies them).
  • Topbar search. Answer to "what does it do?" — receives a query, scores results, opens the matched page. Answer to "what does it require?" — there is no rule called search must exist; search is a deliverable, not a rule. Verdict: Use Case (Feature). It may well satisfy other Policies — REQ-VOCABULARY-FREE-NAVIGATION is a plausible parent.
  • Mermaid rendering. Answer to "what does it do?" — converts .md fences to .svg during build. Verdict: Use Case. Plus it satisfies a REQ-DIAGRAMS-RENDER-OFFLINE Policy that would otherwise live in a README.
  • No cloud CI. Answer to "what does it do?" — nothing; it is a constraint on the pipeline. Verdict: Policy. It is upheld by Features (the pre-push wrapper, the Vercel static serve) that exist so that the constraint can hold.
  • The compliance report itself. Answer to "what does it do?" — scans the repo, renders a table, exits 0 or 1. Verdict: Use Case. It satisfies REQ-DOG-FOOD and REQ-DISCOVERABLE-TRACEABILITY.

Each of these decisions was tacit in the typed-specs era — made by the author's judgement when naming a Feature file, visible only in prose and hindsight. The new DSL makes them explicit: the filename tells you which side of the split the idea landed on (requirements/requirements/ vs requirements/features/), the class declaration tells you the state and the priority, the decorators tell you the graph edges. The judgement is now structural.

What the scanner sees, post-revision

The compliance scanner, described in detail in Chapter 13 — Quality Gates and Compliance, is the part of the package that most directly benefits from the DDD re-alignment. In the typed-specs era the scanner walked one graph: Feature → AC → Test. With the Policy slot in place, the scanner now walks three:

  • Requirement → Requirement (via @Refines) — the policy-decomposition tree.
  • Requirement ← Feature (via @Satisfies) — the satisfaction edges.
  • Feature → AC ← Test (via @FeatureTest + @Verifies) — the verification edges.

The three graphs compose into a single registry, and the three gate conditions map cleanly onto DDD concerns:

  • Every Feature must declare a Policy it satisfies. — DDD: every Use Case exists to uphold some Policy. A Use Case that upholds nothing is architectural dead weight.
  • Every Approved Requirement must have at least one satisfier (direct or via a refined child). — DDD: every Policy must be carried somewhere in the Use Case layer. An Approved Policy with no Use Case is a documented rule no code enforces.
  • Every Critical-priority AC must be Verified by a Test. — DDD: the invariants inside a Bounded Context must be checked. Critical ACs are the hard invariants; the test is the check.

The gate is not three arbitrary rules. Each one is the enforcement of a well-known DDD hygiene invariant, made mechanical by the scanner. The earlier post's prose section titled Acceptance Criteria as Use Cases was arguing for the same hygiene by hand; the new gate runs it in 400 milliseconds before every push.

A note on Bounded Context and package boundaries

One subtlety worth drawing out. The earlier post's mapping said Feature = Bounded Context. Strictly, that was a compression. A single Feature is not a Bounded Context — a single feature is a Use Case inside one. The Bounded Context is the package (or namespace) that groups a coherent set of Requirements, Features, and their verifying Tests.

This matters because the DSL is deliberately reusable across Bounded Contexts. The @frenchexdev/requirements package defines Requirement, Feature, and the decorators; consumer projects — the CV repo, a separate law-compilation project, the Diem CMF rewrite — each declare their own Requirement and Feature classes in their own source trees. The classes in one consumer cannot @Refines or @Satisfies a class from another consumer, because they would have to import across package boundaries, and the scanner (and TypeScript's module system) would refuse.

The consequence is practical: a Bounded Context in the DDD sense lines up exactly with a consumer of @frenchexdev/requirements. Two consumers sharing the DSL do not share a Requirement graph. The ubiquitous language inside each consumer is that consumer's own business vocabulary — Feature, Requirement, AC, Test are the meta-vocabulary, not the domain terms. The domain terms are what the id and title fields of each Requirement class carry.

This is the behaviour every DDD practitioner expects: language boundaries at package boundaries. The earlier post's one-line row ("Feature = Bounded Context") was readable but compressed; the fuller statement is "a Bounded Context is a consumer package; Features are the Use Cases inside it; Requirements are the Policies inside it; the DSL provides the types but not the words".

A specific comparison to the earlier post

To close, a side-by-side of three claims from the earlier post, with what each claim now reads as through the Policy lens.

Earlier post: "A Feature is a Bounded Context." Revised reading: A Feature is a Use Case inside a Bounded Context. The Bounded Context itself is the consumer package. The compression was pedagogically useful but hid the layer above the Feature — the Policies the Feature upholds.

Earlier post: "Each abstract method is a domain invariant — a rule that must always be true within this context." Revised reading: Each abstract AC method is a verifiable condition — a check that a specific test must pass. The invariant the AC upholds is the Requirement (or Requirements) the parent Feature satisfies. The earlier post collapsed the check and the invariant into the same concept because there was no separate slot for the invariant. The two now sit one level apart: AC = check; Requirement = invariant.

Earlier post: "The ACResult type enforces this: every criterion produces a boolean outcome with an optional failure reason. This is the Use Case output boundary." Revised reading: ACResult is still the Use Case output boundary — nothing changes there. What the earlier post could not add is that the reason the Use Case matters lives one stratum up, in the @Satisfies list on the Feature. A green ACResult is not enough to know the Policy is upheld; the @Satisfies list is what tells the scanner that the Use Case is bound to the right Policies in the first place.

Each of the three revised readings is a refinement of the earlier post's mapping, not a retraction. The earlier post mapped correctly within the vocabulary it had. The new DSL adds one noun (Policy, as Requirement) and two arrows (@Satisfies, @Refines), and the entire earlier mapping slots neatly underneath.

⬇ Download