5 min readResonate HQ

Coming from Temporal: The Saga Pattern in Go

Temporal registers compensation eagerly with defer closures that unwind LIFO. Resonate runs it inline in the error branch — same reverse-order rollback, different shape.

Resonate brand card on a dark background with an ember spectrum wave at the lower left, the headline 'Saga / Compensation' in white Sansation, and the subtitle 'Coming from Temporal · Go SDK'.

If you know Temporal's samples-go/saga — the money-transfer example with activity compensation — and you want to see what the same pattern looks like in Resonate, this is that diff. The business operation is identical in both: withdraw from a source account, deposit to a target, and if something goes wrong, reverse whatever already ran.

The pattern

A saga is a multi-step operation where each completed step has a matching compensation, so that a failure later in the chain can undo the already-settled steps and leave the system consistent. Both Temporal and Resonate can express this shape. The structural difference is where the undo logic lives: Temporal registers compensations eagerly with defer closures that fire in reverse (LIFO) order on a named-return error; Resonate keeps the undo inline in the error branch, gated on which steps actually settled. The rollback order is the same either way.

Temporal (samples-go/saga)

The Temporal saga registers a defer right after each step that succeeds. When StepWithError (always-failing, included to exercise both compensations) returns an error, Go unwinds the deferred closures in reverse: DepositCompensation first, then WithdrawCompensation. multierr.Append folds compensation errors alongside the original:

// saga/workflow.go
func TransferMoney(ctx workflow.Context, transferDetails TransferDetails) (err error) {
	options := workflow.ActivityOptions{
		StartToCloseTimeout: time.Minute,
		RetryPolicy: &temporal.RetryPolicy{
			InitialInterval:    time.Second,
			BackoffCoefficient: 2.0,
			MaximumInterval:    time.Minute,
			MaximumAttempts:    3,
		},
	}
	ctx = workflow.WithActivityOptions(ctx, options)
 
	err = workflow.ExecuteActivity(ctx, Withdraw, transferDetails).Get(ctx, nil)
	if err != nil {
		return err
	}
	defer func() {
		if err != nil {
			errComp := workflow.ExecuteActivity(ctx, WithdrawCompensation, transferDetails).Get(ctx, nil)
			err = multierr.Append(err, errComp)
		}
	}()
 
	err = workflow.ExecuteActivity(ctx, Deposit, transferDetails).Get(ctx, nil)
	if err != nil {
		return err
	}
	defer func() {
		if err != nil {
			errComp := workflow.ExecuteActivity(ctx, DepositCompensation, transferDetails).Get(ctx, nil)
			err = multierr.Append(err, errComp)
		}
	}()
 
	// StepWithError always fails, to demonstrate both compensations running.
	err = workflow.ExecuteActivity(ctx, StepWithError, transferDetails).Get(ctx, nil)
	return err
}

The activities (Withdraw, Deposit, WithdrawCompensation, DepositCompensation, StepWithError) live in saga/activity.go. Worker and starter are separate binaries that communicate through TransferMoneyTaskQueue.

Resonate (example-money-transfer-go)

Resonate has no workflow/activity split. Each step is a plain Go function made durable by ctx.Run; compensation is an ordinary ctx.Run step in the error branch. This example fails the deposit itself (via -fail) instead of appending an always-failing step, which is the realistic failure mode for a money transfer:

// from example-money-transfer-go/main.go:176
func transferMoney(ctx *resonate.Context, args TransferArgs) (TransferResult, error) {
	withdrawID := args.TransferID + "-withdraw"
	depositID  := args.TransferID + "-deposit"
	refundID   := args.TransferID + "-refund"
 
	// Step 1 — withdraw from the source.
	f1, err := ctx.Run(withdraw, AccountOp{OpID: withdrawID, Account: args.Source, Amount: args.Amount})
	if err != nil {
		return TransferResult{}, fmt.Errorf("withdraw dispatch: %w", err)
	}
	var w OpResult
	if err := f1.Await(&w); err != nil {
		// Nothing has moved yet — no compensation required.
		return TransferResult{}, fmt.Errorf("withdraw: %w", err)
	}
 
	// Step 2 — deposit. NoRetry so a rejection compensates immediately.
	f2, err := ctx.Run(deposit,
		AccountOp{OpID: depositID, Account: args.Target, Amount: args.Amount, Fail: args.FailDeposit},
		resonate.RunOpts{RetryPolicy: resonate.NoRetry},
	)
	if err != nil {
		return TransferResult{}, fmt.Errorf("deposit dispatch: %w", err)
	}
	var d OpResult
	if err := f2.Await(&d); err != nil {
		// Deposit failed after withdraw settled — undo the one completed step.
		// A longer saga tracks which steps settled and undoes them LIFO.
		fr, cerr := ctx.Run(refund, AccountOp{OpID: refundID, Account: args.Source, Amount: args.Amount})
		if cerr == nil {
			var ref OpResult
			_ = fr.Await(&ref) // best-effort: saga has already failed
		}
		return TransferResult{TransferID: args.TransferID, Status: "compensated", Error: err.Error()}, nil
	}
 
	return TransferResult{TransferID: args.TransferID, Status: "committed", // ...
		Source: args.Source, Target: args.Target, Amount: args.Amount}, nil
}

ctx.Run(fn, args) schedules fn as a durable child and returns a *Future; f.Await(&out) blocks until it settles and decodes the typed result. Step functions take *resonate.Context and return a typed result alongside the error. Only the top-level saga is registered — step functions passed to ctx.Run are invoked by value and need no registration.

The step functions themselves are short and idempotent: each one calls bank.apply(opID, ...), which short-circuits if that opID was already applied, the same role INSERT OR IGNORE plays in the SQLite-backed versions of this example:

// from example-money-transfer-go/main.go:142
func withdraw(_ *resonate.Context, op AccountOp) (OpResult, error) {
	bal := bank.apply(op.OpID, op.Account, -op.Amount, "withdraw")
	return OpResult{OpID: op.OpID, Balance: bal}, nil
}
 
// from example-money-transfer-go/main.go:149
func deposit(_ *resonate.Context, op AccountOp) (OpResult, error) {
	if op.Fail {
		return OpResult{}, fmt.Errorf("account %s rejected the deposit", op.Account)
	}
	bal := bank.apply(op.OpID, op.Account, op.Amount, "deposit")
	return OpResult{OpID: op.OpID, Balance: bal}, nil
}
 
// from example-money-transfer-go/main.go:159
func refund(_ *resonate.Context, op AccountOp) (OpResult, error) {
	bal := bank.apply(op.OpID, op.Account, op.Amount, "refund")
	return OpResult{OpID: op.OpID, Balance: bal}, nil
}

What maps to what

TemporalResonate
workflow.ExecuteActivity(ctx, Withdraw, …).Get(ctx, nil)f, err := ctx.Run(withdraw, op) + f.Await(&out)
Activity func(context.Context, T) errorstep func(*resonate.Context, T) (R, error)
defer func(){ if err != nil { …Compensation… } }()inline ctx.Run(<inverse>, …) in the error branch
WithdrawCompensation / DepositCompensation activitiesrefund step function (and one inverse per forward step)
multierr.Append(err, errCompensation)explicit per-step handling in the error branch
workflow.ActivityOptions{RetryPolicy} + WithActivityOptionsresonate.RunOpts{RetryPolicy: resonate.NoRetry} as optional 3rd arg
temporal.RetryPolicy{…}resonate.ExponentialRetry{…} / resonate.ConstantRetry{…} / resonate.NoRetry
ReferenceID threaded into each activity for idempotencydeterministic op ids derived from the saga's promise ID (<id>-withdraw, etc.)
w.RegisterWorkflow + w.RegisterActivity (task queue)resonate.Register(r, "transferMoney", transferMoney) (saga only; steps need no registration)
StartWorkflowOptions.ID (Workflow ID)promise ID string passed to transferFn.Run(ctx, id, args)

Porting it

  1. Swap imports. Replace go.temporal.io/sdk/workflow, go.temporal.io/sdk/temporal, and go.uber.org/multierr with github.com/resonatehq/resonate-sdk-go (and resonate-sdk-go/localnet if you want in-process exploration).

  2. Merge workflow + activities into one function. The Temporal saga coordinator becomes the Resonate saga function; the separate activity functions become plain step functions, each taking *resonate.Context and returning (R, error). Pass all workflow arguments as a single serialisable struct.

  3. Turn each activity call into ctx.Run + Await. workflow.ExecuteActivity(ctx, Withdraw, d).Get(ctx, nil) becomes f, err := ctx.Run(withdraw, op) followed by f.Await(&out). Drop WithActivityOptions; pass resonate.RunOpts{RetryPolicy: resonate.NoRetry} as the optional third argument only where you want to skip retries and compensate immediately.

  4. Replace the defer stack with inline guarded undo. Instead of registering a defer after each successful step, execute the compensation in the error branch, gated on what settled. For this two-step saga that is a single refund; for a longer saga, track which steps settled (a boolean per step) and run their inverses in reverse order.

  5. Make every step idempotent by op id. Derive a deterministic id per step from the saga's promise ID (<id>-withdraw, <id>-deposit, <id>-refund) and have each step short-circuit if that id was already applied. This keeps a resume after a crash from re-applying a ledger entry.

  6. Drop the task-queue and worker/starter split. Register only the saga with resonate.Register; step functions go to ctx.Run by value and need no registration. Run resonate dev to start a local server (-url=http://localhost:8001), or omit -url to use localnet mode (no external server, but state is in-memory).

  7. Decide how compensation errors surface. Temporal uses multierr.Append to fold them into the returned error. Resonate provides no built-in multierror; decide per step whether to re-raise or log best-effort. This example logs and still returns compensated, because the saga has already failed.

What's actually different

Deferred LIFO stack vs. inline guarded undo. The defer form is compact when there are many steps — register compensation right after the step, and LIFO unwind is automatic. The inline form keeps the failure response visible at exactly the point it can occur and avoids depending on a named return value being mutated by closures. A longer Resonate saga tracks which steps settled (a boolean per step) and runs their inverses in reverse, producing identical rollback order. Neither style requires framework-level machinery; both are Go control flow.

Where the failure is forced. The Temporal sample appends a StepWithError activity that always fails in order to exercise both compensations. This example fails the deposit itself with -fail, which is the realistic failure mode. The structure generalises either way: each forward step pairs with an inverse, and the error branch runs whichever inverses are needed in reverse order.

No workflow/activity distinction. Temporal differentiates a deterministic replay coordinator (workflow, workflow.Context) from arbitrary-I/O units (activities, context.Context) and registers them separately. Resonate uses one function type for both roles. The replay rule that follows: side effects outside a ctx.Run / ctx.RPC / ctx.Sleep boundary run again on every resume, so keep ledger writes inside step functions, not in the saga body.

Idempotency is yours to design. Temporal's WorkflowIDReusePolicy handles re-submission at the workflow level, and the sample threads a ReferenceID through activities for step-level dedup. Resonate gives you the promise ID for saga-level dedup; per-step idempotency is something you build — here with deterministic op ids and an applied map in the ledger.

Notes & coverage

  • No built-in multierror. The Go SDK provides no multierr equivalent. Collect compensation errors explicitly if you need them to surface alongside the original failure.
  • localnet mode is in-process. The example runs without an external server by default, convenient for exploring the API. Localnet state is ephemeral — a process crash also loses the ledger. To see actual crash recovery, point -url at a resonate dev server.
  • In-memory ledger is illustrative. The bank ledger is a process-global map, kept intentionally small. A real service would inject a database handle and use a row constraint (e.g. INSERT OR IGNORE in SQLite) as the idempotency gate per step.
  • resonate.NoRetry is deliberate. The default retry policy is exponential backoff. Passing resonate.RunOpts{RetryPolicy: resonate.NoRetry} on the deposit step means a single failure compensates immediately rather than retrying. In production you might allow a few retries first and compensate only once the target has clearly rejected the deposit.

Sources