noddde
Design Decisions

Why Sagas Return Commands

Why saga event handlers return a SagaReaction instead of dispatching commands imperatively.

The Decision

Saga event handlers return a SagaReaction<TState, TCommands> (state + commands) rather than dispatching commands imperatively through the CommandBus.

order-fulfillment/saga.ts
// noddde approach: declarative return
PaymentCompleted: {
  id: (event) => event.payload.referenceId,
  handle: (event, state) => ({
    state: { ...state, status: "awaiting_shipment" },
    commands: [
      { name: "ConfirmOrder", targetAggregateId: state.orderId! },
      { name: "ArrangeShipment", targetAggregateId: shipmentId, payload: { ... } },
    ],
  }),
},

// Alternative: imperative dispatch (NOT what noddde does)
PaymentCompleted: {
  id: (event) => event.payload.referenceId,
  handle: async (event, state, { commandBus }) => {
    await commandBus.dispatch({ name: "ConfirmOrder", ... });
    await commandBus.dispatch({ name: "ArrangeShipment", ... });
    return { ...state, status: "awaiting_shipment" };
  },
},

Why

Mirrors the Aggregate Pattern

In noddde, aggregate decide handlers return events — they don't persist events themselves. The framework handles persistence and dispatch. Sagas follow the same pattern: handlers return commands, and the framework handles dispatch after persisting the new state.

This consistency makes the framework predictable: handlers declare outputs, the framework executes side effects.

Testability

With declarative returns, testing is trivial:

__tests__/order-fulfillment-saga.test.ts
const reaction = OrderFulfillmentSaga.on.PaymentCompleted!.handle(
  event,
  state,
  mockInfra,
);
expect(reaction.commands).toMatchObject([
  { name: "ConfirmOrder" },
  { name: "ArrangeShipment" },
]);

With imperative dispatch, you'd need to mock the CommandBus, capture calls, and assert on mock invocations. The test becomes coupled to the dispatch mechanism rather than the business decision.

Atomicity

The framework can persist the new saga state and dispatch commands as a single logical operation. If the handler dispatched commands imperatively, a failure between dispatches would leave the saga in an inconsistent state — commands partially dispatched but state not updated.

By default (atomicity: "atomic") the saga-state save and all returned commands share one unit of work, so they commit or roll back together. A saga can opt into atomicity: "best-effort" to commit its state before dispatching commands — see Atomicity for when that trade-off is worthwhile.

Deterministic Replay

If saga event streams are replayed (for rebuilding state or debugging), declarative reactions can be inspected without executing side effects. Imperative dispatches would re-trigger commands during replay.

The Trade-off

Handlers still receive CQRSInfrastructure (including QueryBus), so they can read data before deciding which commands to return. The constraint is on outputs, not inputs: you can read from the query bus, but you return commands rather than dispatching them.

For truly imperative side effects (notifications, logging), handlers can call infrastructure methods directly — only command dispatch is declarative.

The Golden Path: Return Events, Don't Dispatch Them

The same principle applies one layer down, to the aggregates a saga's commands target. On the golden path, aggregate deciders return events; the engine defers them and publishes them only after the unit of work commits. This is what makes the saga's atomicity guarantee hold — the saga state and the events its commands produce become visible together, in order.

Dispatching an event manually with eventBus.dispatch() from inside a command handler (or decider) is off the golden path: the event is published immediately, before the surrounding unit of work commits, so it loses the engine's ordering and atomicity guarantees.

Under the default atomic saga mode, an event a command handler dispatches directly is delivered before the saga's unit of work commits. If that event is meant to advance the same saga instance, the re-entrant handler loads uncommitted (null) state and the event is silently dropped — the saga never advances (issue #119). Prefer returning events from your deciders. If a handler genuinely must dispatch directly (e.g. a standalone command handler, which has no return channel), set the consuming saga to atomicity: "best-effort" so it commits its state before dispatching.

See Also

On this page