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 when(...) subscriptions and (for sagas) state onEnter bodies.
options?WorkflowOptionsdata, states, correlate, description, retry. Omit for stateless.

Closure context

ts
interface WorkflowContext {
  // Subscriptions
  readonly when: <E extends EventDefinition | typeof complete>(
    event: E,
    handler: (event: EventPayload<E>) => Promise<WorkflowState | void> | WorkflowState | void,
    opts?: WorkflowOnOptions,
  ) => 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;
}

when(Event, handler, opts?)

The one subscription verb. Context-aware:

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

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

  notified(() => {
    when(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
when(PaymentConfirmed, async () => {
  await send(activateSubscription({ subscriptionId: data.subscriptionId }));
  return paid;     // transition to `paid` (which is final → workflow ends)
});

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

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

Options

ts
when(PaymentFailed, async () => { … }, {
  in:             [notified, retrying],         // state filter
  retry:          { max: 3, backoff: "exponential" },
  idempotencyKey: (e) => `renewal-${e.subscriptionId}-${e.attempt}`,
  description:    "Retry payment, suspend after 3 attempts",
});
KeyPurpose
in: states[]Equivalent to nesting when() inside each listed state's body. Lets you scope without indentation.
retryRetry policy for the handler body (network failures, downstream errors).
idempotencyKeyPure function of the event payload for deduplication. Studio shows the deduped key.
dispatchesThe actions this handler may dispatch, declared so the scanner can draw the flow edge.
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
  when(PaymentFailed, () => retrying);
  when(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
when(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.

Registration

Workflows are registered through the App's registry; forge folds them by kind:

ts
defineRegistry({
  workflows: [autoCharge, onCatalogChanged],
});

When multiple Apps compose via appCompose(...), each App's workflows see events emitted by every other composed App in the same process — cross-App reactions Just Work. When Apps deploy as separate services, cross-service event flow uses the bus (@nwire/bus + @nwire/nats or @nwire/amqp); the workflow body doesn't change.

Examples

Translator-style — N events fan to one integration event

ts
export const onCatalogChanged = defineWorkflow("on-catalog-changed",
  ({ when, publish }) => {
    when(StationWasCreated, async (e) => {
      await publish(StationCatalogChanged_v1({
        stationId: e.stationId, change: "created", name: e.name,
        location: e.location, at: e.createdAt,
      }));
    });
    when(StationWasUpdated, async (e) => {
      await publish(StationCatalogChanged_v1({
        stationId: e.stationId, change: "updated", at: e.updatedAt,
      }));
    });
    when(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",
  ({ when, enqueue }) => {
    when(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.