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

Past Facts, Immutable, Emitted by Roots

@DomainEvent from @frenchexdev/ddd-domain-event is the integration backbone of the corpus. Every part of strategic DDD that has to cross an aggregate boundary — between contexts, between processes, between time slices via the event store — does so as a domain event. The pattern is small, the rules around it are non-negotiable, and the discipline it enforces is what makes event-sourced and event-driven architectures survive.


What @DomainEvent Reifies

Vernon (Implementing Domain-Driven Design, chapter 8) treats domain events as the most important pattern in the second half of the book. A domain event names what happened in the pastSubscriptionStarted, PaymentCaptured, InvoiceGenerated. The past tense is structural, not stylistic. Events are immutable past facts; you cannot un-happen them; you can only emit a compensating event later. The grammar of the language reflects the grammar of the architecture.

Three rules follow from that one definition. First, events are immutable — every field is readonly, and any code that mutates an event after construction is broken. Second, only aggregate roots emit events — an entity inside an aggregate calls back up to its root, which produces the event; an application service or a domain service that emits events directly has skipped the consistency boundary, and the event will not be transactional with respect to any aggregate's state. Third, events are named in the past tense — present-tense names like PlaceOrder are commands, not events, and conflating the two is the surest way to a codebase whose event store is full of intentions and whose command queue is full of facts.

@DomainEvent reifies the contract. The decorator's only argument is version: number — the schema version, used by the codegen to emit a serialisable payload and by the event store to handle long-term replay across schema migrations. The decorator itself is minimal; the analyzer carries the discipline.


The Runtime: ddd-domain-event

decorator.ts declares the surface in 30 working lines. DomainEventOptions requires only version: number. The decorator wraps it in a DomainEventMetadata literal with decoratorKind: 'DomainEvent' and schemaVersion: 1 — note the dual versioning, one for the meta-schema (always 1 in this milestone) and one for the event's own schema version that consumers control. The decorator stamps __domainEvent and __domainEventMetadata onto the class, and exits.

In our invented Subscription context, a domain event for a started subscription looks like this:

import { DomainEvent } from '@frenchexdev/ddd-domain-event';
import type { SubscriptionId, BillingPlanId, CustomerId, Money } from '../domain/types.js';

@DomainEvent({ version: 1 })
export class SubscriptionStarted {
  constructor(
    public readonly subscriptionId: SubscriptionId,
    public readonly customerId:     CustomerId,
    public readonly planId:         BillingPlanId,
    public readonly startedAt:      Date,
    public readonly initialCharge:  Money,
  ) {}
}

Five things deserve attention. The name is past tense — SubscriptionStarted, not StartSubscription. Every field is readonly — the analyzer's DDD0130 diagnostic will refuse a mutable field. The constructor parameters are domain types (SubscriptionId, Money) — not primitives, not framework types. The version: 1 makes the event's schema explicit, so the codegen can emit a typed payload that carries the version forward into storage. The decorator metadata stays minimal; the contract about who may emit this event lives in the analyzer, not the runtime.

isDomainEventClass is the type guard used by the codegen to discover event classes by walking exports.


The Analyzer: ddd-domain-event-analyzer

The analyzer for this pattern predates the spec-first migration the strategic-boundary patterns went through. There is no spec.ts calling defineAnalyzerSpec; instead, codes.ts exports three hand-written diagnostic factories. The three codes — DDD0130, DDD0131, DDD0132 — sit in a different namespace (DDD0NNN) than the strategic patterns' DDD-X-NNN shape, reflecting their earlier vintage.

DDD0130_DOMAIN_EVENT_MUTABLE_FIELD is the immutability rule. Any field on a @DomainEvent class that is not readonly triggers an error — the past cannot be edited:

export const DDD0130_DOMAIN_EVENT_MUTABLE_FIELD = 'DDD0130';

export function domainEventMutableField(className: string, fieldName: string, file: string, line: number): Diagnostic {
  return {
    code: DDD0130_DOMAIN_EVENT_MUTABLE_FIELD,
    severity: 'error',
    message: `@DomainEvent "${className}" exposes mutable field "${fieldName}". Events are past facts — all fields must be \`readonly\`.`,
    file, line,
  };
}

DDD0131_DOMAIN_EVENT_PRESENT_TENSE_NAME is the naming rule, at warning severity — the analyzer cannot always tell whether Subscribe or Renew is meant as a verb or as an English shorthand for a past fact, so it surfaces a hint rather than blocking the build:

domainEventPresentTenseName('PlaceOrder', 'orders/events.ts', 42);
// DDD0131 [warning] @DomainEvent "PlaceOrder" appears to use a present-tense name.
// Use past tense (e.g. "OrderPlaced", not "PlaceOrder") — events name what happened.

DDD0132_DOMAIN_EVENT_EMITTED_BY_NON_ROOT is the most architecturally significant of the three. It is a cross-pattern invariant — the analyzer must look at not just the event class but at the emitting class, and refuse the emission unless that class carries @AggregateRoot:

domainEventEmittedByNonRoot('SubscriptionStarted', 'SubscriptionService', 'application/sub.ts', 87);
// DDD0132 [error] @DomainEvent "SubscriptionStarted" is emitted by "SubscriptionService"
// which is not an @AggregateRoot. Only roots may emit domain events.

That rule is the practical enforcement of Vernon's chapter-8 discipline. An application service that emits a SubscriptionStarted directly has bypassed the aggregate; the event will fire whether or not the aggregate's state was consistent; the event store will record facts that the aggregate's invariants never approved. The analyzer refusing it forces the dispatch chain through the aggregate, which is where the consistency boundary lives.

The hand-written shape of this triplet's analyzer is on the migration list — the corpus's direction is the spec-first discipline of defineAnalyzerSpec, and a future milestone will rewrite domain-event-analyzer to match. The three codes are part of the contract that has to survive that migration, which is why they exist as named constants rather than only as inline strings.


The Codegen: ddd-domain-event-codegen

The codegen, like the analyzer, predates the spec-first templates pattern. generator.ts exports a single function generateDomainEventType(input) that consumes a GenerateDomainEventTypeInput (eventName, fields: { name, type }[], metadata: DomainEventMetadata) and returns the source of a <EventName>Payload type alias.

The emitted payload type is what makes the event survive the round trip through an event store. The class itself has methods, constructors, prototype identity — none of which serialise. The payload is the data shape, fully readonly, carrying the event's name and version as discriminator literals so a deserialiser can route correctly across schema versions.

// AUTO-GENERATED by ddd-domain-event-codegen@0.0.1 — do not edit.
/* eslint-disable */
// Serialisable type for DomainEvent "SubscriptionStarted" (schema v1).

export type SubscriptionStartedPayload = Readonly<{
  readonly __eventName: 'SubscriptionStarted';
  readonly __eventVersion: 1;
  readonly subscriptionId: SubscriptionId;
  readonly customerId: CustomerId;
  readonly planId: BillingPlanId;
  readonly startedAt: Date;
  readonly initialCharge: Money;
}>;

The two discriminator literals are the heart of the design. __eventName: 'SubscriptionStarted' is a closed literal — the type system can pattern-match on it to choose the right handler. __eventVersion: 1 is also a closed literal — a deserialiser that sees an event with __eventVersion: 2 can refuse to coerce it to the v1 type, forcing the schema migration to happen explicitly. The event store consumes payloads, not class instances; the typed payload is the safe edge.

A consumer of the payload type — the event store, a replay loop, a projection — can write:

function applyEvent(p: SubscriptionStartedPayload): SubscriptionState {
  return {
    id:        p.subscriptionId,
    customer:  p.customerId,
    plan:      p.planId,
    status:    'active',
    startedAt: p.startedAt,
  };
}

…and the compiler refuses any payload that does not have the right __eventName and __eventVersion. The runtime decorator handles emission; the codegen handles transit; the analyzer handles the invariants. The three together make domain events transportable without losing their shape.


@DomainEvent is the integration backbone, so it connects almost everything.

  • It is emitted by an @AggregateRoot — the cross-pattern rule DDD0132 enforces that.
  • It is dispatched by the @EventBus in-process and persisted through the @Outbox for cross-process delivery.
  • Its payload type is the serialised form that the @EventStore appends and replays.
  • A @Projection consumes a stream of payloads to build a read-optimised view.
  • The cross-context shape may travel through an @ACL when an upstream event must be translated into a downstream model.

Back to the series index.

⬇ Download