A trip booking workflow needs to either complete all three reservations (flight, hotel, car) or roll back the ones that already succeeded — the saga pattern. Resonate turns this into a generator function with try/catch, where every yield* ctx.run(...) is a durable checkpoint backed by a durable promise. This example runs end-to-end against the SDK's embedded LocalNetwork (in-memory only) — no Resonate server required to see the saga semantics; persistent crash-recovery requires running the same workflow against a real server with a stable id.
The shape of the solution
// imports + noRetry declaration omitted, see src/workflow.ts:1-16
// noRetry = { next: (attempt) => attempt === 0 ? 0 : null, encode: () => ({ type: "never", data: {} }) }
export function* bookTrip(
ctx: Context,
tripId: string,
shouldFail: boolean,
): Generator<any, BookingResult, any> {
// Note: code outside ctx.run() re-executes on replay.
// All user-visible output lives inside the service functions (which are
// wrapped in ctx.run) or in the entry point that reads the result.
let flightId: string | undefined;
let hotelId: string | undefined;
try {
// Step 1: Book flight
flightId = yield* ctx.run(bookFlight, tripId);
// Step 2: Book hotel
hotelId = yield* ctx.run(bookHotel, tripId);
// Step 3: Book car rental (this may fail)
// noRetry ensures failure propagates immediately to trigger compensation
// rather than retrying indefinitely with exponential backoff.
const carId = yield* ctx.run(
bookCarRental,
tripId,
shouldFail,
ctx.options({ retryPolicy: noRetry }),
);
return { status: "success", tripId, flightId, hotelId, carId };
} catch (error) {
const message = (error as Error).message;
const compensated: string[] = [];
// Compensate in reverse order — each compensation is also durable
if (hotelId) {
yield* ctx.run(cancelHotel, tripId, hotelId);
compensated.push("hotel");
}
if (flightId) {
yield* ctx.run(cancelFlight, tripId, flightId);
compensated.push("flight");
}
return { status: "failed", tripId, error: message, compensated };
}
}
// from example-saga-booking-ts/src/workflow.ts:42-89Setup is two lines; invocation is one call:
const resonate = new Resonate();
resonate.register(bookTrip);
// ...
const result = await resonate.run(
`saga/${tripId}`,
bookTrip,
tripId,
shouldFail,
);
// from example-saga-booking-ts/src/index.ts:8-9,23-28The durable primitives in play
new Resonate()— constructs a client. With nourland noRESONATE_URLenv var, the SDK falls through toLocalNetwork, an in-memory backend that keeps durable promises in aMap— fine for demonstrating saga semantics, but it does not survive a process crash (src/index.ts:8; SDKsrc/resonate.ts:101-171; SDKsrc/network/local.ts:139-146).resonate.register(bookTrip)— registers the generator function under its name so it can be invoked and replayed by id (src/index.ts:9; SDKsrc/resonate.ts:253-294).resonate.run(id, bookTrip, ...args)— creates a durable promise keyed by`saga/${tripId}`and drives the generator to completion. Calling it again with the same id, within a process whose backend still holds that promise, returns the existing promise (src/index.ts:23; SDKsrc/resonate.ts:296-299).yield* ctx.run(fn, ...args)— a local function call that becomes a durable checkpoint: the result ofbookFlight/bookHotel/bookCarRentalis persisted to the backend, so on replay the function is not re-invoked, the prior return value is replayed (src/workflow.ts:56,59,64; SDKsrc/context.ts:208-209).ctx.options({ retryPolicy: noRetry })— attaches per-call options, in this case overriding the default retry policy on the car rental step (src/workflow.ts:68; SDKsrc/context.ts:244).noRetry— an inline retry policy whosenext(attempt)returns0on the first attempt andnullafterward, encoded as{ type: "never", data: {} }. Behaviorally equivalent to the SDK's built-inNeverpolicy — a duck-typed object literal in this example, where the SDK ships a class (src/workflow.ts:13-16; SDKsrc/retries.ts:118-135).
What the SDK handles vs. what you write
| You write | The SDK handles |
|---|---|
The generator bookTrip with try/catch and reverse-order compensation | Persisting the result of every yield* ctx.run(...) as a durable promise in the configured backend |
The three booking calls (bookFlight, bookHotel, bookCarRental) and their inverses (cancelHotel, cancelFlight) | Replaying completed steps from their persisted results instead of re-invoking them on the same execution |
The decision that the car rental step should fail-fast instead of retrying (retryPolicy: noRetry) | Applying the default Exponential retry policy to non-generator steps, and applying noRetry only where you asked (SDK src/context.ts:384,418) |
The invocation id `saga/${tripId}` | Keying execution by id — within a process whose backend still holds the promise, a second resonate.run with the same id attaches to the existing durable promise |
The load-bearing claim: the executable code in src/workflow.ts contains no checkpoint bookkeeping, no state machine, and no compensation table. The saga semantics fall out of yield* + try/catch. (The file's leading comments do mention those terms — they describe what the SDK is doing on your behalf, not what the function body is implementing.)
Failure modes covered
This example runs against the SDK's embedded LocalNetwork, which stores durable promises in memory only — a process crash erases the backend, so the crash-recovery claims below apply when this same workflow runs against a real Resonate server with persistent storage and the caller passes a stable id (not `trip-${Date.now()}` recomputed at process start).
What this example, as shipped, demonstrates by running bun start:fail:
- Car rental throws. With
shouldFail === true,bookCarRentalraisesError("Car rental unavailable: no cars at this location"). Because the step is configured withnoRetry, the error is not retried — it propagates out ofyield* ctx.run(...)and is caught by thetry/catchinbookTrip, which then runs the compensation branch (src/services.ts:75,src/workflow.ts:64-72). - Compensation order.
cancelHotelruns beforecancelFlight— the last successful booking is the first to be reversed. Each compensation is itself ayield* ctx.run(...)so its return is also persisted to the backend (src/workflow.ts:77-85). - Selective retry behavior.
bookFlightandbookHotelare async (non-generator) functions, so they inherit the SDK's defaultExponentialretry policy; onlybookCarRentalopts intonoRetry. A transient failure on flight or hotel would be retried; a failure on car rental immediately enters compensation (SDKsrc/context.ts:384,418).
What additionally holds when the same workflow runs against a Resonate server with persistent storage and a stable id:
- Process crash between
bookFlightandbookHotel— on restart,resonate.runis called with the same`saga/${tripId}`id. The flight step's persisted result is replayed; execution resumes atbookHotel(src/workflow.ts:59). - Process crash between
bookHotelandbookCarRental— both prior steps replay from their persisted results, the car rental step runs fresh (src/workflow.ts:64). - Process crash mid-compensation, after
cancelHotelbut beforecancelFlight—cancelHotel's success is already a persisted checkpoint, so the saga replays through it and proceeds directly tocancelFlight— the hotel is not double-cancelled (src/workflow.ts:78,83). - Caller invokes
resonate.runtwice with the same id — the id keys a single durable promise; the second call attaches to the existing promise rather than starting a parallel saga. Requires a stable id from the calling system (e.g. an order id from your DB), not`trip-${Date.now()}`.
The example does not handle: a cancelHotel / cancelFlight call that itself throws (the default retry policy on those steps will retry on failure, but the example does not demonstrate a permanent compensation failure or a dead-letter path).
When to reach for this pattern
- If you need to make a multi-step booking, payment, or provisioning workflow all-or-nothing against external services that have no shared transaction.
- If your "rollback" is actually a sequence of compensating API calls (
cancelHotel,refundCharge,releaseInventory) rather than a database rollback. - If a crash mid-rollback would otherwise leave the system in an inconsistent state — and you do not want to write a separate recovery worker for partially-completed compensations (note: this property requires running against a Resonate server with persistent storage, not the embedded
LocalNetworkthis example uses by default). - If you want one specific step to fail-fast (skip retries) so that compensation fires immediately, while other steps still get the default retry behavior.
- If you want the saga to be readable as straight-line code in code review, without a separate state machine or framework DSL.
Sources
- Example repo: https://github.com/resonatehq-examples/example-saga-booking-ts
- Resonate TS SDK: https://github.com/resonatehq/resonate-sdk-ts (pinned at
^0.10.0inpackage.json) - SDK
Context(thectx.run,ctx.options,ctx.detachedsurface): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts - SDK retry policies (
Constant,Exponential,Linear,Never): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/retries.ts - SDK
Resonate.run/Resonate.register: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts - SDK
LocalNetwork(embedded in-memory backend): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/network/local.ts - Resonate docs: https://docs.resonatehq.io
