A money transfer needs the debit of the source account and the credit of the target account to either both apply or neither apply, even if the worker crashes between them. Resonate runs the workflow as a generator and records each step as a durable promise, so a replay after a crash skips the steps that already completed. This example wires that workflow behind an Express HTTP API, with the URL path acting as the external idempotency key.
The shape of the solution
export function* transferMoney(
ctx: Context,
transferId: string,
request: TransferRequest
): Generator<any, TransferResult, any> {
const { source, target, amount } = request;
const db = ctx.getDependency('db');
// ...
try {
// Deduct from source account
const sourceConfirmation = yield* ctx.run(
updateAccount,
db,
`${transferId}-source`,
source,
-amount
);
// Credit target account
const targetConfirmation = yield* ctx.run(
updateAccount,
db,
`${transferId}-target`,
target,
amount
);
// ...
return {
success: true,
transferId: transferId,
source,
target,
amount,
sourceConfirmation,
targetConfirmation,
};
} catch (error) {
// ...
return {
success: false,
transferId: transferId,
source,
target,
amount,
error: error instanceof Error ? error.message : String(error),
};
}
}
// from example-money-transfer-application-ts/src/workflow.ts:33-85The HTTP handler triggers this workflow with the path param as the execution id:
app.post('/transfer/:id', async (req, res) => {
const { id } = req.params;
// ...validation elided...
const result = await transferMoneyR.run(`transfer/${id}`, id, request);
// ...
});
// from example-money-transfer-application-ts/src/index.ts:36-61transferMoneyR is the registered handle returned by resonate.register(transferMoney) at src/index.ts:19. Two calls with the same :id hit the same execution and receive the same result — the second call returns the cached output instead of re-running the workflow.
The durable primitives in play
resonate.register(transferMoney)— turns the generator into a Resonate-managed function with a.run(id, ...args)entry point.src/index.ts:19.resonate.setDependency('db', db)— injects the SQLite handle into the runtime so the workflow doesn't capture it from closure (which would break replay-time determinism for a remote worker).src/index.ts:16.transferMoneyR.run(`transfer/${id}`, id, request)— external trigger. The first arg is the durable execution id; reusing it returns the cached result.src/index.ts:49.ctx.run(updateAccount, db, `${transferId}-source`, source, -amount)— a durable in-process function call. Each call writes a durable promise; on replay, completed calls return the recorded value without re-invoking the function.src/workflow.ts:45-51and:54-60.ctx.getDependency('db')— pulls the injected dep at workflow start.src/workflow.ts:39.
The workflow does not use ctx.sleep, ctx.detached, resonate.schedule, or ctx.promise. The README's "Test 1" walks a crash-recovery test by asking the reader to add a ctx.sleep(5000) between the two steps — that line is not in the shipped workflow.
What the SDK handles vs. what you write
What the SDK handles:
- Recording each
ctx.runinvocation, its args, and its return value as a durable promise keyed by the execution id plus a sequence number. - On a worker restart or replay, walking the recorded log: completed
ctx.runcalls return their cached values without re-invoking the underlying function. The generator runs deterministically from the top each time, so the source debit is skipped on replay if it already completed. - Caching the top-level return value against the execution id. Calling
transferMoneyR.run('transfer/tx-001', ...)a second time returns the recordedTransferResultwithout entering the generator. - Dependency resolution via
setDependency/getDependency, so the workflow body doesn't need to import or close over external handles.
What you write:
- The generator function with the business sequence (debit, then credit).
- The deterministic per-step operation id (
${transferId}-source,${transferId}-target) — used byupdateAccountto key the ledger row. This is the second idempotency layer: even if Resonate's replay didn't skip the step (for example, if the durable promise was lost), the SQLINSERT OR IGNOREon the uuid primary key would still prevent double-application. - The
INSERT OR IGNOREstatement and the uuid column on thetransferstable.src/database.ts:19-26, 49-50. - The HTTP framing — Express routes, input validation (Joi schema at
src/index.ts:23-27), and mapping the path param to a Resonate execution id.
The two layers are independent and protect against different failure modes. The SDK's execution-id cache protects against retried HTTP calls and against re-entering the generator on replay. The uuid-keyed INSERT OR IGNORE protects the ledger when the durable promise state is lost or when a fresh execution id is pointed at the same business identifier by mistake.
Failure modes covered
- Worker crashes between the debit and the credit. On restart, the workflow replays. The first
ctx.runatsrc/workflow.ts:45returns the recorded confirmation without re-invokingupdateAccount. The secondctx.runatsrc/workflow.ts:54runs and completes. Final ledger state: one debit row, one credit row. - The same HTTP request arrives twice (client retry). Both calls hit
POST /transfer/:idwith the same:id. The secondtransferMoneyR.run('transfer/${id}', ...)atsrc/index.ts:49matches the existing execution and returns the cachedTransferResultwithout entering the generator. The ledger sees no second pair of rows. - The same transfer is replayed but the durable promise was lost. Resonate would re-invoke
updateAccount. TheINSERT OR IGNOREatsrc/database.ts:50matches on the uuid primary key (${transferId}-sourceor${transferId}-target), the insert is a no-op, the function returns the same uuid. The ledger remains correct. - The credit step throws. The generator's
try/catchatsrc/workflow.ts:43, 73catches the error and returns a failure-shapedTransferResult. The HTTP handler atsrc/index.ts:51-55mapssuccess: falseto a 500. Important: there is no compensating reversal in the shipped workflow — the debit row stays in the ledger. The README acknowledges this atREADME.md:278as a deferred production consideration. The Python and Rust siblings (example-money-transfer-py,example-money-transfer-rs) do implement an explicit reversal step; if an agent is looking for the canonical saga-with-compensation shape, those are the references, not this one. - Bad input. The Joi schema at
src/index.ts:23-27, 40-43rejects the request before any Resonate work starts. The execution id is never created.
When to reach for this pattern
- If you need to expose a multi-step business operation behind an HTTP API and the same logical operation must be safe to retry from the client side.
- If you want execution-level deduplication keyed on a stable identifier from the URL (path param, header, payload field) and you can shape that identifier into a Resonate execution id.
- If your worker process can crash mid-operation and you don't want to write the recovery walker yourself.
- If your storage layer can enforce row-level idempotency (uuid primary key, unique constraint, dedup table) — the combination of Resonate's checkpoint cache and a uuid-keyed insert is what makes the saga safe across both replay and storage-state mismatches.
- If you need the saga's compensating action implemented, do not start from this example — start from
example-money-transfer-pyorexample-money-transfer-rsand translate to TypeScript, or extend this example with atry/catchthat runs a thirdctx.runfor the reversal.
Sources
- Example repo: https://github.com/resonatehq-examples/example-money-transfer-application-ts
- TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts (pinned at
^0.10.0inpackage.json) - SDK
Context#run(durable in-process call alias): https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts#L208 and https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts#L276 - SDK
Resonate#register: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts#L253 - SDK
Resonate#setDependency: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts#L538 - Sibling examples covering the saga with explicit compensation:
- Resonate docs: https://docs.resonatehq.io
