Workflows
A listener reacts to one event and acts. A workflow is what that listener becomes when the reaction needs to remember something across events, correlate them by a key, or wait on a timer. It's the same when(Event, (payload, ctx) => …) block you already write — with state and time added.
That's the whole idea: you don't relearn a shape to handle a longer-lived process. You graduate by adding what you need.
// a listener — stateless, reacts and acts
when(OrderPlaced, (order, ctx) => ctx.send(reserveStock, { id: order.id }));When "reserve stock, then wait for payment, and release it if payment doesn't arrive within an hour" enters the picture, that same when moves into a workflow that holds a little state and schedules a timer. Nothing about how you write the reaction changes.
The common case: react and act
Most workflows hold no state at all. They react to an event and produce effects — dispatch a command, publish another event, enqueue work:
import { defineWorkflow } from "@nwire/forge";
export const onWashComplete = defineWorkflow("on-wash-complete", ({ when }) => {
when(CarWasWashed, async (event, ctx) => {
await ctx.publish(WashRecorded({ washId: event.washId, at: event.washedAt }));
await ctx.enqueue(normalizeTraffic, { stationId: event.stationId });
});
});No data, no states. The framework sees there's nothing to remember and runs a plain async dispatch — no state machine, near-zero overhead. Several whens in one workflow is fine; each reacts independently.
The reaction context (ctx) is the listener context plus a couple of workflow verbs:
send(handler, input)— dispatch a command and await it.publish(Event, payload)— emit an event others can react to.enqueue(handler, input)— hand work to a queue, fire-and-forget.assign(patch)/schedule(timer)— only meaningful once the workflow holds state (below); they no-op otherwise.
When it holds state: a saga
Declare a data schema and named states, and the workflow becomes a small state machine: one instance per correlation key, transitions driven by what each handler returns, timers it can set and react to. This is the tool for a process that spans steps and time — a renewal that notifies, retries, and eventually suspends:
export const subscriptionRenewal = defineWorkflow("subscription-renewal",
({ data, states, when, assign, send, publish, schedule, timeout, complete }) => {
const { notified, retrying, paid, suspended } = states;
const PaymentOverdue = timeout("payment-overdue", "7d");
const RetryOverdue = timeout("retry-overdue", "2d");
// entry: a renewal is due → notify, arm the timer, land in `notified`
when(RenewalDue, async (e, ctx) => {
await assign({ subscriptionId: e.subscriptionId, amount: e.amount, attempts: 0 });
await ctx.send(sendRenewalNotice, { subscriptionId: e.subscriptionId });
await schedule(PaymentOverdue);
return notified;
});
// always active: payment confirmed wins from any state
when(PaymentConfirmed, async (_e, ctx) => {
await ctx.send(activateSubscription, { subscriptionId: data.subscriptionId });
return paid;
});
// a state body scopes reactions to that state
retrying(() => {
when(PaymentFailed, async () => {
if (data.attempts >= 3) return suspended;
await assign({ attempts: data.attempts + 1 });
await schedule(RetryOverdue);
return retrying;
});
});
// fires when the workflow reaches any `final` state
when(complete, async (_e, ctx) => {
await ctx.publish(RenewalFinished({ subscriptionId: data.subscriptionId }));
});
},
{
correlate: (map) => {
map(RenewalDue, (e) => e.subscriptionId);
map(PaymentConfirmed, (e) => e.subscriptionId);
map(PaymentFailed, (e) => e.subscriptionId);
},
data: z.object({
subscriptionId: z.string(),
amount: z.number(),
attempts: z.number().default(0),
}),
states: { notified: {}, retrying: {}, paid: { final: true }, suspended: { final: true } },
},
);You don't flip a switch to go stateful — the framework infers it from whether you declared data/states. No schema means the fast stateless path; a schema means a persisted state machine that survives restarts.
The handful of rules
Where you call when sets its scope. At the top of the closure a reaction is always active; inside a state body (retrying(() => …)) it only fires while the workflow is in that state.
The most specific reaction wins. If an event has both a top-level and a state-scoped reaction for the current state, only the state-scoped one runs — one transition per event, no stacking.
A returned state is a transition. Return a state from states to move there; return nothing to stay put. Returning a state marked final: true ends the workflow and fires complete.
complete is the finish line. Subscribe with when(complete, …) to publish a completion event or release resources. (For a stateless workflow it fires after each handled event — "after each reaction.")
Correlation and operational options
correlate maps each event to the key that names its workflow instance, so events for the same subscription land in the same saga. Without it, every event starts a fresh instance.
The optional third argument to when carries operational concerns, mirroring a handler's: retry (handler-body retry policy), dlq (where to send a permanently-failed reaction), and idempotencyKey (a pure function of the event, for dedup).
Workflow or actor?
Both hold state, but they answer different questions:
| Workflow | Actor | |
|---|---|---|
| Triggered by | events it reacts to | commands a handler sends it |
| Keyed by | a correlation key (the process) | an entity id (the thing) |
| Lifecycle | transient — runs to a final state | durable — lives as long as the entity |
If you're modeling a process that runs to completion (a renewal, an onboarding), it's a workflow. If you're modeling a thing with identity that enforces a rule (an order that can't ship twice, a seat that can't be double-booked), it's an actor.
Testing
it("suspends after three failed retries", async () => {
const h = await harness({ app: testApp });
await h.emit(RenewalDue, { subscriptionId: "sub_1", amount: 99 });
await h.advanceTime("7d"); await h.idle();
await h.advanceTime("2d"); await h.idle();
await h.advanceTime("2d"); await h.idle();
const wf = h.workflow("subscription-renewal", "sub_1");
expect(wf.state).toBe("suspended");
});See also
defineWorkflowreference — every option, timer semantics, retry shape.- Events and listeners — where a workflow starts life.
- Actor — the sibling for entities with identity.