Skip to content

defineWorkflow

The primitive for everything event-driven that isn't an actor. Reactions, translators, and sagas — all three are now one declaration.

ts
import { defineWorkflow } from "@nwire/forge";

defineWorkflow(name, closure, options?);
ArgTypePurpose
namestringWorkflow identity, e.g. "on-wash-complete". Used by Studio + scanner.
closure(ctx) => voidThe body. Registers on(...) subscriptions and (for sagas) state onEnter bodies.
options?WorkflowOptionsdata, states, correlate, description, dispatches. Omit for stateless.

Closure context

ts
interface WorkflowContext {
  // Subscriptions
  readonly on: <E extends EventDefinition | typeof complete>(
    event: E,
    handler: (event: EventPayload<E>) => Promise<WorkflowState | void> | WorkflowState | void,
    opts?: OnOptions,
  ) => void;

  // State (saga only — undefined in stateless workflows)
  readonly states: Record<string, WorkflowState>;
  readonly data:   TData;
  readonly assign: (patch: Partial<TData>) => Promise<void>;

  // Effects
  readonly send:    (msg: ActionMessage) => Promise<unknown>;
  readonly execute: (msg: ActionMessage) => Promise<unknown>;     // module wf only
  readonly enqueue: (msg: ActionMessage) => Promise<void>;        // module wf only
  readonly publish: (event: EventMessage) => Promise<void>;
  readonly schedule: (timeout: TimeoutDefinition) => Promise<void>;

  // Built-in
  readonly complete: WorkflowCompleteSymbol;
}

on(Event, handler, opts?)

The one subscription primitive. Context-aware:

  • Top level of the closure → always-active. Fires in any state.
  • Inside a state body (e.g. notified(() => { on(...) })) → state-scoped. Fires only while in that state.
ts
defineWorkflow("renewal", ({ on, states }) => {
  const { notified } = states;

  on(GlobalEvent, async () => { … });           // always-active

  notified(() => {
    on(StateEvent, async () => { … });          // only in `notified`
  });
});

Overlap resolution

If both a top-level and state-scoped handler match, state-scoped wins. The top-level is shadowed. Same semantics as XState's most-specific-match walk up the state hierarchy. Only one handler fires per event.

Return value = transition

The handler's return value transitions the workflow:

ts
on(PaymentConfirmed, async () => {
  await send(activateSubscription({ subscriptionId: data.subscriptionId }));
  return paid;     // transition to `paid` (which is final → workflow ends)
});

on(SomeEvent, async () => {
  await publish(SomeIntegrationEvent({ … }));
  // No return → stay in current state
});

For stateless workflows (no states block), returns are ignored.

Options

ts
on(PaymentFailed, async () => { … }, {
  in:             [notified, retrying],         // state filter
  retry:          { max: 3, backoff: "exponential" },
  dlq:            "renewal-dlq",
  idempotencyKey: (e) => `renewal-${e.subscriptionId}-${e.attempt}`,
  description:    "Retry payment, suspend after 3 attempts",
});
KeyPurpose
in: states[]Equivalent to nesting on() inside each listed state's body. Lets you scope without indentation.
retryRetry policy for the handler body (network failures, downstream errors).
dlqDead-letter target on permanent failure.
idempotencyKeyPure function of the event payload for deduplication. Studio shows the deduped key.
descriptionFree-form description Studio displays on hover.

State bodies (saga only)

ts
states: {
  notified: {},
  retrying: {},
  paid:      { final: true },
  suspended: { final: true },
}

The closure receives states as a destructurable record. Each state is a callable — call it with a body function to define the entry effect:

ts
const { retrying } = states;

retrying(async () => {
  // 1. Entry effects run when transitioning INTO `retrying`
  if (data.attempts >= 3) {
    await send(suspendSubscription({ … }));
    return suspended;        // immediate transition out
  }
  await assign({ attempts: data.attempts + 1 });
  await send(sendPaymentReminder({ … }));

  // 2. State-scoped subscriptions
  on(PaymentFailed, () => retrying);
  on(RetryOverdue,  () => retrying);
});

The state body is itself a transition target — it can return another state to transition immediately (the "if X then go to Y" early-exit pattern).

complete — finalization

complete is a framework-emitted pseudo-event that fires when the workflow enters any state marked final: true:

ts
on(complete, async () => {
  await publish(RenewalFinished({ subscriptionId: data.subscriptionId }));
});

For stateless workflows, complete fires after every successful event handler invocation. Use it for trailing cleanup / outcome publishing.

options.correlate (saga only)

How the framework picks which workflow instance owns an incoming event:

ts
options: {
  correlate: (map) => {
    map(RenewalDue,       (e) => e.subscriptionId);
    map(PaymentConfirmed, (e) => e.subscriptionId);
    map(PaymentFailed,    (e) => e.subscriptionId);
  },
}

Each event in correlate maps to a string key. All events with the same key share the same workflow instance + data. Without correlate, each event creates a fresh instance.

For stateless workflows, correlate is ignored — no instance state to share.

options.data (saga only)

Zod schema for the workflow's persistent state:

ts
data: z.object({
  subscriptionId: z.string(),
  amount:         z.number(),
  attempts:       z.number().default(0),
}),

ctx.data is typed against this schema. ctx.assign({ … }) patches it. The framework persists data between event arrivals (one row per correlation key in the workflow store).

Stateless vs stateful — automatic

The framework decides based on declaration:

  • No data, no states → stateless. Plain async dispatch. No XState instance. No persistence. Zero ceremony, zero overhead.
  • data OR states declared → stateful. XState machine. Correlation key resolves to a persisted instance.

You don't pick. The closure writes itself accordingly.

Module + app scopes

Workflows live in defineModule.workflows[] or defineApp.workflows[]. The scope determines which verbs are available:

VerbModule workflowApp workflow
on(domain events)
on(integration events)
execute(internal actions)
enqueue(internal actions)
send(public actions)
publish(integration events)

App-level workflows can only cross module boundaries via integration events + public actions (send). They cannot reach into module internals.

Examples

Translator-style — N events fan to one integration event

ts
export const onCatalogChanged = defineWorkflow("on-catalog-changed",
  ({ on, publish }) => {
    on(StationWasCreated, async (e) => {
      await publish(StationCatalogChanged_v1({
        stationId: e.stationId, change: "created", name: e.name,
        location: e.location, at: e.createdAt,
      }));
    });
    on(StationWasUpdated, async (e) => {
      await publish(StationCatalogChanged_v1({
        stationId: e.stationId, change: "updated", at: e.updatedAt,
      }));
    });
    on(StationWasDeleted, async (e) => {
      await publish(StationCatalogChanged_v1({
        stationId: e.stationId, change: "deleted", at: e.deletedAt,
      }));
    });
  },
);

Reaction-style — fire an async action on an event

ts
export const normalizeOnWash = defineWorkflow("normalize-on-wash",
  ({ on, enqueue }) => {
    on(CarWasWashed, async (event) => {
      await enqueue(normalizeTraffic({ stationId: event.stationId }));
    });
  },
);

Saga — long-running subscription renewal

See the Workflow concept page for the full subscription-renewal example.

See also

MIT licensed.