Tiny, generic, synchronous saga library for Go — for orchestrating multi-step workflows that need deterministic rollback when one step fails.
err := saga.New[*Order]().
NamedStep("reserve-stock", reserveStock, releaseStock).
NamedStep("charge-card", chargeCard, refundCard).
NamedStep("ship-order", shipOrder, nil).
Run(ctx, order)If ship-order fails, the library calls refundCard then releaseStock (reverse order) before returning. If the original ctx is already cancelled, rollback still runs against a WithoutCancel context capped by a configurable timeout.
Saga is a well-known pattern, but most existing Go libraries are heavyweight workflow engines. This is the opposite: ~150 LOC, no dependencies, no DSL, no orchestration server. You write your steps as plain Go functions and the library guarantees:
- Deterministic rollback order. Compensations run in strict reverse order of completed steps.
- Rollback survives client disconnect. The forward
ctxmay be cancelled (HTTP client gone, deadline exceeded). Rollback runs againstcontext.WithoutCancel(ctx)with its own timeout, so resources allocated mid-saga are still released. - Errors aggregate. If multiple compensations fail, you get a joined error — not just the first failure.
- Compile-time safety. Steps share a typed state value via Go generics; no
interface{}plumbing.
go get github.com/tmac33/go-sagaRequires Go 1.23+ (uses context.WithoutCancel and generics).
s := saga.New[*MyState]() // empty saga
s.Step(do, undo) // append step (auto-named)
s.NamedStep("activate-sim", doSim, undoSim) // append named step
s.WithRollbackTimeout(30 * time.Second) // cap rollback wall-time
err := s.Run(ctx, state) // executeErrors:
*saga.StepError— wraps the original error from a failed forward step.*saga.RollbackError— wraps an error from a failed compensation. Multiple are joined viaerrors.Join.
var stepErr *saga.StepError
if errors.As(err, &stepErr) {
log.Printf("saga failed at %s: %v", stepErr.Step, stepErr.Cause)
}Synchronous, not async. Sagas here are request-scoped — the caller blocks until all steps complete or rollback finishes. This is the right model for provisioning workflows where the client wants a definitive answer. For long-running, durable sagas spanning hours or days, use a workflow engine (Temporal, Cadence) — that's a different problem.
Idempotency is your job. The library makes rollback deterministic. Making releaseMSISDN safe to invoke twice is on you. In practice this means storing an outcome marker keyed by some idempotency key, but that varies per backend.
No retries. A step that needs retries should retry inside its Action. Conflating step-level retry with saga-level rollback makes both harder to reason about.
No persistence. State lives in memory for the duration of Run. If the process dies mid-saga, you have orphaned resources — same as any synchronous workflow. If you need crash recovery, use a workflow engine.
- A Temporal/Cadence replacement
- A choreography-style event-driven saga (this library is orchestration-only)
- A persistent workflow store
It's the smallest amount of code that turns "five RPC calls with manual defer rollback" into "a typed saga with predictable failure semantics."
This library is the abstraction behind a pattern I've shipped to production several times — most recently in NaaS provisioning at One New Zealand, where saga-style rollback across 5+ gRPC services governs the UFB broadband activation lifecycle, and earlier in Vodafone NZ's MVNE platform for mobile / FWA provisioning across MSISDN, OCS, Cellular, IPv4 and SIM resources. Those production systems hand-rolled the pattern with defer chains; this library packages the recurring shape.
MIT