Skip to content

PATTERN Cited by 1 source

Saga rollback as step metadata

Intent

Attach compensation (rollback) logic directly to the durable step definition as configuration metadata, rather than in a separate catch block, a global error handler, or a separate builder/wrapper API. The engine owns the orchestration of when and in what order rollbacks run; the developer only declares what undo means for each step, co-located with the forward action.

Context

Workflow engines that support the saga pattern need a place to declare compensation logic. Traditional approaches include:

  • Try/catch blocks — developer manually tracks what succeeded and unwinds in reverse. Error-prone, doesn't compose with parallel steps, compensation logic sprawls as the workflow grows.
  • Separate compensation registries — a global error handler or saga coordinator that maps step names to undo functions. Decouples forward and reverse logic, making it harder to reason about what undoes what.
  • Builder/fluent APIsstep.saga("name").do(...).rollback(...).run() or step.do(...).rollback(...). Adds ceremony or creates semantic ambiguity with the step's return value.

Solution

Make rollback a metadata option on the step primitive itself:

await step.do("debit-bank-a", () => bankA.debit(from, amount), {
  rollback: async ({ output }) => bankA.credit(from, amount, output.id),
  rollbackConfig: {
    retries: { limit: 10, delay: '30 seconds', backoff: 'exponential' },
    timeout: '2 minutes',
  },
});

Properties of this shape:

  1. Co-location. Forward action and its undo live in the same call site. Add a step, add its rollback right there.
  2. Engine-managed orchestration. The engine fires rollbacks in reverse step-start order on terminal failure. Developer doesn't manage ordering.
  3. No new API surface. Extends the existing step primitive rather than introducing a new type (builder, saga coordinator).
  4. Step timing unchanged. The step starts when step.do() is called; the returned promise still represents step output. No deferred start.
  5. Opt-in per step. Steps without rollback handlers simply aren't compensated.

Relationship to prior art

  • Skipper's @Compensate annotation is the same concept at the annotation-class level — compensation co-located with the forward action. The metadata-options shape is the TypeScript/functional equivalent for engines without annotation-based class models.
  • Temporal's compensation via try/finally + activity cancellation is more general but requires explicit developer orchestration of the reverse walk.

Trade-offs

  • Simpler adoption — one options object to learn; existing step.do() calls keep working.
  • Sequential rollback only — the engine doesn't (yet) support parallel compensation, limiting throughput of the reverse walk.
  • Rollback handler failure stops the walk — if a handler exhausts retries, the Workflow errors out and remaining handlers don't run. No partial-completion model.
  • Handler must tolerate output === undefined — the failing step may have partially executed before persisting output.

Seen in

  • sources/2026-06-25-cloudflare-saga-rollbacks-for-workflows — canonical wiki instance. Cloudflare Workflows ships { rollback, rollbackConfig } as the options-based API after rejecting fluent and builder alternatives. Explicit framing: "each step ships with its own undo. add a step, add its rollback right here. no growing catch block, no manual ordering, no replay logic."
Last updated · 559 distilled / 1,651 read