An order needs to move through an enforced sequence of states — created → confirmed → shipped → delivered, or branch to cancelled → refunded — and the process has to resume from the last completed transition if the worker crashes mid-flight, without re-running anything that already succeeded. On Resonate, the workflow is a single generator function and each state transition is a yield* ctx.run(...) call; the SDK records the result of each completed call against a stable workflow ID, so on replay the generator advances past already-completed transitions by returning their cached results and resumes at the first uncompleted one. This example (example-state-machine-ts, @resonatehq/sdk ^0.10.0) implements the order entity in ~50 lines of state-machine logic with no separate status store and no manually written retry code.
The shape of the solution
export function* orderLifecycle(
ctx: Context,
orderId: string,
path: "deliver" | "cancel" | "crash",
): Generator<any, OrderResult, any> {
const history: Transition[] = [];
// CREATED
history.push(yield* ctx.run(transitionTo, orderId, null, "created"));
// CONFIRMED
history.push(
yield* ctx.run(transitionTo, orderId, "created", "confirmed"),
);
if (path === "cancel") {
history.push(
yield* ctx.run(transitionTo, orderId, "confirmed", "cancelled"),
);
history.push(
yield* ctx.run(transitionTo, orderId, "cancelled", "refunded"),
);
return { orderId, finalState: "refunded", history };
}
// SHIPPED (first attempt throws when path === "crash")
history.push(
yield* ctx.run(
transitionTo,
orderId,
"confirmed",
"shipped",
path === "crash",
),
);
// DELIVERED
history.push(
yield* ctx.run(transitionTo, orderId, "shipped", "delivered"),
);
return { orderId, finalState: "delivered", history };
}
// from example-state-machine-ts/src/workflow.ts:34The workflow is invoked from src/index.ts:37 as resonate.run(`order/${orderId}`, orderLifecycle, orderId, path). The string order/${orderId} is the workflow ID — it is what Resonate uses to dedupe and to find prior checkpoints on replay.
The durable primitives in play
new Resonate()— embedded-mode SDK handle. No external server, no transport config.src/index.ts:8.resonate.register(orderLifecycle)— registers the generator as an addressable workflow function.src/index.ts:9.resonate.run(id, fn, ...args)— starts (or attaches to) the workflow keyed byid. Returns the workflow's final value. The IDorder/${orderId}is the idempotency key for the whole lifecycle.src/index.ts:37-42.ctx.run(fn, ...args)— runsfnas a durable child step. On success, the result is persisted against the workflow's promise store; on replay, the call returns the persisted result without re-executingfn. On thrown error, Resonate retries the call. Used six times inworkflow.ts— one per state transition — atsrc/workflow.ts:43, 48, 55, 61, 73, 85.- Generator
yield*delegation — the mechanism by whichctx.runparticipates in the workflow's execution position. The generator only advances past ayield*once that step's promise is resolved.src/workflow.ts:43-85.
There is no ctx.sleep, no ctx.detached, no scheduled invocation, no signal, no RFI. The example deliberately uses the smallest possible primitive set: register a generator, run it under a stable ID, wrap each step in ctx.run.
What the SDK handles vs. what you write
| You write | The SDK handles |
|---|---|
The generator function and the order of yield* ctx.run(...) calls — that ordering IS the state machine | Recording each completed ctx.run result against the workflow ID's promise store |
The transition function transitionTo (just an async function — no retry logic, no state lookup) | Returning cached results for completed ctx.run calls on replay so the workflow resumes at the first uncompleted step |
A workflow ID per order (order/${orderId}) | Deduping concurrent invocations of the same ID to the same in-flight (or completed) workflow |
The throw that signals a transient failure (src/transitions.ts:67-69) | Catching that throw, persisting the failure, scheduling a retry, and re-invoking ctx.run on the same step |
Branching for the cancellation path with a plain if (src/workflow.ts:51) | Persisting which branch was taken so replay follows the same path |
The example contains zero lines of retry logic, zero lines of "what state am I in" lookup, and zero references to an external status store. Every one of those concerns is absorbed by ctx.run plus the workflow ID.
Failure modes covered
- Worker crashes between two completed transitions. The completed
ctx.runcalls have already persisted their results to the promise store keyed by the workflow ID. On restart,resonate.run(`order/${orderId}`, ...)reattaches to that workflow ID and the generator replays — but eachyield* ctx.run(...)above the crash point immediately returns its cached result rather than callingtransitionToagain. Execution resumes at the first uncompletedyield*. Logging-wise, this is why the README's crash demo (README.md:113-124) showscreatedandconfirmedeach printed exactly once. - A transition step throws (transient carrier API failure). In crash mode,
transitionTothrows on its first attempt for theshippedstep (src/transitions.ts:63-69). The example has notry/catchand no retry policy. The SDK catches the throw, retries the samectx.runinvocation, and on the second attemptattempt > 1so the throw branch is skipped and the transition succeeds. The cached results forcreatedandconfirmedare NOT re-executed during this retry — only the failing step is. - Two callers invoke the workflow with the same
orderIdconcurrently. Bothresonate.run("order/<id>", ...)calls resolve to the same workflow and receive the same result. This is workflow idempotency at the ID level — useful when, for example, a retried HTTP request would otherwise trigger a duplicate lifecycle. It is NOT per-key mutual exclusion across distinct workflows; for that, see the distributed-mutex pattern referenced in the repo README. - The cancellation branch must not double-fire transitions after a partial crash. Because the branch is taken inside the generator and each branch step is its own
ctx.run, the same caching rules apply: a crash aftercancelledand beforerefundedresumes atrefundedonly.
What this example deliberately does NOT cover: invalid transitions. There is no "reject shipping when status=cancelled" code, because there is no path through the generator that can express that transition — the structure makes it unreachable.
When to reach for this pattern
- If you have an entity with a small, finite set of states and a transition graph that can be expressed as a sequence (with at most a few branch points), and you want the state and the code to be the same artifact rather than two artifacts kept in sync.
- If you currently store entity status in a row plus a worker that polls and dispatches, and you'd rather have the workflow itself be the system of record for "where am I in the lifecycle".
- If your transitions involve external side effects (carrier API, payment processor) that can fail transiently, and you want retries and crash-resume without writing them.
- If multiple callers can race to advance the same entity and you want all of them to see the same eventual result, not duplicated side effects (workflow idempotency by ID).
- If you do NOT need true concurrent-mutation exclusivity across overlapping workflows on the same key — that is a different pattern (distributed mutex) and this example is explicit about it (
README.md:147-149).
Sources
- Example repo: https://github.com/resonatehq-examples/example-state-machine-ts
- Resonate TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts
- Distributed mutex example (referenced for the concurrency caveat): https://github.com/resonatehq-examples/example-distributed-mutex-ts
- Resonate documentation: https://docs.resonatehq.io
- SDK pin verified at
example-state-machine-ts/package.json:12—@resonatehq/sdk^0.10.0 - Primary source files:
src/workflow.ts(generator state machine),src/transitions.ts(transitionTostep + crash simulation),src/index.ts(Resonate instance, register, run)
