Skip to content

Workflows + sagas

A workflow is a listener that needs to remember things, correlate events by a key, or wait on a timer. It's the same when(Event, …) reaction, with state and time added. One primitive (defineWorkflow) covers the range:

  • Reaction — one event in, effects out: send an email, dispatch the next action. No state.
  • Saga — a multi-event state machine with timers and per-instance correlation: retry payment up to 3 times, then mark the order failed.

You don't choose a mode. The runtime infers it: declare data or states and it runs as a saga; otherwise it's a stateless reaction with near-zero overhead.

Stateless reaction

ts
import { defineWorkflow } from "@nwire/forge";
import { OrderWasPlaced } from "../orders/events";
import { sendOrderConfirmation } from "./send-order-confirmation.action";

export const notifyOnOrderPlaced = defineWorkflow("notify-on-order-placed", ({ when, send }) => {
  when(OrderWasPlaced, async (event) => {
    await send(sendOrderConfirmation, {
      customerId: event.customerId,
      orderId:    event.orderId,
    });
  });
});

When OrderWasPlaced fires, the workflow dispatches sendOrderConfirmation — which is itself an action that emits its own event (ConfirmationWasSent). The whole chain shows up in Studio's trace view.

Stateful saga

When the reaction needs memory ("retry payment up to 3 times, then mark the order as failed and email the customer"), declare data + states:

ts
import { defineWorkflow } from "@nwire/forge";
import { z } from "zod";
import { OrderWasPlaced } from "../orders/events";
import { PaymentSucceeded, PaymentFailed } from "../payments/events";
import { chargeCard, markOrderFailed } from "../payments/actions";

export const collectPayment = defineWorkflow(
  "collect-payment",
  ({ data, states, when, send, schedule, timeout, complete, assign }) => {
    const { idle, charging, failed } = states;

    when(OrderWasPlaced, async (event) => {
      await assign({ orderId: event.orderId, attempts: 0 });
      return charging;
    });

    charging(() => {
      send(chargeCard, { orderId: data.orderId });

      when(PaymentSucceeded, () => complete);

      when(PaymentFailed, async () => {
        if (data.attempts < 3) {
          await assign({ attempts: data.attempts + 1 });
          await schedule(retryTimer);
          return charging;
        }
        return failed;
      });
    });

    failed(() => {
      send(markOrderFailed, { orderId: data.orderId });
      return complete;
    });

    const retryTimer = timeout("retry-charge", "30s");
    when(retryTimer, () => charging);
  },
  {
    data: z.object({ orderId: z.string(), attempts: z.number().int().nonnegative() }),
    states: {
      idle:     { initial: true },
      charging: {},
      failed:   {},
    },
    correlate: (map) => {
      map(OrderWasPlaced,    (e) => e.orderId);
      map(PaymentSucceeded,  (e) => e.orderId);
      map(PaymentFailed,     (e) => e.orderId);
    },
  },
);

What it does:

  • One workflow instance per orderId (via correlate).
  • Starts when OrderWasPlaced fires; transitions through idle → charging → (complete | failed).
  • Retries up to 3 times with a 30s delay between attempts; timers survive process restarts when paired with a persistent timer store.

When to use what

NeedForm
Send an email when X happensStateless reaction
Dispatch the next action in a chainStateless reaction
Retry with backoffStateful saga (timers)
Multi-step approval (request → review → decide)Stateful saga (states)
Wait for two events before proceedingStateful saga (correlate + when)

Workflows vs actors

Both react to events. The difference:

  • An actor holds state for one entity (one order, one user). Methods on the actor enforce invariants on that entity.
  • A workflow holds state for one orchestration (one payment attempt, one onboarding flow). It coordinates between entities.

Rule of thumb: if it answers "the current state of an order", it's the order actor. If it answers "what step of the payment retry are we on", it's a saga workflow.

Where to next

MIT licensed.