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
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:
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(viacorrelate). - Starts when
OrderWasPlacedfires; transitions throughidle → 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
| Need | Form |
|---|---|
| Send an email when X happens | Stateless reaction |
| Dispatch the next action in a chain | Stateless reaction |
| Retry with backoff | Stateful saga (timers) |
| Multi-step approval (request → review → decide) | Stateful saga (states) |
| Wait for two events before proceeding | Stateful 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
defineWorkflowreference — full signature, all builder methods, timer store interface.- Actors — when state lives on an entity, not on a flow.
- Cross-service events — workflows that consume events from a different service over a bus.