noddde

Sagas

Event-driven process managers that coordinate workflows across multiple aggregates.

What is a Saga?

A saga (also called a process manager) is a stateful, event-driven workflow that coordinates actions across multiple aggregates. While an aggregate handles a single consistency boundary, a saga orchestrates the interactions between several aggregates to complete a multi-step business process.

A saga is the structural inverse of an aggregate: an aggregate receives commands and produces events; a saga receives events and produces commands.

  Aggregate                          Saga
  =========                          ====

  Command ──> decide ──> Event(s)    Event ──> react ──> Command(s)
                 |                               |
                 v                               v
              State                           State

Aggregate vs Saga

AggregateSaga
Triggered byCommandsEvents
ProducesEventsCommands
StateDomain truthWorkflow progress
PersistenceEvent-sourced or state-storedState-stored
PurityCommand handlers may use infraEvent handlers may use infra

When to Use a Saga

Use a saga when a business process:

  • Spans multiple aggregates -- e.g., Order, Payment, and Shipping must coordinate for order fulfillment
  • Requires sequential steps -- each step depends on the outcome of the previous one
  • Needs compensation -- if a step fails, earlier steps must be rolled back (e.g., refund a payment when shipping fails)
  • Has a lifecycle -- the process starts, progresses through stages, and completes or times out

Examples

  • Order fulfillment -- Order placed, payment requested, payment completed, shipment arranged, shipment dispatched, order delivered
  • Account onboarding -- Registration, email verification, KYC check, account activated
  • Transfer between accounts -- Debit source, credit destination, confirm transfer (or rollback)

The SagaTypes Bundle

Like aggregates and projections, a saga starts with a types bundle that declares its type universe:

import { SagaTypes } from "@noddde/core";

type OrderFulfillmentSagaDef = {
  state: OrderFulfillmentState;
  events: OrderEvent | PaymentEvent | ShippingEvent;
  commands: OrderCommand | PaymentCommand | ShippingCommand;
  infrastructure: EcommerceInfrastructure;
};
MemberDescription
stateThe saga's internal state tracking workflow progress
eventsUnion of all event types the saga reacts to (from any aggregate)
commandsUnion of all command types the saga may dispatch
infrastructureExternal dependencies available to handlers

Note that events and commands can span multiple aggregates -- this is the whole point of a saga.

Saga State

The saga state tracks where the workflow currently stands. Design it around the workflow steps, not domain state:

type FulfillmentStatus =
  | "awaiting_payment"
  | "payment_failed"
  | "awaiting_shipment"
  | "shipped"
  | "delivered"
  | "cancelled";

interface OrderFulfillmentState {
  orderId: string | null;
  customerId: string | null;
  items: OrderItem[];
  total: number;
  status: FulfillmentStatus | null;
  paymentId: string | null;
  shipmentId: string | null;
  trackingNumber: string | null;
}

The initialState should use null/empty zero-values, just like aggregate state.

The SagaReaction

Every saga event handler returns a SagaReaction -- the new state plus commands to dispatch:

type SagaReaction<TState, TCommands extends Command> = {
  state: TState;
  commands?: TCommands | TCommands[];
};

This is the key design: handlers return commands declaratively rather than dispatching them imperatively. The framework handles dispatch after persisting the new state. This keeps handlers testable -- you assert on the returned value without mocking a bus.

Return patterns

// Single command
return {
  state: { ...state, status: "awaiting_payment" },
  commands: { name: "RequestPayment", targetAggregateId: paymentId, payload: { ... } },
};

// Multiple commands
return {
  state: { ...state, status: "awaiting_shipment" },
  commands: [
    { name: "ConfirmOrder", targetAggregateId: orderId },
    { name: "ArrangeShipment", targetAggregateId: shipmentId, payload: { ... } },
  ],
};

// State update only, no commands
return { state: { ...state, status: "delivered" } };

// Conditional commands
return {
  state: { ...state, status: "cancelled" },
  commands: state.paymentId
    ? { name: "RefundPayment", targetAggregateId: state.paymentId, payload: { ... } }
    : undefined,
};

Defining a Saga with defineSaga

Use defineSaga -- an identity function that provides full type inference:

import { defineSaga } from "@noddde/core";

export const OrderFulfillmentSaga = defineSaga<OrderFulfillmentSagaDef>({
  initialState: {
    orderId: null,
    customerId: null,
    items: [],
    total: 0,
    status: null,
    paymentId: null,
    shipmentId: null,
    trackingNumber: null,
  },

  startedBy: ["OrderPlaced"],

  associations: {
    // Order aggregate — uses "orderId"
    OrderPlaced: (event) => event.payload.orderId,
    OrderConfirmed: (event) => event.payload.orderId,
    OrderCancelled: (event) => event.payload.orderId,
    // Payment aggregate — uses "referenceId"
    PaymentCompleted: (event) => event.payload.referenceId,
    PaymentFailed: (event) => event.payload.referenceId,
    // Shipping aggregate — uses "customerReference"
    ShipmentDispatched: (event) => event.payload.customerReference,
    ShipmentDelivered: (event) => event.payload.customerReference,
  },

  handlers: {
    // Order placed → request payment
    OrderPlaced: (event, state) => ({
      state: {
        ...state,
        orderId: event.payload.orderId,
        customerId: event.payload.customerId,
        total: event.payload.total,
        items: event.payload.items,
        status: "awaiting_payment" as const,
        paymentId: "payment-" + event.payload.orderId,
      },
      commands: {
        name: "RequestPayment",
        targetAggregateId: "payment-" + event.payload.orderId,
        payload: {
          referenceId: event.payload.orderId,
          amount: event.payload.total,
        },
      },
    }),

    // Payment completed → confirm order + arrange shipment
    PaymentCompleted: (event, state) => ({
      state: { ...state, status: "awaiting_shipment" as const },
      commands: [
        {
          name: "ConfirmOrder",
          targetAggregateId: state.orderId!,
        },
        {
          name: "ArrangeShipment",
          targetAggregateId: "ship-" + event.payload.referenceId,
          payload: {
            customerReference: event.payload.referenceId,
            itemCount: state.items.reduce((sum, i) => sum + i.quantity, 0),
          },
        },
      ],
    }),

    // Payment failed → cancel the order
    PaymentFailed: (event, state) => ({
      state: { ...state, status: "payment_failed" as const },
      commands: {
        name: "CancelOrder",
        targetAggregateId: state.orderId!,
        payload: { reason: `Payment failed: ${event.payload.reason}` },
      },
    }),

    // Shipment dispatched → update order with tracking
    ShipmentDispatched: (event, state) => ({
      state: {
        ...state,
        trackingNumber: event.payload.trackingNumber,
        status: "shipped" as const,
      },
      commands: {
        name: "MarkOrderShipped",
        targetAggregateId: state.orderId!,
        payload: { trackingNumber: event.payload.trackingNumber },
      },
    }),

    // Shipment delivered → notify customer, mark order complete
    ShipmentDelivered: async (_event, state, { notificationService }) => {
      await notificationService.notifyCustomer(
        state.customerId!,
        `Your order ${state.orderId} has been delivered!`,
      );
      return {
        state: { ...state, status: "delivered" as const },
        commands: {
          name: "MarkOrderDelivered",
          targetAggregateId: state.orderId!,
        },
      };
    },

    // Order cancelled → refund if payment was taken
    OrderCancelled: (event, state) => ({
      state: { ...state, status: "cancelled" as const },
      commands: state.paymentId
        ? {
            name: "RefundPayment",
            targetAggregateId: state.paymentId,
            payload: { reason: event.payload.reason },
          }
        : undefined,
    }),

    // Events observed for tracking, no commands dispatched
    OrderConfirmed: (_event, state) => ({ state }),
  },
});

Associations

Aggregates have a simple routing mechanism: every command carries a targetAggregateId that identifies which instance should handle it. Sagas don't have this luxury. Events carry domain data, not a "target saga ID" -- and different bounded contexts name their correlation fields differently. The Order aggregate calls it orderId, the Payment aggregate calls it referenceId, and the Shipping aggregate calls it customerReference. They all refer to the same correlation ID, but each context uses its own naming convention.

The association map solves this: a per-event function that extracts the saga instance ID from each event type, regardless of how each context stores it.

Defining associations

The associations field is a map keyed by event name. Each entry is a function that receives the narrowed event type and returns the saga instance ID:

associations: {
  // Order events — the order aggregate uses "orderId"
  OrderPlaced: (event) => event.payload.orderId,
  // Payment events — the payment aggregate uses "referenceId"
  PaymentCompleted: (event) => event.payload.referenceId,
  // Shipping events — the shipping aggregate uses "customerReference"
  ShipmentDispatched: (event) => event.payload.customerReference,
},

Inside each association function, event is narrowed to the specific event type for that key -- not the full union. This means you get full IntelliSense on the event payload, and TypeScript will catch it if you try to access a field that doesn't exist on that specific event type.

Every event must have an association

The association map requires an entry for every event in the saga's event union. If you add an event to the events type but forget to add an association, TypeScript will report a compile error. This is enforced by the mapped type:

type SagaAssociationMap<T extends SagaTypes, TSagaId extends ID = string> = {
  [K in T["events"]["name"]]: (
    event: Extract<T["events"], { name: K }>,
  ) => TSagaId;
};

Why different field names?

In real-world systems, each bounded context owns its own naming conventions. A Payment service doesn't think in terms of "orders" -- it processes payments for any kind of reference. A Shipping service tracks shipments by its own IDs and treats the originating order as an external customer reference.

This is exactly why associations are per-event functions rather than a single shared extractor: the same correlation ID lives under a different key in each context's events.

The startedBy Declaration

The startedBy field declares which events can create a new saga instance:

startedBy: ["OrderPlaced"],

When an event arrives:

  1. The association function extracts the saga ID
  2. The framework checks if a saga instance with that ID already exists
  3. If the event is in startedBy and no instance exists -- create a new instance with initialState
  4. If the event is in startedBy and an instance exists -- use the existing instance (idempotent restart)
  5. If the event is NOT in startedBy and no instance exists -- ignore the event (the saga hasn't started yet)
  6. If the event is NOT in startedBy and an instance exists -- use the existing instance (normal continuation)

The startedBy type is a non-empty tuple [T, ...T[]], enforcing at least one entry at the type level.

Multiple start events

A saga may be started by multiple events:

startedBy: ["OrderPlaced", "ImportedOrderReceived"],

Custom Saga ID Types

By default, saga IDs are string. You can use a custom type via the second generic parameter:

// UUID branded type
type OrderSagaId = string & { __brand: "OrderSagaId" };

const MySaga = defineSaga<MySagaDef, OrderSagaId>({
  // ...
  associations: {
    OrderPlaced: (event) => event.payload.orderId as OrderSagaId,
  },
});

Saga Runtime Lifecycle

The full saga runtime lifecycle proceeds as follows:

  1. An event arrives (from any aggregate in the domain)
  2. The framework checks if any saga subscribes to that event
  3. The association map extracts the saga instance ID from the event
  4. If the event is in startedBy and no instance exists, a new instance is created with initialState
  5. Otherwise, the existing saga state is loaded from persistence
  6. The event handler is called with (event, state, infrastructure)
  7. The handler returns a SagaReaction: new state + commands to dispatch
  8. The framework persists the new state and dispatches the returned commands
  9. Those commands trigger aggregates, which emit events, which may trigger the saga again
Event ──> Association Map ──> Load State ──> Handler ──> SagaReaction
                                                           |
                                                    ┌──────┴──────┐
                                                    v             v
                                              Persist State   Dispatch Commands
                                                                  |
                                                                  v
                                                           Aggregate(s)
                                                                  |
                                                                  v
                                                            New Event(s)
                                                                  |
                                                                  v
                                                            (cycle repeats)

Registering Sagas

Sagas are registered in the processModel section of configureDomain -- a dedicated top-level key separate from writeModel and readModel:

import { configureDomain, InMemorySagaPersistence } from "@noddde/engine";

const domain = await configureDomain<EcommerceInfrastructure>({
  writeModel: {
    aggregates: { Order, Payment, Shipping },
  },
  readModel: {
    projections: { OrderSummary: OrderSummaryProjection },
  },
  processModel: {
    sagas: {
      OrderFulfillment: OrderFulfillmentSaga,
    },
  },
  infrastructure: {
    aggregatePersistence: () => new InMemoryEventSourcedAggregatePersistence(),
    sagaPersistence: () => new InMemorySagaPersistence(),
    provideInfrastructure: () => ({
      /* ... */
    }),
    cqrsInfrastructure: () => ({
      /* ... */
    }),
  },
});

The processModel section is separate because sagas are neither pure write-model (they subscribe to events) nor pure read-model (they dispatch commands). They bridge both sides.

Type Inference Helpers

Like aggregates and projections, sagas have Infer* helpers:

import {
  InferSagaState,
  InferSagaEvents,
  InferSagaCommands,
  InferSagaInfrastructure,
  InferSagaId,
} from "@noddde/core";

type State = InferSagaState<typeof OrderFulfillmentSaga>;
type Events = InferSagaEvents<typeof OrderFulfillmentSaga>;
type Commands = InferSagaCommands<typeof OrderFulfillmentSaga>;
type Infra = InferSagaInfrastructure<typeof OrderFulfillmentSaga>;
type Id = InferSagaId<typeof OrderFulfillmentSaga>; // string

Saga vs Standalone Command Handler

noddde also supports standalone command handlers for cross-aggregate coordination. Here is when to use which:

Standalone Command HandlerSaga
Triggered byA command (imperative)Domain events (reactive)
StatefulNoYes (persisted state)
OutputSide effects via infrastructureDeclarative command returns
LifecycleSingle request-responseMulti-step over time
TestabilityRequires mocking busesAssert on returned data
CompensationManual try/catchBuilt-in via event reactions
Error handlingManual try/catchCompensation via state machine
Use caseSimple one-shot orchestration, integrationsMulti-step workflows, long-running processes

Use a standalone command handler for stateless, simple coordination: sending notifications, syncing with external systems, or dispatching a few commands in sequence.

Use a saga when the workflow has multiple steps, needs to track progress, or requires compensation on failure.

Next Steps

On this page