Read in other languages: Português (Brasil)
A compact, domain-agnostic SAGA orchestration engine written in Go.
Development status: blocks 1 through 4 are complete: deterministic DAG execution, isolated parallel waves, persisted retries, optimistic concurrency, renewable leases, a transactional outbox, resilience tests and hardened CI. The latest release is
v0.2.1. Follow the update roadmap and changelog.
The engine coordinates distributed transactional workflows without knowing their business rules. Typical use cases include invoices, settlements, chargebacks and onboarding flows.
Go 1.27.0 is the minimum supported version. CI continuously verifies the latest Go 1.27 patch release.
- validated DAG-based execution;
- stable topological ordering;
- isolated execution of parallel stages;
- deterministic payload merge;
- context cancellation and optional stage timeouts;
- reverse-order compensation, optionally including the stage that failed;
- idempotent saga start through a caller-supplied saga ID;
- persisted execution history;
- pluggable repository, retry policy and event bus contracts.
It is an orchestrator, not a BPMN engine, scheduler, cron system, message broker or domain framework.
| Capability | Status |
|---|---|
| DAG validation and stable topological order | Implemented |
| Isolated parallel execution and deterministic merge | Implemented |
| Reverse-order compensation after wave shutdown | Implemented |
| Deep-copy in-memory repository | Implemented |
| Durable retry limits, backoff, jitter and maximum delay | Implemented |
| Renewable lease and optimistic concurrency | Implemented |
| Transactional outbox and stalled-saga recovery | Implemented |
| Resilience suite and CI race gate | Implemented |
Event delivery is at-least-once. Consumers must deduplicate by EventID, and
stages remain responsible for making their external effects idempotent.
state.State is the persisted source of truth:
type State struct {
SagaID string
Name string
Status Status
CurrentStage string
Version uint64
Payload map[string]any
ExecutedStages map[string]bool
CompletedStages []string
Attempts map[string]int
History []HistoryEntry
Retry *RetryInfo
Error *ErrorInfo
CreatedAt time.Time
UpdatedAt time.Time
}Payloadis the business data exchanged by stages.ExecutedStagestracks stages confirmed by the engine.CompletedStagesdefines deterministic reverse compensation order.AttemptsandRetrypersist retry scheduling per stage.Versionis used by repository compare-and-swap saves.Historyrecords execution, failure and compensation events.
type Stage interface {
Name() string
Execute(ctx context.Context, saga *state.State) error
Compensate(ctx context.Context, saga *state.State) error
}
type TimedStage interface {
Stage
Timeout() time.Duration
}
type FailureCompensator interface {
Stage
CompensateOnFailure() bool
}Execute and Compensate must be idempotent with respect to their external
effects. In a parallel wave, every stage receives an isolated state copy. The
engine merges only payload changes back into its state; changes to lifecycle
metadata are ignored.
Payloads should be acyclic and use maps, slices, pointers or immutable values. Opaque values with private mutable state are copied by value and must be treated as immutable by parallel stages.
Parallel stages may change different payload keys or write the same value to a
shared key. Different writes to the same key produce ErrPayloadConflict and
fail the saga rather than creating a nondeterministic result.
type Node struct {
Stage stage.Stage
DependsOn []string
Parallel bool
}Construction rejects:
- an empty graph;
- nil stages or empty stage names;
- duplicate stage names;
- unknown or repeated dependencies;
- dependency cycles.
Ready nodes follow a stable topological order derived from declaration order.
A non-parallel node runs alone. Consecutive ready nodes marked Parallel run
as one wave.
The constructor validates configuration and the complete DAG:
eng, err := engine.New(
"invoice-saga",
repo,
bus,
retryPolicy,
logger,
nodes...,
)
if err != nil {
return err
}
saga, err := eng.Start(ctx, map[string]any{"invoice_id": "INV-2026-01"})
if err != nil {
return err
}
if err := eng.HandleNext(ctx, saga.SagaID); err != nil {
return err
}Use StartWithID to make starts idempotent. The saga ID acts as the
idempotency key: starting the same ID again returns the existing saga without
creating a new one or changing it.
saga, err := eng.StartWithID(ctx, "onboarding-customer-42", payload)New now returns (*engine.Engine, error) and HandleNext returns operational
or terminal execution errors. Existing users should follow the
v0.2.0 migration guide.
For every execution wave, the engine:
- finds ready nodes in stable topological order;
- records
EXECUTINGin that same order; - gives each stage an isolated state copy;
- cancels the wave context when a stage fails;
- waits for every started stage to return;
- commits successful stages and merges their payload changes deterministically;
- persists the resulting state before continuing.
A parallel stage that finishes successfully before a peer fails is confirmed and included in compensation. This avoids leaving a successful external effect untracked.
type Policy interface {
ShouldRetry(err error, saga *state.State) bool
NextDelay(attempt int) time.Duration
}On failure, the engine increments the stage attempt before consulting the
policy. The exponential policy enforces MaxAttempts, doubles from BaseDelay,
caps at MaxDelay and optionally applies jitter. The resulting RetryInfo and
retry outbox event are committed atomically before publication.
Retry events carry Attempt and NotBefore. Consumers must not invoke the
engine before NotBefore; an early call returns ErrRetryNotReady without
executing a stage.
HandleNext claims a renewable lease for its SagaID. Another worker receives
repository.ErrLeaseHeld and cannot start stages for that saga. Every save uses
the state Version as a compare-and-swap condition, producing
repository.ErrVersionConflict for stale state.
RecoverStalled(ctx, updatedBefore, limit) creates durable Next events for
RUNNING sagas whose lease expired and whose state was not updated after the
cutoff. Stages still need idempotent external effects because a process can
crash after an external side effect and before saving its result.
Applications should call RecoverStalled periodically from a single scheduled
maintenance job; it is intentionally not an internal goroutine.
On a terminal failure:
- all stages in the current wave are allowed to stop first;
- every confirmed stage is compensated in reverse
CompletedStagesorder; - a stage that implements
stage.FailureCompensatorand returnstruefromCompensateOnFailure()is compensated first when its ownExecutefails, because it may have left partial effects (itsCompensatemust be idempotent and safe to call on a stage that never completed); - compensation remains best-effort;
- a successful compensation records
COMPENSATED; - a failed compensation records
COMPENSATION_FAILEDand its error is returned; - the saga ends as
FAILEDand emitsevent.KindDeadLetter.
Go cancellation is cooperative, not preemptive. Once one stage fails, every
other running stage receives ctx.Done(), but it may still finish. No new wave
starts after the failure.
Stages should regularly observe the context and avoid starting irreversible work after it is canceled.
Events are persisted as event.Envelope values containing EventID, Kind,
SagaID, stage, attempt and scheduling timestamps. The engine emits:
event.KindNextwhen a saga is created;event.KindRetrywhen a failure is accepted by the retry policy;event.KindDeadLetterafter a terminal failure is persisted.
type EventBus interface {
Publish(ctx context.Context, envelope event.Envelope) error
}State changes and new envelopes are committed atomically by the repository.
DispatchPending publishes the outbox using a context-aware EventBus and only
then marks events as published. A crash between those operations can duplicate
delivery, so consumers must deduplicate by EventID.
RabbitMQ, Kafka, NATS or an in-memory implementation can provide the small
EventBus contract.
examples/simpledemonstrates the basic engine API and a retryable stage.examples/idempotent-startshowsStartWithIDpreventing a duplicated request from creating a second saga.examples/order-fulfillmentmodels order validation, parallel fraud and inventory checks, payment, shipping retry, customer notification and reverse compensation after payment rejection.
The current block is verified with:
go test ./...
go test -race -count=20 ./...
go vet ./...
golangci-lint run ./...
govulncheck ./...
Tests cover invalid graphs, stable ordering, isolated payload merging, conflicting writes, reverse compensation, repository copies, retry limits and timing, outbox recovery, leases, version conflicts, timeout, cancellation, persisted-wave restarts and stalled-saga scheduling. CI repeats the suite under the race detector, and the DAG validator also has a fuzz target.
The project follows Semantic Versioning. Before v1.0.0, incompatible public
API changes increment the minor version. Starting with v1.0.0, incompatible
changes increment the major version; compatible features increment the minor
version and fixes increment the patch version. Releases and upgrade notes are
recorded in the changelog.
Distributed under the MIT License. See LICENSE.
2026, Thiago Zilli Sarmento ❤️