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

Explicit Id Generation

How does an aggregate get its id? The answers tend to be implicit — the database column has a sequence, the API caller provides one, the framework generates UUIDs in the entity constructor. Each is fine in isolation; together, in a codebase that mixes them silently, they produce the worst class of distributed-systems bug: an aggregate whose id is sometimes assigned and sometimes echoed back from storage, and where the "sometimes" depends on a code path nobody documented. @IdentityStrategy from @frenchexdev/ddd-identity-strategy reifies the choice as a typed metadata field so the decision is explicit and audit-able.


What @IdentityStrategy Reifies

Four strategies cover the practical universe. user-provided: the id comes from outside the system — an API caller, a CSV import, a manual scrape. Validation lives in the strategy; the aggregate trusts the value. app-generated: the application produces the id at construction time, typically via crypto.randomUUID(). The aggregate is its own source of truth from the first moment of existence. persistence-generated: the database (a sequence, an IDENTITY column, a Mongo _id) assigns the id at insert time, and the aggregate is told what its id is after persistence. Trickier — the aggregate exists transiently before it has an id, and the application must handle that intermediate state. from-context: the id originates in another bounded context and travels in as part of an integration event or ACL translation. The strategy carries sourceContext so the analyzer can validate that the source context exists.

The discipline is that every aggregate names its strategy. Mixing the strategies inside one bounded context is allowed; mixing them inside one aggregate type is not. The compiler and the registry know which strategy each aggregate uses, and the factory signature adapts to the choice.


The Runtime: ddd-identity-strategy

decorator.ts declares the closed union IdentityStrategyKind = 'user-provided' | 'app-generated' | 'persistence-generated' | 'from-context'. IdentityStrategyOptions requires kind, accepts optional sourceContext: string (used when kind is from-context). The decorator stamps __identityStrategy and __identityStrategyMetadata onto the strategy class.

In our invented Subscription context:

import { IdentityStrategy } from '@frenchexdev/ddd-identity-strategy';

@IdentityStrategy({ kind: 'app-generated' })
export class SubscriptionIdStrategy {}

@IdentityStrategy({ kind: 'from-context', sourceContext: 'Payment' })
export class PaymentReferenceIdStrategy {}

The strategy class is again a metadata anchor — empty body, single purpose. The codegen reads the metadata, emits the appropriate factory, and the rest of the application calls the factory rather than rolling its own id generation.


The Analyzer: ddd-identity-strategy-analyzer

Spec-first (spec.ts). Pattern IDSTRATEGY under requirement IdentityStrategyExplicitRequirement, priority Critical. The Critical priority is deliberate — an implicit identity strategy is the kind of architectural drift that survives every test pass.

Two rules — DDD-IDSTRAT-001 requires kind (the choice must be explicit), DDD-IDSTRAT-002 enforces single-per-file (each strategy is its own anchor). The closed-union type on kind does the rest of the work — a typo like 'random' is a compile error before the analyzer ever runs.

A future cross-pattern rule could verify that every @AggregateRoot references a registered @IdentityStrategy — the spec is where it will land.


The Codegen: ddd-identity-strategy-codegen

Spec-first (spec.ts). Two templates: an id factory and a strategy registry.

templates/id-factory.ts emits a factory whose signature varies by kind. This is the part that distinguishes the codegen from the other pattern codegens: the emitted function is shape-shifted by the strategy's choice.

For user-provided: make<Strategy>(value: string) — caller passes the id, factory wraps it.

// AUTO-GENERATED by ddd-identity-strategy-codegen:id-factory — do not edit.
import { ApiKeyIdStrategy } from '../../strategies/api-key.strategy.js';

export function makeApiKeyIdStrategy(value: string): ApiKeyIdStrategy {
  return new ApiKeyIdStrategy(value);
}

export const STRATEGY_KIND = 'user-provided' as const;

For app-generated: make<Strategy>() — no caller input, factory uses crypto.randomUUID().

export function makeSubscriptionIdStrategy(): SubscriptionIdStrategy {
  return new SubscriptionIdStrategy(crypto.randomUUID());
}

export const STRATEGY_KIND = 'app-generated' as const;

For persistence-generated: make<Strategy>(persistedId: string) — the database assigned the id, the factory echoes it back as a strongly-typed strategy instance.

For from-context: emits an extra SOURCE_CONTEXT constant and a factory make<Strategy>(remoteId: string):

export const SOURCE_CONTEXT = 'Payment' as const;

export function makePaymentReferenceIdStrategy(remoteId: string): PaymentReferenceIdStrategy {
  return new PaymentReferenceIdStrategy(remoteId);
}

export const STRATEGY_KIND = 'from-context' as const;

The shape-shifting signature is the codegen earning its keep. A hand-written factory would either pick one shape or carry a union of arguments; the codegen reads the strategy's kind and emits exactly the shape that fits, plus the STRATEGY_KIND literal so consumers can pattern-match on the choice if they need to handle multiple strategies generically.

The registry template emits the workspace-wide list of strategies — the framework reads it to know which strategies exist and what kind each is, useful when wiring an aggregate constructor to its id source.


@IdentityStrategy is the meta-decision behind every aggregate id.

  • Composes with @Entity — the entity carries the Id<T> typed identifier; the strategy decides where the underlying string comes from.
  • Required by @AggregateRoot constructors — the strategy's factory is what the aggregate calls (or is called with).
  • The from-context kind references @BoundedContext — a future analyzer pass will validate that the named context exists.
  • Surfaces in the audit trail through @AuditTrail — knowing which strategy assigned an id matters for forensic questions.

Back to the series index.

⬇ Download