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

Where Words Mean What They Mean

If @Subdomain told us what kind of work this part of the system does, @BoundedContext from @frenchexdev/ddd-bounded-context tells us what words mean here, and only here. The decorator is the central strategic concept of Domain-Driven Design — Evans's chapter 14 — and the rest of the corpus reads it as the boundary inside which a Subscription, a Customer, an Invoice has exactly one meaning.


What @BoundedContext Reifies

In a non-trivial system, the same word means three different things in three different places. Subscription in the billing context is a price plan with proration rules; Subscription in the notification context is an opt-in to a channel; Subscription in the analytics context is a row in a fact table. Without a boundary, these meanings interfere — a "fix" to the billing notion silently breaks the notification notion, the type system gives no warning because both are called Subscription, and the team learns the difference the hard way during the next incident review.

A bounded context is the linguistic frontier inside which a word has exactly one definition. Cross the frontier and the same word may resolve to a different concept with a different invariant — that is fine, that is even desirable, but the crossing must be explicit. Evans's central move in Domain-Driven Design (chapter 14) is to make those crossings deliberate: each context has its own model, its own Ubiquitous Language, and its own integration contract with the contexts it touches.

@BoundedContext reifies that frontier as class metadata. The decorator stamps a context's name and its term-to-definition map onto an anchor class — typically named SubscriptionContext or BillingBC — and from that point on every other package in the corpus can ask which context owns this aggregate?, what does "renewal" mean inside the Subscription context?, which contexts is BillingCore mapped across? without re-reading any wiki page. The boundary is in the type system.


The Runtime: ddd-bounded-context

decorator.ts keeps the surface deliberately tight. BoundedContextOptions carries a required name (the context identifier used by every cross-context reference) and an optional ubiquitous: Readonly<Record<string, string>> — the term-to-definition map that captures the vocabulary the team agrees on. The decorator wraps both into a BoundedContextMetadata with the discriminator decoratorKind: 'BoundedContext' and the schema marker version: 1, then stamps __boundedContext and __boundedContextMetadata onto the class.

The ubiquitous map is the part that surprises people on first read. It is not documentation. It is a typed contract the codegen will use to emit a UBIQUITOUS_LANGUAGE module whose keys are exact string literals — meaning any code that references a term outside the map gets a compiler error, and any term that drifts between code and product copy gets caught at build time rather than during onboarding.

In an invented Subscription context, the anchor class looks like this:

import { BoundedContext } from '@frenchexdev/ddd-bounded-context';

@BoundedContext({
  name: 'Subscription',
  ubiquitous: {
    Subscription: 'A recurring agreement between Customer and BillingPlan, with a status, a period, and a renewal date.',
    Renewal:      'The event of extending a Subscription period by one billing cycle, possibly with prorated charges.',
    Suspension:   'A reversible pause on a Subscription that halts charges without ending the agreement.',
    Cancellation: 'The terminal end of a Subscription, after which no further renewals occur.',
  },
})
export class SubscriptionContext {}

Three things are worth noticing. First, SubscriptionContext has no fields, no constructor, no methods — it is purely a metadata anchor. Its job is to exist in a file so the analyzer and the codegen can find it. Second, the keys of ubiquitous are the canonical vocabulary; once emitted as the UBIQUITOUS_LANGUAGE const, any aggregate or value-object name that does not appear in the map is a candidate for a name review. Third, the decorator does not enforce vocabulary at runtime — it captures it. Enforcement happens through the analyzer and through downstream code that imports the generated map.

isBoundedContextClass is the type guard the codegen uses to walk a list of exported classes and pick out the anchors without any registry to maintain by hand. Same metadata, same lookup, no decorator-metadata polyfill, no global side effects.


The Analyzer: ddd-bounded-context-analyzer

The spec at spec.ts calls defineAnalyzerSpec to declare the pattern as BOUNDEDCONTEXT under the parent requirement BoundedContextCohesionRequirement. The feature (BOUNDED-CONTEXT-ANALYZER) is the first one in the series marked priority: 'Critical' — Subdomain was High, BoundedContext is Critical, because a context boundary that quietly drifts is the single most expensive class of strategic-DDD mistake.

Four acceptance criteria pin the invariants the analyzer enforces: declares-name, is-concrete-class, name-suffix, single-bc-per-file. The matching four rules carry stable codes DDD-BC-001 through DDD-BC-004. The interesting one is DDD-BC-003: a context whose anchor class does not end with the Context suffix gets an info-level diagnostic, not an error. The convention is real (SubscriptionContext, not Subscription — the latter would clash with the aggregate name inside the context) but the analyzer respects that a team may have legacy names it cannot rename in one go.

import { defineAnalyzerSpec } from '@frenchexdev/ddd-spec-features/codegen';

export const boundedContextAnalyzerSpec = defineAnalyzerSpec({
  patternId: 'BOUNDEDCONTEXT',
  featureId: 'BOUNDED-CONTEXT-ANALYZER',
  priority: 'Critical',
  // ...
  rules: [
    { kind: 'require-decorator-arg',  code: 'DDD-BC-001', severity: 'error', targetAC: 'declares-name',
      decoratorName: 'BoundedContext', argName: 'name',
      message: '@BoundedContext must declare a name' },
    { kind: 'require-concrete-class', code: 'DDD-BC-002', severity: 'error', targetAC: 'is-concrete-class',
      decoratorName: 'BoundedContext',
      message: '@BoundedContext must be a concrete class' },
    { kind: 'require-name-suffix',    code: 'DDD-BC-003', severity: 'info',  targetAC: 'name-suffix',
      suffix: 'Context',
      message: '@BoundedContext type should end with "Context" suffix by convention' },
    { kind: 'single-per-file',        code: 'DDD-BC-004', severity: 'error', targetAC: 'single-bc-per-file',
      decoratorName: 'BoundedContext',
      message: 'File declares {count} @BoundedContext classes — split one per file' },
  ],
});

A failing example trips DDD-BC-004: declare two contexts in the same module and the analyzer refuses the build — a context per file is the convention that keeps the strategic map navigable.

// contexts/subscription-and-invoice.ts — INVALID
@BoundedContext({ name: 'Subscription', ubiquitous: { /* ... */ } })
export class SubscriptionContext {}

@BoundedContext({ name: 'Invoice', ubiquitous: { /* ... */ } })
export class InvoiceContext {}

// DDD-BC-004 [error] File declares 2 @BoundedContext classes — split one per file
//   AC: BOUNDED-CONTEXT-ANALYZER/single-bc-per-file

As with every analyzer in the corpus, the four generators in the pipeline (CodesGenerator, RulesGenerator, AnalyzerGenerator, TestSkeletonGenerator, PropertyTestGenerator) emit codes.generated.ts, rules.generated.ts, analyzer.generated.ts, analyzer.skeleton.generated.ts, analyzer.property.test.ts under src/generated/boundedcontext/ from the spec alone. Adding a fifth invariant is a spec edit, not an analyzer rewrite.


The Codegen: ddd-bounded-context-codegen

The codegen declares two templates in spec.ts. The first is a workspace-wide registry (bc-registry) producing *.registry.generated.ts; the second is a per-context Ubiquitous-Language module (bc-ubiquitous-language) producing *.ubiquitous.generated.ts. Both inherit the corpus invariants — banner-present-in-each-output, idempotent-for-same-input — so re-running the pipeline on unchanged input is a byte-identical no-op.

templates/bc-registry.ts consumes a BoundedContextDescriptor[], sorts by name, imports each anchor class by fromModule, and emits both an array of { name, ctor } tuples (BOUNDED_CONTEXT_REGISTRY) and a separate literal-tuple of names (BOUNDED_CONTEXT_NAMES). The split matters: the registry gives you the constructor references for runtime lookup, while the names tuple gives the type system a closed list of literals — useful as the exhaustive type for any "which context?" parameter elsewhere in the corpus.

// AUTO-GENERATED by ddd-bounded-context-codegen:bc-registry — do not edit.
import { InvoiceContext } from '../../contexts/invoice.js';
import { NotificationContext } from '../../contexts/notification.js';
import { SubscriptionContext } from '../../contexts/subscription.js';

export const BOUNDED_CONTEXT_REGISTRY = [
  { name: 'Invoice',      ctor: InvoiceContext },
  { name: 'Notification', ctor: NotificationContext },
  { name: 'Subscription', ctor: SubscriptionContext },
] as const;

export const BOUNDED_CONTEXT_NAMES = ['Invoice', 'Notification', 'Subscription'] as const;

templates/ubiquitous-language.ts is the more distinctive of the two. For each context, it sorts the ubiquitous map by key, emits each entry on its own line, and wraps the literal in as const so TypeScript narrows the keys to a closed union of string literals. A type alias UbiquitousTerm = keyof typeof UBIQUITOUS_LANGUAGE then gives every consumer a guard against using a term that does not exist in the vocabulary.

// AUTO-GENERATED by ddd-bounded-context-codegen:ubiquitous-language — do not edit.
/**
 * Ubiquitous Language for the Subscription context.
 * Source of truth for the vocabulary used in code AND stakeholder conversations.
 */
export const UBIQUITOUS_LANGUAGE = {
  'Cancellation': 'The terminal end of a Subscription, after which no further renewals occur.',
  'Renewal':      'The event of extending a Subscription period by one billing cycle, possibly with prorated charges.',
  'Subscription': 'A recurring agreement between Customer and BillingPlan, with a status, a period, and a renewal date.',
  'Suspension':   'A reversible pause on a Subscription that halts charges without ending the agreement.',
} as const;

export type UbiquitousTerm = keyof typeof UBIQUITOUS_LANGUAGE;

That UbiquitousTerm type is the practical end-state of Evans's Ubiquitous Language: the vocabulary stops being a Confluence page somebody forgot to update and becomes a closed type the compiler can reason about. A function signature like function explain(term: UbiquitousTerm): string rejects any string that is not in the map. A renamed term in the source — say renaming Suspension to Pause in the decorator — propagates: the regenerated UBIQUITOUS_LANGUAGE changes, the UbiquitousTerm union changes, every consumer of the old name fails to compile, and the team is forced to update the language across the codebase in lockstep rather than discovering the drift six months later.


@BoundedContext sits at the centre of the strategic patterns and is referenced by almost everything.

  • It is referenced by name from @Subdomain.boundedContexts — the subdomain analyzer cross-validates that every name resolves to a real context.
  • Every @ContextRelationship names a from and a to context — those names must be in BOUNDED_CONTEXT_NAMES.
  • An @ACL lives at the boundary between two contexts and translates types across.
  • A @SharedKernel is the only sanctioned way for two contexts to share a model fragment without going through an ACL.
  • Inside the context, tactical patterns (@AggregateRoot, @ValueObject, @Repository) live; the analyzer of those packages may resolve their home context by name against BOUNDED_CONTEXT_REGISTRY.

Back to the series index.

⬇ Download