5 min readResonate HQJust published

Durable order-lifecycle state machine in TypeScript on Resonate

How a six-state order workflow collapses to straight-line generator code when each ctx.run call is a durable checkpoint.

Resonate brand card on a dark background with a teal spectrum wave at the bottom and the post headline in white Sansation.

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 writeThe SDK handles
The generator function and the order of yield* ctx.run(...) calls — that ordering IS the state machineRecording 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