Durable order-lifecycle state machine in TypeScript on Resonate

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

The 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 by id. Returns the workflow's final value. The ID order/${orderId} is the idempotency key for the whole lifecycle. src/index.ts:37-42.

ctx.run(fn, ...args) — runs fn as 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-executing fn. On thrown error, Resonate retries the call. Used six times in workflow.ts — one per state transition — at src/workflow.ts:43, 48, 55, 61, 73, 85.

Generator yield* delegation — the mechanism by which ctx.run participates in the workflow's execution position. The generator only advances past a yield* 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.run calls 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 each yield* ctx.run(...) above the crash point immediately returns its cached result rather than calling transitionTo again. Execution resumes at the first uncompleted yield*. Logging-wise, this is why the README's crash demo (README.md:113-124) shows created and confirmed each printed exactly once.

A transition step throws (transient carrier API failure). In crash mode, transitionTo throws on its first attempt for the shipped step (src/transitions.ts:63-69). The example has no try/catch and no retry policy. The SDK catches the throw, retries the same ctx.run invocation, and on the second attempt attempt > 1 so the throw branch is skipped and the transition succeeds. The cached results for created and confirmed are NOT re-executed during this retry — only the failing step is.

Two callers invoke the workflow with the same orderId concurrently. Both resonate.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 after cancelled and before refunded resumes at refunded only.

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 (transitionTo step + crash simulation), src/index.ts (Resonate instance, register, run)