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

Reliable Dispatch, Transactional with the Aggregate

The hardest problem in event-driven architecture is not what to publish, it is when. Publish before the aggregate's transaction commits and a system crash leaves consumers acting on an event that never happened; publish after and a system crash leaves the aggregate's state changed with no event consumer noticing. The Transactional Outbox pattern — Chris Richardson's name for an old discipline — solves it by writing the event into the same database transaction as the aggregate, then dispatching from the outbox table asynchronously. @frenchexdev/ddd-outbox reifies that boundary.


What Outbox Reifies

A naive implementation publishes the event right after the aggregate persists. The two writes are in two systems (a database and a message broker), and the second one can fail independently. If it does, the aggregate is in its new state, the event never fired, and the rest of the system disagrees with the source of truth. The dual failure — the event firing but the aggregate persistence rolling back — is the symmetric disaster. Either way, the system has lied about what happened.

Richardson's Outbox pattern (Microservices Patterns, chapter 3) inverts the dependency. The aggregate's transaction writes both the aggregate state and a row in an outbox table — same database, same transaction, atomic. A separate process or coroutine reads the outbox, dispatches the events to the actual message broker, and marks them processed. If the dispatcher crashes between dispatch and acknowledgement, it re-dispatches the same event on restart — at-least-once delivery — and the consumers are responsible for being idempotent. Exactly-once delivery is achievable with additional discipline (idempotency keys, deduplication on the consumer side), but the default is at-least-once because that is the boundary the database transaction can actually enforce.

The pattern's promise is the transactionality. The aggregate's invariant cannot disagree with the event store. Crash anywhere — between writes, before dispatch, mid-dispatch — and the system recovers to a consistent state on the next start. Without the outbox, the team is permanently one outage away from "we have no idea whether that notification was sent." With it, the consistency question collapses to "the dispatcher will catch up."


The Runtime: ddd-outbox

The runtime is, at this milestone, a deliberate stub. stub.ts exports STUB_KIND and describeStub() returning a StubMetadata literal that pins the canonical feature (OutboxCanonicalFeature) and the canonical requirement (REQ-OUTBOX-FIABILISED-DISPATCH). The intent is documented in a single comment: "M4/M5 stub. Full implementation lands when a consumer drives it."

export const STUB_KIND = 'stub' as const;

export interface StubMetadata {
  readonly kind: typeof STUB_KIND;
  readonly status: 'stub';
  readonly canonicalFeature: 'OutboxCanonicalFeature';
  readonly canonicalRequirements: readonly ['REQ-OUTBOX-FIABILISED-DISPATCH'];
}

export function describeStub(): StubMetadata {
  return {
    kind: STUB_KIND,
    status: 'stub',
    canonicalFeature: 'OutboxCanonicalFeature',
    canonicalRequirements: ['REQ-OUTBOX-FIABILISED-DISPATCH'],
  };
}

The decision to ship the package as a stub is itself a design choice. The runtime shape of an outbox is opinionated — it must be transactional with respect to a specific database, it must know how to mark rows as dispatched, it must handle retries and back-off — and the corpus has not yet had a real consumer driving the requirements. Shipping a premature runtime would lock the shape on guesses; shipping a stub keeps the canonical feature visible (so it is on the compliance map) without committing to an implementation. The analyzer and codegen, by contrast, have a real contract to enforce — the shape of an outbox class is decidable even before the behaviour is implemented.

In our invented architecture, a future @Outbox decorated class would look like this (the runtime stub does not yet expose a decorator, but the shape the codegen targets is clear):

// Sketch of the eventual @Outbox shape — runtime decorator not yet shipped.
// What the codegen registry will key by, what the analyzer suffix rule guards.

export class SubscriptionOutbox {
  readonly storeName     = 'subscription_outbox' as const;
  readonly dispatchPolicy = 'at-least-once' as const;

  async persist(event: unknown): Promise<void> { /* writes to outbox table in aggregate tx */ }
  async dispatch(): Promise<void>             { /* polls + ships to broker */ }
}

Two fields, two methods. storeName names the persistence target (an outbox table, typically). dispatchPolicy records whether the consumer should expect at-least-once or exactly-once. persist(event) is called from within the aggregate's transaction. dispatch() is called periodically by a background worker. The runtime stub's job, when it lands, will be to provide a base class that wires these primitives against the corpus's standard stores; until then, the codegen scaffolds the shape and the team supplies the body.


The Analyzer: ddd-outbox-analyzer

The analyzer is spec-first (spec.ts), unlike the runtime. The spec declares the pattern OUTBOX under parent requirement OutboxFiabilisedDispatchRequirement — note the parentRequirementSubpath: 'application' rather than the strategic patterns' 'strategic', reflecting that an outbox lives at the application-service edge, not at the bounded-context boundary itself.

Priority is High. Only two acceptance criteria, only two rules — modest because the runtime contract is intentionally still forming. DDD-OUTBOX-001 is the now-familiar info-severity naming-convention rule (suffix Outbox). DDD-OUTBOX-002 is the single-per-file invariant at error severity. The analyzer will gain more rules as the runtime matures — likely a forbid-multiple-dispatch invariant, a require-transaction-context invariant — but the spec is the agreed contract, and adding rules is a spec edit not a rewrite.

export const outboxAnalyzerSpec = defineAnalyzerSpec({
  patternId: 'OUTBOX',
  featureId: 'OUTBOX-ANALYZER',
  priority: 'High',
  parentRequirementSubpath: 'application',
  // ...
  rules: [
    { kind: 'require-name-suffix', code: 'DDD-OUTBOX-001', severity: 'info', targetAC: 'name-suffix',
      suffix: 'Outbox',
      message: 'Outbox type should end with "Outbox" suffix by convention' },
    { kind: 'single-per-file',     code: 'DDD-OUTBOX-002', severity: 'error', targetAC: 'single-outbox-per-file',
      decoratorName: 'Outbox',
      message: 'File declares {count} @Outbox classes — split one Outbox per file' },
  ],
});

The Codegen: ddd-outbox-codegen

The codegen is also spec-first (spec.ts) and ships two templates against a richer descriptor — OutboxDescriptor carries className, storeName, fromModule, and an optional dispatchPolicy: 'at-least-once' | 'exactly-once'. Five acceptance criteria including the distinctive registry-records-dispatch-policy, which pins the contract that the registry must surface the dispatch policy per outbox.

templates/outbox-stub.ts emits a concrete class <OutboxClassName>Stub with the two methods throwing NotImplemented. The shape is the same as the ACL translator stub and the port adapter stub — a missing implementation must fail loudly, not silently default. The storeName and dispatchPolicy are emitted as as const literal fields so even the stub carries the closed-type information the consumers will need.

// AUTO-GENERATED by ddd-outbox-codegen:outbox-stub — do not edit.
/**
 * Outbox stub backed by subscription_outbox (at-least-once).
 * Persists events in the aggregate transaction and dispatches once, reliably.
 */
export class SubscriptionOutboxStub {
  readonly storeName      = 'subscription_outbox' as const;
  readonly dispatchPolicy = 'at-least-once' as const;
  async persist(event: unknown): Promise<void> {
    throw new Error('NotImplemented: SubscriptionOutboxStub.persist (subscription_outbox)');
  }
  async dispatch(): Promise<void> {
    throw new Error('NotImplemented: SubscriptionOutboxStub.dispatch (subscription_outbox)');
  }
}

templates/outbox-registry.ts emits the workspace-wide registry, sorted by storeName so the diff stays stable across builds. Each entry records the dispatchPolicy — a CI gate can assert that a given store never silently transitions from exactly-once to at-least-once between releases, because the registry says so.

// AUTO-GENERATED by ddd-outbox-codegen:outbox-registry — do not edit.
import { InvoiceOutbox } from '../../outboxes/invoice-outbox.js';
import { SubscriptionOutbox } from '../../outboxes/subscription-outbox.js';

export const OUTBOX_REGISTRY = [
  { storeName: 'invoice_outbox',      outbox: InvoiceOutbox,      dispatchPolicy: 'exactly-once' },
  { storeName: 'subscription_outbox', outbox: SubscriptionOutbox, dispatchPolicy: 'at-least-once' },
] as const;

export const OUTBOX_STORE_NAMES = ['invoice_outbox', 'subscription_outbox'] as const;

The spec-first shape of the analyzer + codegen with a runtime that is still a stub is the cleanest version of the corpus's incremental discipline. The contract surface (analyzer rules + codegen templates) ships first, the runtime is built when there is a real consumer to design against, and the codegen-emitted stub becomes the concrete substrate the team fills in. By the time the runtime lands, the rest of the corpus is already wired against the expected shape.


The outbox is the reliability seam between the aggregate and the bus.

Back to the series index.

⬇ Download