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.
You can scaffold a saga with the CLI: noddde new saga PaymentProcessing. See
CLI Reference for details.
Aggregate vs Saga
| Aggregate | Saga | |
|---|---|---|
| Triggered by | Commands | Events |
| Produces | Events | Commands |
| State | Domain truth | Workflow progress |
| Persistence | Event-sourced or state-stored | State-stored |
| Purity | Decide handlers may use infra | Event 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;
};| Member | Description |
|---|---|
state | The saga's internal state tracking workflow progress |
events | Union of all event types the saga reacts to (from any aggregate) |
commands | Union of all command types the saga may dispatch |
infrastructure | External 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. We recommend extracting each on entry to its own file typed with InferSagaOnEntry -- see Why Extracted Handlers. The examples in this section use that pattern.
import type { InferSagaOnEntry } from "@noddde/core";
import type { OrderFulfillmentSagaDef } from "../saga";
export const onOrderPlaced: InferSagaOnEntry<
OrderFulfillmentSagaDef,
"OrderPlaced"
> = {
id: (event) => event.payload.orderId,
handle: (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,
},
},
}),
};import type { InferSagaOnEntry } from "@noddde/core";
import type { OrderFulfillmentSagaDef } from "../saga";
export const onPaymentCompleted: InferSagaOnEntry<
OrderFulfillmentSagaDef,
"PaymentCompleted"
> = {
id: (event) => event.payload.referenceId,
handle: (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),
},
},
],
}),
};import type { InferSagaOnEntry } from "@noddde/core";
import type { OrderFulfillmentSagaDef } from "../saga";
export const onShipmentDelivered: InferSagaOnEntry<
OrderFulfillmentSagaDef,
"ShipmentDelivered"
> = {
id: (event) => event.payload.customerReference,
handle: 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!,
},
};
},
};Then the saga definition imports them all:
import { defineSaga } from "@noddde/core";
import { onOrderPlaced } from "./on-entries/on-order-placed";
import { onPaymentCompleted } from "./on-entries/on-payment-completed";
import { onPaymentFailed } from "./on-entries/on-payment-failed";
import { onShipmentDispatched } from "./on-entries/on-shipment-dispatched";
import { onShipmentDelivered } from "./on-entries/on-shipment-delivered";
import { onOrderCancelled } from "./on-entries/on-order-cancelled";
import { onOrderConfirmed } from "./on-entries/on-order-confirmed";
export type OrderFulfillmentSagaDef = {
state: OrderFulfillmentState;
events: OrderEvent | PaymentEvent | ShippingEvent;
commands: OrderCommand | PaymentCommand | ShippingCommand;
infrastructure: EcommerceInfrastructure;
};
export const OrderFulfillmentSaga = defineSaga<OrderFulfillmentSagaDef>({
initialState: {
orderId: null,
customerId: null,
items: [],
total: 0,
status: null,
paymentId: null,
shipmentId: null,
trackingNumber: null,
},
startedBy: ["OrderPlaced"],
on: {
OrderPlaced: onOrderPlaced,
PaymentCompleted: onPaymentCompleted,
PaymentFailed: onPaymentFailed,
ShipmentDispatched: onShipmentDispatched,
ShipmentDelivered: onShipmentDelivered,
OrderCancelled: onOrderCancelled,
OrderConfirmed: onOrderConfirmed,
},
});The on Map
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 on map solves this: each entry bundles an id function that extracts the saga instance ID from the event and a handle function that processes the event, all keyed by event name.
Defining on entries
The on field is a map keyed by event name. Each entry has an id function that receives the narrowed event type and returns the saga instance ID, and a handle function that receives the event, current state, and infrastructure. Whether the entry is inline or extracted, the shape is the same:
on: {
// Extracted entries -- each typed with InferSagaOnEntry in its own file
OrderPlaced: onOrderPlaced,
PaymentCompleted: onPaymentCompleted,
ShipmentDispatched: onShipmentDispatched,
},Inside each on entry, 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. When using InferSagaOnEntry, this narrowing is enforced by the type annotation itself.
The on map is partial
The on map is typed as partial over the event union. You only need entries for events the saga handles. Events in the saga's event type that have no entry in the on map are silently ignored at runtime.
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 id functions are per-event 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:
- The
idfunction from the matchingonentry extracts the saga ID - The framework checks if a saga instance with that ID already exists
- If the event is in
startedByand no instance exists -- create a new instance withinitialState - If the event is in
startedByand an instance exists -- use the existing instance (idempotent restart) - If the event is NOT in
startedByand no instance exists -- ignore the event (the saga hasn't started yet) - If the event is NOT in
startedByand 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>({
// ...
on: {
OrderPlaced: {
id: (event) => event.payload.orderId as OrderSagaId,
handle: (event, state) => ({
/* ... */
}),
},
},
});Saga Runtime Lifecycle
The full saga runtime lifecycle proceeds as follows:
- An event arrives (from any aggregate in the domain)
- The framework checks if any saga has an
onentry for that event - The
idfunction from the matchingonentry extracts the saga instance ID from the event - If the event is in
startedByand no instance exists, a new instance is created withinitialState - Otherwise, the existing saga state is loaded from persistence
- The
handlefunction is called with(event, state, infrastructure) - The handler returns a
SagaReaction: new state + commands to dispatch - The framework persists the new state and dispatches the returned commands
- Those commands trigger aggregates, which emit events, which may trigger the saga again
Registering Sagas
Sagas are registered in the processModel section of defineDomain -- a dedicated top-level key separate from writeModel and readModel:
import { defineDomain } from "@noddde/core";
import { wireDomain, InMemorySagaPersistence } from "@noddde/engine";
const ecommerceDomain = defineDomain({
writeModel: {
aggregates: { Order, Payment, Shipping },
},
readModel: {
projections: { OrderSummary: OrderSummaryProjection },
},
processModel: {
sagas: {
OrderFulfillment: OrderFulfillmentSaga,
},
},
});
const domain = await wireDomain(ecommerceDomain, {
infrastructure: () => ({
/* ... */
}),
aggregates: {
persistence: () => new InMemoryEventSourcedAggregatePersistence(),
},
sagas: {
persistence: () => new InMemorySagaPersistence(),
},
buses: () => ({
/* ... */
}),
});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.
Atomicity
By default, a saga's state transition and the commands it returns are atomic: the saga state save and every reaction command share a single unit of work, so they commit or roll back together. If any reaction command fails, the saga-state transition is rolled back too.
You can change this per-saga with the optional atomicity field on the definition:
export const OrderFulfillmentSaga = defineSaga<OrderFulfillmentSagaDef>({
atomicity: "atomic", // (default) or "best-effort"
initialState: {
/* ... */
},
startedBy: ["OrderPlaced"],
on: {
/* ... */
},
});| Mode | Coupling | On reaction-command failure |
|---|---|---|
"atomic" (default) | Saga-state save and all reaction commands share one unit of work. | The whole reaction rolls back — saga state is not persisted. |
"best-effort" | Saga state is committed first, then reaction commands are dispatched afterward, each in its own unit of work. | The (already committed) saga state stays persisted; the failing command's own changes roll back. The error still propagates. |
atomicity is purely declarative on the definition — defineSaga does not
read, validate, or default it. The engine's saga executor consumes it, and
treats an absent field as "atomic", so existing sagas keep their current
behavior.
When to use best-effort
Choose best-effort when a reaction command's handler publishes a follow-up event directly through the event bus (the off-path pattern that standalone command handlers must use, since they have no "return events" channel) and that event needs to advance the same saga instance.
Under atomic, reaction commands run inside the saga's still-uncommitted unit of work, so a re-entrant event dispatched by a command handler loads null saga state and is silently dropped — the saga never advances. Committing the saga state first (best-effort) makes that re-entrant event observe the persisted state and resume the saga. This is the fix for the silent event loss in issue #119.
If your aggregates follow the golden path — deciders return events rather than dispatching them, and the engine publishes those events only after the unit of work commits — atomic is correct and you do not need best-effort.
Type Inference Helpers
Like aggregates and projections, sagas have Infer* helpers. The most commonly used is InferSagaOnEntry, which types an extracted on-entry in its own file:
import type { InferSagaOnEntry } from "@noddde/core";
// Types the full { id, handle } object for a specific event
type Entry = InferSagaOnEntry<OrderFulfillmentSagaDef, "OrderPlaced">;Definition-level helpers extract types from the built saga:
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>; // stringSee Type Inference Helpers for the full list of handler-level and definition-level helpers.
Saga vs Standalone Command Handler
noddde also supports standalone command handlers for cross-aggregate coordination. Here is when to use which:
| Standalone Command Handler | Saga | |
|---|---|---|
| Triggered by | A command (imperative) | Domain events (reactive) |
| Stateful | No | Yes (persisted state) |
| Output | Side effects via infrastructure | Declarative command returns |
| Lifecycle | Single request-response | Multi-step over time |
| Testability | Requires mocking buses | Assert on returned data |
| Compensation | Manual try/catch | Built-in via event reactions |
| Error handling | Manual try/catch | Compensation via state machine |
| Use case | Simple one-shot orchestration, integrations | Multi-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
- Standalone Command Handlers -- Stateless cross-aggregate orchestration
- Testing Sagas -- Testing saga handlers and on entries in isolation