Modulation as Code
A YAML configuration file is a typed configuration class without the typing. Renames in the codebase do not propagate; the schema lives in two places (the loader and the file); refactors break silently. @ConfigModule from @frenchexdev/ddd-config-module reifies typed configuration with composition — modules extend other modules through TypeScript inheritance, override specific fields, and the compiler validates the whole chain.
What @ConfigModule Reifies
The pattern is a deliberate descendant of Symfony 1.4's YAML descriptors (generator.yml, databases.yml, cache.yml) reimplemented as TypeScript classes. Same composition shape: a child module names its parent through extends, the parent's fields flow down, the child overrides the ones it cares about. Typed at every level: every field has a TS type, every override is Partial<Config>, every rename triggers compile-time errors at every reference. Discoverable: the decorator stamps metadata, the codegen emits a workspace-wide registry, every module is findable from a single index.
The corpus author calls the principle modulation as code — configuration is not data being read by code, it is code being structured. The composition mechanism (extends, overrides) is the team's modulation surface; the resolved configuration is the byte-for-byte deterministic output the application bootstrap consumes.
The Runtime: ddd-config-module
config-module.ts declares the surface. ConfigModuleOptions requires id: string, accepts optional extends: readonly string[] (the parent module ids) and overrides: Readonly<Record<string, unknown>> (the fields this module changes).
resolveConfig(modules) is the merge function. The caller supplies the modules in topological order (most-base first), and the function Object.assigns the overrides in sequence so the final configuration carries every layer's contribution.
import { ConfigModule, resolveConfig } from '@frenchexdev/ddd-config-module';
@ConfigModule({
id: 'database.base',
overrides: { host: 'localhost', port: 5432, pool: 10 },
})
export class DatabaseBaseConfig {}
@ConfigModule({
id: 'database.production',
extends: ['database.base'],
overrides: { host: 'db-prod.internal', pool: 50 },
})
export class DatabaseProductionConfig {}
// At application bootstrap:
const config = resolveConfig([
DatabaseBaseConfig.__configModuleMetadata,
DatabaseProductionConfig.__configModuleMetadata,
]);
// → { host: 'db-prod.internal', port: 5432, pool: 50 }import { ConfigModule, resolveConfig } from '@frenchexdev/ddd-config-module';
@ConfigModule({
id: 'database.base',
overrides: { host: 'localhost', port: 5432, pool: 10 },
})
export class DatabaseBaseConfig {}
@ConfigModule({
id: 'database.production',
extends: ['database.base'],
overrides: { host: 'db-prod.internal', pool: 50 },
})
export class DatabaseProductionConfig {}
// At application bootstrap:
const config = resolveConfig([
DatabaseBaseConfig.__configModuleMetadata,
DatabaseProductionConfig.__configModuleMetadata,
]);
// → { host: 'db-prod.internal', port: 5432, pool: 50 }The merge respects override semantics — every later module's overrides wins for the keys it names; earlier modules' values flow through for keys nobody re-touches. The result is a flat record the application uses to wire infrastructure adapters.
Why Decorator Over Plain Object
A plain const databaseProductionConfig = { ... } would work at runtime. The decorator buys three things. Discoverability: the codegen finds every @ConfigModule in the workspace and emits a registry. Cross-validation: the analyzer can refuse an extends reference to an id no module declares. Audit: the registry feeds the compliance report — which environments are configured, which modules are unused, which modules override which fields.
The Analyzer: ddd-config-module-analyzer
The analyzer is hand-written legacy, in the same cohort as Mediator and Pipeline Behavior. codes.ts exports three diagnostic factories in the DDD0NNN namespace.
DDD0370_CONFIG_MODULE_DUPLICATE_ID is the uniqueness invariant. Module ids are the registry key; two modules sharing one id is a configuration ambiguity the framework cannot resolve. Error severity:
export const DDD0370_CONFIG_MODULE_DUPLICATE_ID = 'DDD0370';
export function configModuleDuplicateId(id: string, file: string): Diagnostic {
return {
code: DDD0370_CONFIG_MODULE_DUPLICATE_ID,
severity: 'error',
message: `Two @ConfigModule classes declare id "${id}". Module ids must be unique within the workspace.`,
file,
};
}export const DDD0370_CONFIG_MODULE_DUPLICATE_ID = 'DDD0370';
export function configModuleDuplicateId(id: string, file: string): Diagnostic {
return {
code: DDD0370_CONFIG_MODULE_DUPLICATE_ID,
severity: 'error',
message: `Two @ConfigModule classes declare id "${id}". Module ids must be unique within the workspace.`,
file,
};
}DDD0371_CONFIG_MODULE_UNKNOWN_EXTENDS is the dangling-reference check. A module that extends an id no other module declares is broken at composition time — resolveConfig would receive an incomplete chain. Error severity.
DDD0372_CONFIG_MODULE_EXTENDS_CYCLE is the acyclicity invariant. The extends graph must be a DAG for topological resolution to terminate; the analyzer detects cycles statically rather than waiting for the codegen's topoSort to throw at build time. Error severity.
The three codes will survive the eventual spec-first migration; PROP-CONFIGMODULE-001 (to file) will lift the analyzer on top of defineAnalyzerSpec while preserving the contract.
The Codegen: ddd-config-module-codegen
The codegen is hand-written, like the analyzer. generator.ts exports generateConfigGraph(input) taking a GenerateConfigGraphInput (modules: { id, extends? }[]) and returning a banner-stamped source emitting a topologically sorted CONFIG_MODULE_ORDER constant:
// AUTO-GENERATED by ddd-config-module-codegen@0.0.1 — do not edit.
/* eslint-disable */
// Topologically sorted ConfigModule ids. Resolved at compile time.
export const CONFIG_MODULE_ORDER = ["database.base","database.production","cache.base","cache.production"] as const;// AUTO-GENERATED by ddd-config-module-codegen@0.0.1 — do not edit.
/* eslint-disable */
// Topologically sorted ConfigModule ids. Resolved at compile time.
export const CONFIG_MODULE_ORDER = ["database.base","database.production","cache.base","cache.production"] as const;The topological sort is the codegen's contribution: resolveConfig does not recursively traverse extends chains (that would require the workspace-wide registry at runtime), so the caller must hand it modules pre-ordered. The codegen produces that order at build time; if the graph has a cycle, the sort throws — the same condition DDD0372 catches statically at the analyzer layer.
The banner is emitted via withDddBanner from ddd-core-codegen — but no defineCodegenSpec ratifies banner/idempotence invariants yet. PROP-CONFIGMODULE-002 (to file) will lift the codegen to spec-first and may extend it to emit a full composeConfig() function per workspace that pre-orders modules topologically before calling resolveConfig.
Cross-Links
- Composes with every infrastructure port —
@Storage,@Search,@Cache,@Log,@Metrics,@RBAC— declaring environment-specific configuration the application bootstrap consumes. - Distinct from
@Modulewhich is the cohesion-unit inside a bounded context;@ConfigModuleis cross-cutting configuration. - Predates the broader corpus — the pattern is documented as a deliberate continuation of the author's earlier Symfony 1.4 work (the Diem CMF used the same modulation shape).
Back to the series index.