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

Cohesion Below the Context Line

A bounded context that contains a hundred files is a context that has stopped being navigable. @Module from @frenchexdev/ddd-module reifies Evans's namespace pattern (Domain-Driven Design, chapter 5) — the unit of cohesion below the context line — so the directory layout stops being the only signal of what belongs with what.


What @Module Reifies

Evans devotes a single chapter to modules and the chapter is rarely the one people quote. The argument is structural: inside a bounded context, code clusters around tasks or invariants, and the cluster boundaries are themselves meaningful. Billing rules, subscription lifecycle, invoice generation are three modules inside a single Subscription context; the cluster lines tell a reader where to look, where to add code, where a refactor is likely to have local versus context-wide consequences. The pattern is not about file systems. The folder layout reflects the modules; it does not define them.

In a sprawling TypeScript codebase the modules disappear into directory conventions. Sometimes a subscription/billing/ directory exists; sometimes the same code lives under subscription/lifecycle/; sometimes the cluster spans two files in two directories because someone refactored halfway and never finished. The pattern dissolves into folklore. @Module reifies the cluster so the type system, the analyzer, and the codegen have a referent — the cluster is named, its exports are listable, its consumers can be audited.

The decorator is deliberately minimal. A name is required (so the module is namable across the workspace). An exports?: readonly string[] is optional (so a module that wants to declare its public surface can — and a barrel will be generated from that list). The richness of Nest-style dependency-injection modules is not part of this pattern; that responsibility lives in @Config-Module and other patterns further along in the corpus. Here, the pattern is the cluster name plus the surface, nothing more.


The Runtime: ddd-module

decorator.ts keeps the surface to two fields. ModuleOptions carries name: string and exports?: readonly string[]. The decorator wraps both in a ModuleMetadata literal with decoratorKind: 'Module' and version: 1, stamps __module and __moduleMetadata onto the anchor class, and exits. No initialisation lifecycle, no dependency-graph machinery, no decorator-based DI — those concerns belong elsewhere.

In an invented Subscription context, a module that clusters the lifecycle code looks like this:

import { Module } from '@frenchexdev/ddd-module';

@Module({
  name: 'SubscriptionLifecycle',
  exports: ['Subscription', 'SubscriptionStatus', 'startSubscription', 'cancelSubscription'],
})
export class SubscriptionLifecycleModule {}

// The actual code — aggregates, services, value objects — lives in sibling
// files. The decorator's `exports` list names what this module surfaces
// outside its directory; the barrel is regenerated whenever the list
// changes.

The anchor SubscriptionLifecycleModule is again a metadata-only class. The exported names are strings on purpose — they are validated by the codegen against what the module's source files actually export, not by the runtime. A string is enough at the decorator level because the analyzer and codegen do the cross-checking with full TypeScript AST awareness.

isModuleClass is the type guard used by the codegen to walk exports and discover every module without a hand-maintained registry — same discipline as the other strategic decorators.


The Analyzer: ddd-module-analyzer

The spec at spec.ts declares pattern MODULE under parent requirement ModuleCohesionRequirement. Priority is Medium — the lowest priority of the strategic set so far. A missing module decoration does not corrupt the boundary, leak types across a context, or break a context map; it merely leaves cohesion implicit. That is worth catching, but not at the same urgency as a missing @ContextRelationship.

Three rules DDD-MODULE-001 through DDD-MODULE-003: name required, Module suffix recommended at info severity, one module per file. The naming convention matters more than it looks — SubscriptionLifecycleModule is the navigable name, SubscriptionLifecycle would collide with a future aggregate, value object, or service of the same prefix. Convention is a coupling cost that pays itself back in readability.

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

export const moduleAnalyzerSpec = defineAnalyzerSpec({
  patternId: 'MODULE',
  featureId: 'MODULE-ANALYZER',
  priority: 'Medium',
  // ...
  rules: [
    { kind: 'require-decorator-arg', code: 'DDD-MODULE-001', severity: 'error', targetAC: 'declares-name',
      decoratorName: 'Module', argName: 'name',
      message: '@Module must declare a name' },
    { kind: 'require-name-suffix',   code: 'DDD-MODULE-002', severity: 'info',  targetAC: 'name-suffix',
      suffix: 'Module',
      message: '@Module type should end with "Module" suffix by convention' },
    { kind: 'single-per-file',       code: 'DDD-MODULE-003', severity: 'error', targetAC: 'single-module-per-file',
      decoratorName: 'Module',
      message: 'File declares {count} @Module classes — split one per file' },
  ],
});

A failing example trips DDD-MODULE-003: two modules in the same file collapse the cohesion the pattern is supposed to make explicit.

// modules/billing.ts — INVALID
@Module({ name: 'SubscriptionLifecycle', exports: [/* ... */] })
export class SubscriptionLifecycleModule {}

@Module({ name: 'InvoiceGeneration', exports: [/* ... */] })
export class InvoiceGenerationModule {}

// DDD-MODULE-003 [error] File declares 2 @Module classes — split one per file
//   AC: MODULE-ANALYZER/single-module-per-file

The Codegen: ddd-module-codegen

The codegen spec at spec.ts declares two templates. The registry collects every module across the workspace; the barrel emits the per-module re-export file that consumers actually import.

templates/module-registry.ts sorts modules alphabetically by name, imports each anchor class by fromModule, and emits a MODULE_REGISTRY array of { name, ctor, exports } tuples plus a MODULE_NAMES literal tuple. The exports list is sorted within each entry for determinism. A workspace operation that needs to iterate every module — say, the docs site building a sidebar — reads from the registry rather than scanning the file system.

// AUTO-GENERATED by ddd-module-codegen:module-registry — do not edit.
import { InvoiceGenerationModule } from '../../modules/invoice-generation.js';
import { SubscriptionLifecycleModule } from '../../modules/subscription-lifecycle.js';

export const MODULE_REGISTRY = [
  { name: 'InvoiceGeneration',     ctor: InvoiceGenerationModule,     exports: ['Invoice', 'generateInvoice'] as const },
  { name: 'SubscriptionLifecycle', ctor: SubscriptionLifecycleModule, exports: ['Subscription', 'SubscriptionStatus', 'cancelSubscription', 'startSubscription'] as const },
] as const;

export const MODULE_NAMES = ['InvoiceGeneration', 'SubscriptionLifecycle'] as const;

templates/module-barrel.ts emits a per-module barrel file. For each module, it sorts the exports alphabetically and emits an export { Name } from '<module-source>'; line per item. The barrel becomes the canonical import path: consumers of the module never reach into individual files, they import from the barrel, and a renamed file inside the module is invisible from the outside.

// AUTO-GENERATED by ddd-module-codegen:module-barrel — do not edit.
/**
 * Barrel for module SubscriptionLifecycle.
 */
export { Subscription }            from '../../modules/subscription-lifecycle.js';
export { SubscriptionStatus }      from '../../modules/subscription-lifecycle.js';
export { cancelSubscription }      from '../../modules/subscription-lifecycle.js';
export { startSubscription }       from '../../modules/subscription-lifecycle.js';

export const MODULE_NAME = 'SubscriptionLifecycle' as const;

The deterministic sort plus the banner-present and idempotent-for-same-input invariants make the barrel diff-stable across builds. A consumer that wants to add an export edits the module decorator's exports array, runs the pipeline, and the barrel updates — the file is never edited by hand. The acceptance criterion barrel-re-exports-declared-exports pins the contract: every name in the decorator's exports must appear in the barrel, and any name in the barrel that is not in the decorator is a generation bug.


@Module is the structural pattern that sits below the strategic line and above the tactical one.

  • It lives inside a @BoundedContext. A module crossing context boundaries is a context modelling error, not a module modelling error.
  • Its exports list will, in a future pattern, be cross-referenced with @AggregateRoot and @DomainService declarations to validate that what a module claims to export is actually a tactical pattern instance.
  • The barrel is the import path most other patterns will use to address the module's contents — the Module CLI plugin consumes MODULE_REGISTRY to draw a context-internal map.
  • It is distinct from @ConfigModule, which addresses cross-cutting configuration; @Module is the cohesive unit, @ConfigModule is the cross-cutting one.

Back to the series index.

⬇ Download