Multi-step food delivery saga in TypeScript on Resonate

A food delivery pipeline runs six sequential steps against external services — restaurant, kitchen, dispatch, driver app — and any of them can fail. The Resonate shape is a single generator function where each step is yield* ctx.run(...), which durably records its result so the same id resumes at the first uncompleted step instead of re-issuing side effects. This example runs end-to-end with no Resonate server: the embedded in-memory network drives the workflow and demonstrates retry-in-place when deliverOrder throws on its first attempt; the same generator, pointed at a Resonate server, additionally survives process restart.

The shape of the solution

export function* deliverFood(
ctx: Context,
order: Order,
crashMidDelivery: boolean,
): Generator<any, DeliveryResult, any> {
// Step 1: Place order
const orderId = yield* ctx.run(placeOrder, order);

// Step 2: Prepare food
yield* ctx.run(prepareOrder, orderId);

// Step 3: Assign driver
let driverId: string;
try {
driverId = yield* ctx.run(assignDriver, orderId);
} catch {
// No driver available — refund and abort
yield* ctx.run(refundOrder, orderId);
return { status: "failed_no_driver", orderId, error: "No drivers available" };
}

// Step 4: Pickup
yield* ctx.run(pickupOrder, orderId, driverId);

// Step 5: Deliver
// This step retries automatically on failure (e.g. driver app crash).
// When the process restarts, Resonate replays steps 1-4 from checkpoints
// and resumes here. The food is not re-cooked. The driver is not re-assigned.
yield* ctx.run(deliverOrder, orderId, driverId, crashMidDelivery);

// Step 6: Complete
const result = yield* ctx.run(completeOrder, orderId, driverId);

return {
status: "success",
orderId: result.orderId,
driverId: result.driverId,
completedAt: result.completedAt,
};
}
// from example-food-delivery-ts/src/workflow.ts:42

The generator is invoked from two setup lines plus one durable-promise call:

const resonate = new Resonate();
resonate.register(deliverFood);

// ...

const result = await resonate.run(
`delivery/${order.id}`,
deliverFood,
order,
crashMidDelivery,
);
// from example-food-delivery-ts/src/index.ts:8 (and :37, comments elided)

The durable primitives in play

new Resonate() — constructs a client. With no url argument and no RESONATE_URL env var, the SDK uses an in-memory local network, so the example needs no server (src/index.ts:8).

resonate.register(deliverFood) — registers the generator under its function name so it can be invoked by id (src/index.ts:9).

resonate.run(id, deliverFood, ...args) — creates a durable promise keyed by `delivery/${order.id}` and drives the generator. A second call with the same id, in a context where promise state persists, attaches to the existing promise rather than starting a parallel delivery (src/index.ts:37).

yield* ctx.run(placeOrder, order) — calls placeOrder as a durable checkpoint. The return value (order.id) is persisted; on replay this step is not re-invoked, the persisted return value is yielded back (src/workflow.ts:48).

yield* ctx.run(prepareOrder, orderId) — same mechanism, no return value to persist beyond completion (src/workflow.ts:51).

yield* ctx.run(assignDriver, orderId) — checkpointed call whose thrown error would be caught by the surrounding try block, routing the workflow to the refund branch (src/workflow.ts:56).

yield* ctx.run(refundOrder, orderId) — the compensation step, itself a durable checkpoint so a crash mid-refund replays through the persisted result (src/workflow.ts:59).

yield* ctx.run(deliverOrder, orderId, driverId, crashMidDelivery) — the step that throws in crash mode. Resonate's default retry policy retries the step automatically; prior checkpoints are not re-invoked (src/workflow.ts:70).

yield* ctx.run(completeOrder, orderId, driverId) — final checkpoint, returns the structured completion record (src/workflow.ts:73).

What the SDK handles vs. what you write

You write | The SDK handles
The generator deliverFood with sequential yield* steps and a single try/catch around assignDriver | Persisting the result of every yield* ctx.run(...) as a durable promise
The six step functions (placeOrder, prepareOrder, assignDriver, pickupOrder, deliverOrder, completeOrder) and the refundOrder compensation | Replaying completed steps from their persisted results instead of re-invoking them
The decision to refund and abort when assignDriver throws | Applying the default retry policy to every ctx.run call automatically
The invocation id `delivery/${order.id}` | Deduplicating repeated calls with that id to a single durable promise
The simulated failure (throw new Error("Driver app connection lost") in deliverOrder) | Logging Runtime. Function 'deliverOrder' failed ... (retrying in 2 secs) and retrying the step in place

The load-bearing observation: src/workflow.ts is 81 lines and invokes no checkpoint API, declares no journal type, registers no state machine, declares no retry policy, and maintains no compensation table. The delivery semantics fall out of yield* ctx.run(...) plus one try/catch.

Failure modes covered

deliverOrder throws mid-delivery, then recovers in the same process (bun start:crash — crashMidDelivery === true and attempt === 1). The step raises Error("Driver app connection lost") at src/steps.ts:82. Resonate logs the retry and re-invokes only deliverOrder; the in-process deliveryAttempts counter at src/steps.ts:60 is now 2, the conditional is false, and the step completes. This is the failure mode the supplied scripts demonstrate against the embedded in-memory network.

Process crashes between any two steps (when run against a Resonate server with persistent storage). The embedded in-memory network used by new Resonate() does not survive process restart, so the supplied scripts do not exercise this path. With promise state persisted by a Resonate server, a second resonate.run with the same `delivery/${order.id} id replays completed steps from their stored return values and resumes at the first uncompleted step. The order is not re-placed, the food is not re-cooked, the driver is not re-assigned (src/workflow.ts:48-73; the README documents this guarantee at README.md:13).

Workflow handles, not exercised by the supplied scripts: no driver available. src/steps.ts:38-44 assignDriver always returns a driver-NNN id; it never throws in the example as written. The try/catch at src/workflow.ts:55-61 is structurally in place — were assignDriver to throw, refundOrder would run as a durable step and the workflow would return { status: "failed_no_driver", orderId, error: "No drivers available" }.

Workflow handles, not exercised by the supplied scripts: crash mid-compensation. Because refundOrder is itself a ctx.run checkpoint at src/workflow.ts:59, a replay after it succeeds (against a Resonate server) would return the persisted result instead of double-refunding the customer.

Workflow handles, not exercised by the supplied scripts: duplicate invocation. The id `delivery/${order.id} is constructed at src/index.ts:38. Two calls to resonate.run with the same id, in a context where promise state persists, attach to a single durable promise rather than starting a parallel delivery.

The example does not demonstrate: a refundOrder that itself throws permanently (the default retry policy applies, but no dead-letter path is shown), nor an external event pushing into a running workflow — for that, the README points to example-human-in-the-loop-ts.

When to reach for this pattern

If you're modelling a multi-step pipeline (order, kitchen, dispatch, pickup, delivery, complete) where each step calls an external service and the whole pipeline must survive transient failure (and, when run against a Resonate server, process restart) without re-issuing side effects.

If a single step can fail transiently (driver app crash, network partition) and you want the step to retry in place without rewinding prior work.

If one specific failure (no driver available) should branch into a compensating action (refund) rather than fail the entire run, and the compensation itself must be crash-safe.

If you want the entire workflow visible as straight-line code in code review, without a separate state machine, activity registry, or signal table.

If you want a single id per business object (`delivery/${orderId}`) to deduplicate retries from upstream callers automatically.

Sources

Example repo: https://github.com/resonatehq-examples/example-food-delivery-ts

Resonate TS SDK: https://github.com/resonatehq/resonate-sdk-ts (pinned at ^0.10.0 in package.json)

SDK Context surface (ctx.run, ctx.options, ctx.detached): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts

SDK Resonate.run / Resonate.register: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts

SDK retry policies (default Exponential, etc.): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/retries.ts

SDK LocalNetwork (in-memory default when no URL is resolved): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/network/local.ts

Resonate docs: https://docs.resonatehq.io