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

Long-Running Orchestration with Compensations

Some business processes do not complete in one transaction. Subscribe → charge card → provision account → send welcome email spans three or four external systems, each of which can fail independently, and rolling back the whole sequence atomically is not an option — the card may already be charged. A saga is the orchestration pattern for those processes: a long-running aggregate that reacts to events, advances through steps, persists its state between steps so it can resume after a crash, and undoes its earlier work via compensations when a later step fails. @Saga from @frenchexdev/ddd-saga reifies that pattern.


What @Saga Reifies

Sagas appear in two flavours in the literature: orchestration (one coordinator commands the participants) and choreography (participants react to each other's events with no central authority). This corpus's @Saga is the orchestration shape — a coordinating aggregate that owns the process's state machine. The choreography shape is achievable through plain @DomainEvent + @EventBus, but the orchestration shape benefits from being named: the saga is an aggregate, has an identity, persists its state, has invariants, and can be reasoned about as a unit.

Three design choices shape the decorator. Correlation — every saga has a correlation key, named by correlatedBy: string, that maps incoming events to the saga instance handling them. When a PaymentCaptured event arrives, the framework reads event[correlatedBy] (typically orderId or subscriptionId), loads the saga with that id from the saga store, applies the event. The correlation key is what makes the saga find itself in a stream of unrelated events.

PersistencepersistedAs?: string names which SagaStorePort implementation persists this saga. Different sagas may use different stores (in-memory for tests, Postgres for production, Redis for cross-process). The name is the registry key; the framework matches it to a registered adapter at runtime.

Compensationscompensations?: Readonly<Record<string, string>> maps each forward step to the name of a compensation method on the same saga class. If chargeCard is the forward step, compensations.chargeCard is refundCard; on failure of a later step, the framework calls the compensation methods in reverse order so the saga rolls back its visible work. The mapping is a runtime registry, the methods are the saga's own — the discipline is "every forward step declares its undo path."


The Runtime: ddd-saga

decorator.ts declares the surface. SagaOptions requires correlatedBy: string, accepts optional persistedAs: string and compensations: Record<string, string>. The decorator wraps everything in a SagaMetadata literal with decoratorKind: 'Saga' and version: 1, stamps __saga and __sagaMetadata onto the class.

In our invented architecture, an order-fulfilment saga looks like this:

import { Saga } from '@frenchexdev/ddd-saga';

@Saga({
  correlatedBy: 'orderId',
  persistedAs: 'postgres:OrderFulfilment',
  compensations: {
    chargeCard:     'refundCard',
    provisionAccount: 'deprovisionAccount',
    sendWelcomeEmail: 'sendCancellationEmail',
  },
})
export class OrderFulfilmentSaga {
  // Forward steps
  async chargeCard(e: PaymentRequiredEvent): Promise<void>     { /* ... */ }
  async provisionAccount(e: PaymentCapturedEvent): Promise<void> { /* ... */ }
  async sendWelcomeEmail(e: AccountReadyEvent): Promise<void>  { /* ... */ }
  // Compensations
  async refundCard(): Promise<void>            { /* ... */ }
  async deprovisionAccount(): Promise<void>    { /* ... */ }
  async sendCancellationEmail(): Promise<void> { /* ... */ }
}

correlatedBy: 'orderId' tells the framework: when an event with event.orderId === 'ord_xyz' arrives, load the saga instance keyed by 'ord_xyz' from the store, invoke the matching forward method. persistedAs selects the Postgres adapter from the registered saga stores. compensations lets the framework, on a later step failure, walk back through the completed forward steps and call each compensation in reverse order.

isSagaClass is the type guard used by the registry codegen to find sagas without a hand-maintained list.


The Analyzer: ddd-saga-analyzer

Spec-first (spec.ts). Pattern SAGA under SagaLongRunningOrchestrationRequirement, priority High. Four rules. DDD-SAGA-001 requires correlatedBy — without it, the framework cannot route events to the right saga instance. DDD-SAGA-002 requires the class to be concrete — an abstract saga has nothing to dispatch to. DDD-SAGA-003 recommends the Saga suffix at info severity. DDD-SAGA-004 keeps one saga per file so the orchestration is navigable.

rules: [
  { kind: 'require-decorator-arg',  code: 'DDD-SAGA-001', severity: 'error',
    decoratorName: 'Saga', argName: 'correlatedBy',
    message: '@Saga must declare correlatedBy (event field used to load the saga instance)' },
  { kind: 'require-concrete-class', code: 'DDD-SAGA-002', severity: 'error',
    decoratorName: 'Saga',
    message: '@Saga must be a concrete class (long-running orchestration with executable steps)' },
  { kind: 'require-name-suffix',    code: 'DDD-SAGA-003', severity: 'info',
    suffix: 'Saga',
    message: 'Saga type should end with "Saga" suffix by convention' },
  { kind: 'single-per-file',        code: 'DDD-SAGA-004', severity: 'error',
    decoratorName: 'Saga',
    message: 'File declares {count} @Saga classes — split one saga per file' },
],

The four rules cover the structural shape. The contract-level invariants — every entry in compensations must reference a real method on the class, every forward step that mutates external state must have a compensation declared — are the kind a future cross-AST rule will enforce. The spec is the place where those rules will be added; the analyzer is rebuilt from the spec when they are.


The Codegen: ddd-saga-codegen

Spec-first (spec.ts). Two templates.

templates/saga-stub.ts emits a concrete <SagaClassName>Stub with a handle(event) entry point that throws NotImplemented and a docstring listing the steps. The signature surfaces the correlation key — event: { [correlatedBy]: string } & Record<string, unknown> — so the stub itself documents which field the framework will use to route events.

// AUTO-GENERATED by ddd-saga-codegen:saga-stub — do not edit.
/**
 * Long-running saga: OrderFulfilmentSaga.
 * Correlation key: orderId (extracted from each incoming event).
 * Steps:
 *   - chargeCard
 *   - provisionAccount
 *   - sendWelcomeEmail
 */
export class OrderFulfilmentSagaStub {
  async handle(event: { orderId: string } & Record<string, unknown>): Promise<void> {
    throw new Error('NotImplemented: OrderFulfilmentSagaStub.handle (correlatedBy=orderId)');
  }
}

The registry template emits SAGA_REGISTRY keyed by class name, sorted deterministically. The framework reads this registry to know which sagas to load for which events; the registry is what makes the wiring declarative rather than imperative.


@Saga builds on several patterns.

  • It is a specialised @AggregateRoot — long-running, persistent, event-reactive.
  • It persists via @SagaStore — the next article covers the port and its memory/Postgres/Redis adapters.
  • It reacts to @DomainEvent instances dispatched by the @EventBus.
  • A saga's forward step may emit further @DomainEvents that other sagas (or other consumers) react to — the orchestration composes.

Back to the series index.

⬇ Download