4 min readResonate HQJust published

Multi-step food delivery saga in TypeScript on Resonate

How a six-step delivery pipeline with a no-driver compensation branch fits inside a single generator function when every yield is a Resonate durable checkpoint.

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

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 writeThe SDK handles
The generator deliverFood with sequential yield* steps and a single try/catch around assignDriverPersisting the result of every yield* ctx.run(...) as a durable promise
The six step functions (placeOrder, prepareOrder, assignDriver, pickupOrder, deliverOrder, completeOrder) and the refundOrder compensationReplaying completed steps from their persisted results instead of re-invoking them
The decision to refund and abort when assignDriver throwsApplying 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:crashcrashMidDelivery === 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