Skip to content

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.

ts
// 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:

ts
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:

ts
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:

WorkflowActor
Triggered byevents it reacts tocommands a handler sends it
Keyed bya correlation key (the process)an entity id (the thing)
Lifecycletransient — runs to a final statedurable — 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

ts
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

MIT licensed.