defineWorkflow
The primitive for everything event-driven that isn't an actor. Reactions, translators, and sagas — all three are now one declaration.
import { defineWorkflow } from "@nwire/forge";
defineWorkflow(name, closure, options?);| Arg | Type | Purpose |
|---|---|---|
name | string | Workflow identity, e.g. "on-wash-complete". Used by Studio + scanner. |
closure | (ctx) => void | The body. Registers on(...) subscriptions and (for sagas) state onEnter bodies. |
options? | WorkflowOptions | data, states, correlate, description, dispatches. Omit for stateless. |
Closure context
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.
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:
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
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",
});| Key | Purpose |
|---|---|
in: states[] | Equivalent to nesting on() inside each listed state's body. Lets you scope without indentation. |
retry | Retry policy for the handler body (network failures, downstream errors). |
dlq | Dead-letter target on permanent failure. |
idempotencyKey | Pure function of the event payload for deduplication. Studio shows the deduped key. |
description | Free-form description Studio displays on hover. |
State bodies (saga only)
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:
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:
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:
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:
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, nostates→ stateless. Plain async dispatch. No XState instance. No persistence. Zero ceremony, zero overhead. dataORstatesdeclared → 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:
| Verb | Module workflow | App 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
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
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
- Workflow concept — the design rationale + invariants
defineActor— the sibling primitive for entities with identitydefineAction— what workflows dispatchdefineEvent— what workflows subscribe to and publish