Strategic Classification in the Type System
The first decorator of the series is also the first decision of any DDD effort: which parts of this system are strategic core, which are merely supporting, and which are commodity I should buy or outsource rather than build? @Subdomain from @frenchexdev/ddd-subdomain reifies that decision so it stops being a whiteboard ritual and starts being something the compiler, the analyzer and the codegen can read.
What @Subdomain Reifies
In Vernon's Implementing Domain-Driven Design (chapter 2), the strategic landscape of any non-trivial business splits into three subdomain kinds: core, where the competitive advantage lives and where investment is justified; supporting, which the business needs but does not differentiate on; and generic, where the right move is usually to buy a SaaS, integrate a library, or outsource. The classification is not cosmetic — it determines staffing, deadlines, where the seniors sit, where source-generated scaffolding is acceptable versus where every line must be hand-shaped. Get it wrong and you spend two years rebuilding what you should have bought from Stripe; get it right and the core team is the one writing the rules that matter.
Without a decorator, the classification lives in three places at once: a Lucidchart diagram nobody updates, a wiki page that contradicts the diagram, and a chapter of onboarding documentation that contradicts both. The system does not know its own strategic shape, so neither does the compiler. Refactors that move billing logic into a "shared utilities" module pass review because nothing in the type system says billing is core, utilities is generic, and core code must never depend on generic code in the wrong direction.
@Subdomain collapses those three places into one. The decorator stamps a typed metadata block onto a class — typically the bounded-context anchor class — and from then on every other tool in the corpus reads that block. The context map becomes generated. The compliance report knows which feature flags target core code and which target supporting code. The analyzer can refuse a dependency from a core context to a generic context. The whiteboard ritual becomes a contract.
The Runtime: ddd-subdomain
The exported surface from packages/ddd-subdomain/src/index.ts is intentionally narrow: a Subdomain decorator factory, an isSubdomainClass type guard, and the type aliases that describe what gets stamped onto a class. The metadata shape declared in decorator.ts is the single source of truth — a SubdomainOptions carrying name, a SubdomainType enum literal ('core' | 'supporting' | 'generic'), and the boundedContexts it spans, all wrapped in a SubdomainMetadata that adds a decoratorKind: 'Subdomain' discriminator and a version: 1 schema marker.
The decorator itself does almost nothing at runtime. It captures the options, builds the metadata object, and assigns it to two well-known properties on the class — __subdomain: true as the cheap-check flag and __subdomainMetadata as the typed payload. There is no reflection, no global registry, no decorator-metadata polyfill. The class becomes its own typed token: pass it around like any other class reference and the metadata travels with it. That choice matters for tree-shaking and for build-time analysis — the codegen does not need a runtime hook to discover decorated classes; it walks the AST and reads the same metadata literal that the decorator returns.
In an invented Subscription aggregate, the call site looks like this:
import { Subdomain } from '@frenchexdev/ddd-subdomain';
import { BoundedContext } from '@frenchexdev/ddd-bounded-context';
@Subdomain({
name: 'BillingCore',
type: 'core',
boundedContexts: ['Subscription', 'Invoice', 'Payment'],
})
@BoundedContext({ name: 'Subscription' })
export class SubscriptionContext {}import { Subdomain } from '@frenchexdev/ddd-subdomain';
import { BoundedContext } from '@frenchexdev/ddd-bounded-context';
@Subdomain({
name: 'BillingCore',
type: 'core',
boundedContexts: ['Subscription', 'Invoice', 'Payment'],
})
@BoundedContext({ name: 'Subscription' })
export class SubscriptionContext {}Three things are worth noticing in that snippet. First, the strings inside boundedContexts are not magic — they are the same names used by @BoundedContext on the anchor classes of those contexts, and the analyzer cross-validates them. Second, type is a string literal union, not a free string, so a typo ('cores') fails at compile time before the analyzer even runs. Third, a single subdomain spans several bounded contexts on purpose: Vernon's classification is strategic, applied to coherent business areas, while bounded contexts are linguistic, often finer-grained. Billing-as-core may legitimately fan out into Subscription, Invoice and Payment without each of them needing its own subdomain classification.
The isSubdomainClass guard is the dual of the decorator. It accepts an unknown and narrows it to { __subdomain: true; __subdomainMetadata: SubdomainMetadata }, which lets a consumer — typically a codegen pass or a CLI plugin — walk a list of exported classes and pick out the subdomain anchors without depending on a registry that needs to be kept in sync. The anchor is the registry entry.
The Analyzer: ddd-subdomain-analyzer
The analyzer is not hand-written. spec.ts calls defineAnalyzerSpec from @frenchexdev/ddd-spec-features/codegen to declare the pattern's invariants once, and ts-codegen-pipeline materialises everything else: the diagnostic codes module, the rules module, the analyzer entry point, a test skeleton, and a property-test harness — five generators driven by the same spec, committed at fixpoint into src/generated/subdomain/.
The spec names the pattern (patternId: 'SUBDOMAIN'), pins it to a parent requirement (SubdomainStrategicClassificationRequirement under the strategic subpath), declares the feature (SUBDOMAIN-ANALYZER) with a priority: 'High', and lists four acceptance criteria: declares-name, declares-type, declares-bounded-contexts, single-subdomain-per-file. Those acceptance-criteria identifiers are not decorative — every rule must target one of them, and the compliance report groups diagnostics by AC at the end of a build.
The four rules ship the contract surface. Three of them are require-decorator-arg invariants enforcing that @Subdomain carries name, type, and boundedContexts; the fourth is a single-per-file invariant refusing more than one @Subdomain declaration per source file. Each rule carries a stable code in the DDD-SUB-NNN namespace, an error severity, the targeted AC, and the human-readable message the analyzer prints:
import { defineAnalyzerSpec } from '@frenchexdev/ddd-spec-features/codegen';
export const subdomainAnalyzerSpec = defineAnalyzerSpec({
patternId: 'SUBDOMAIN',
featureId: 'SUBDOMAIN-ANALYZER',
// ...
rules: [
{ kind: 'require-decorator-arg', code: 'DDD-SUB-001', severity: 'error',
targetAC: 'declares-name', decoratorName: 'Subdomain', argName: 'name',
message: '@Subdomain must declare a name' },
{ kind: 'require-decorator-arg', code: 'DDD-SUB-002', severity: 'error',
targetAC: 'declares-type', decoratorName: 'Subdomain', argName: 'type',
message: '@Subdomain must declare a type (core|supporting|generic)' },
{ kind: 'require-decorator-arg', code: 'DDD-SUB-003', severity: 'error',
targetAC: 'declares-bounded-contexts', decoratorName: 'Subdomain', argName: 'boundedContexts',
message: '@Subdomain must declare boundedContexts' },
{ kind: 'single-per-file', code: 'DDD-SUB-004', severity: 'error',
targetAC: 'single-subdomain-per-file', decoratorName: 'Subdomain',
message: 'File declares {count} @Subdomain classes — split one per file' },
],
});import { defineAnalyzerSpec } from '@frenchexdev/ddd-spec-features/codegen';
export const subdomainAnalyzerSpec = defineAnalyzerSpec({
patternId: 'SUBDOMAIN',
featureId: 'SUBDOMAIN-ANALYZER',
// ...
rules: [
{ kind: 'require-decorator-arg', code: 'DDD-SUB-001', severity: 'error',
targetAC: 'declares-name', decoratorName: 'Subdomain', argName: 'name',
message: '@Subdomain must declare a name' },
{ kind: 'require-decorator-arg', code: 'DDD-SUB-002', severity: 'error',
targetAC: 'declares-type', decoratorName: 'Subdomain', argName: 'type',
message: '@Subdomain must declare a type (core|supporting|generic)' },
{ kind: 'require-decorator-arg', code: 'DDD-SUB-003', severity: 'error',
targetAC: 'declares-bounded-contexts', decoratorName: 'Subdomain', argName: 'boundedContexts',
message: '@Subdomain must declare boundedContexts' },
{ kind: 'single-per-file', code: 'DDD-SUB-004', severity: 'error',
targetAC: 'single-subdomain-per-file', decoratorName: 'Subdomain',
message: 'File declares {count} @Subdomain classes — split one per file' },
],
});A failing example trips DDD-SUB-002: omit type, and the analyzer reports the rule together with the AC it targets, so the compliance run can point the human at the right acceptance criterion rather than at a raw diagnostic message:
@Subdomain({
name: 'BillingCore',
// type omitted
boundedContexts: ['Subscription', 'Invoice', 'Payment'],
})
@BoundedContext({ name: 'Subscription' })
export class SubscriptionContext {}
// DDD-SUB-002 [error] @Subdomain must declare a type (core|supporting|generic)
// AC: SUBDOMAIN-ANALYZER/declares-type@Subdomain({
name: 'BillingCore',
// type omitted
boundedContexts: ['Subscription', 'Invoice', 'Payment'],
})
@BoundedContext({ name: 'Subscription' })
export class SubscriptionContext {}
// DDD-SUB-002 [error] @Subdomain must declare a type (core|supporting|generic)
// AC: SUBDOMAIN-ANALYZER/declares-typeThe interesting part is what the spec does not contain. There is no hand-written AST walker, no per-rule TypeScript code, no diagnostic-emission boilerplate. The pipeline's CodesGenerator, RulesGenerator, AnalyzerGenerator, TestSkeletonGenerator and PropertyTestGenerator take the spec and emit everything under src/generated/subdomain/ — codes.generated.ts, rules.generated.ts, analyzer.generated.ts, analyzer.skeleton.generated.ts, analyzer.property.test.ts. Adding a fifth rule is a four-line spec change; the rest is regenerated on the next fixpoint pass.
The Codegen: ddd-subdomain-codegen
The codegen follows the same spec-first discipline as the analyzer. spec.ts calls defineCodegenSpec (also from @frenchexdev/ddd-spec-features/codegen) to declare the pattern, the feature (SUBDOMAIN-CODEGEN), four acceptance criteria — registry-lists-all-subdomains, investment-summary-derives-tier-from-type, banner-present-in-each-output, idempotent-for-same-input — and two templates that produce the emitted artefacts. Everything else, including the generator wiring inside ts-codegen-pipeline, is materialised from the spec.
The hand-written part is just the two template functions in templates/. Both consume a SubdomainDescriptor (className, name, type, boundedContexts, fromModule) and return a string wrapped by withDddBanner from @frenchexdev/ddd-core-codegen — the banner is what enforces the banner-present-in-each-output invariant; the template functions are pure on their input, which is what enforces idempotent-for-same-input.
The first template is the typed subdomain registry, emitted as <consumer>.registry.generated.ts. The generator imports each decorated class by fromModule, sorts subdomains by name, sorts boundedContexts inside each entry, and emits a single as const tuple-array. The as const matters: downstream code that pattern-matches on the literal types gets full TypeScript narrowing rather than string[]. Application code that needs to ask which subdomain owns this context imports SUBDOMAIN_REGISTRY rather than walking decorators at runtime — same metadata, ordered deterministically, fully typed.
// AUTO-GENERATED by ddd-subdomain-codegen:subdomain-registry — do not edit.
import { SubscriptionContext } from '../../contexts/subscription.js';
import { NotificationContext } from '../../contexts/notification.js';
export const SUBDOMAIN_REGISTRY = [
{ name: 'BillingCore', type: 'core', ctor: SubscriptionContext, boundedContexts: ['Invoice', 'Payment', 'Subscription'] as const },
{ name: 'Notification', type: 'generic', ctor: NotificationContext, boundedContexts: ['EmailDispatch', 'WebhookOutbox'] as const },
] as const;// AUTO-GENERATED by ddd-subdomain-codegen:subdomain-registry — do not edit.
import { SubscriptionContext } from '../../contexts/subscription.js';
import { NotificationContext } from '../../contexts/notification.js';
export const SUBDOMAIN_REGISTRY = [
{ name: 'BillingCore', type: 'core', ctor: SubscriptionContext, boundedContexts: ['Invoice', 'Payment', 'Subscription'] as const },
{ name: 'Notification', type: 'generic', ctor: NotificationContext, boundedContexts: ['EmailDispatch', 'WebhookOutbox'] as const },
] as const;The second template is the per-subdomain investment summary, emitted as <consumer>.investment.generated.ts, one file per subdomain. The interesting line is the tier derivation: core → 'invest-heavily', supporting → 'maintain', generic → 'buy-or-outsource'. The investment tier is a literal string constant — typed and tree-shakeable — that dashboards and CI gates can read to decide where to spend senior talent, where to accept generated scaffolding, where coverage thresholds bite hardest. A spec acceptance criterion (investment-summary-derives-tier-from-type) pins the mapping so the template cannot quietly drift.
// AUTO-GENERATED by ddd-subdomain-codegen:investment-summary — do not edit.
/**
* Investment summary for the BillingCore subdomain (core).
* INVESTMENT_TIER is dictated by the subdomain type per Vernon's strategic ch. 2.
*/
export const SUBDOMAIN_NAME = 'BillingCore' as const;
export const SUBDOMAIN_TYPE = 'core' as const;
export const INVESTMENT_TIER = 'invest-heavily' as const;
export const COVERED_BOUNDED_CONTEXTS = ['Invoice', 'Payment', 'Subscription'] as const;// AUTO-GENERATED by ddd-subdomain-codegen:investment-summary — do not edit.
/**
* Investment summary for the BillingCore subdomain (core).
* INVESTMENT_TIER is dictated by the subdomain type per Vernon's strategic ch. 2.
*/
export const SUBDOMAIN_NAME = 'BillingCore' as const;
export const SUBDOMAIN_TYPE = 'core' as const;
export const INVESTMENT_TIER = 'invest-heavily' as const;
export const COVERED_BOUNDED_CONTEXTS = ['Invoice', 'Payment', 'Subscription'] as const;Both templates are strictly additive ([[feedback_codegen_strictly_additive.md]]) — they write only under outDir, never mutate sources — and the pipeline runs them inside the virtFS that ts-codegen-pipeline commits once at fixpoint ([[feedback_sourcegen_virtfs_fixpoint.md]]). Replace the decorator on SubscriptionContext, run the pipeline, and the registry, the investment summary, and the analyzer's generated artefacts all update on the same fixpoint pass. One spec, two templates, one source of truth.
Cross-Links
@Subdomain is the strategic anchor, so it touches several siblings in the series.
- It directly references
@BoundedContextby name throughboundedContexts: readonly string[]. The analyzer cross-validates the two. - It informs
@ContextRelationship: a core-to-generic relationship is almost always a customer/supplier or conformist arrangement, never a partnership. - It indirectly shapes
@SharedKernel— shared kernels across subdomains of differenttypedeserve a higher bar than within a single subdomain. - The emitted registry feeds the Compliance CLI plugin, which produces the per-subdomain coverage report.
Back to the series index.