The Go port of Saga Engine. Compile-time step safety via generics, PostgreSQL persistence, and a 15-minute hard limit. No Temporal cluster required.
After shipping Saga Engine for Node.js, the most common request was a Go version. Not a wrapper. A native implementation that leverages what Go actually gives you: generics, context propagation, and compile-time safety.
Saga Engine Go is that implementation. Same guarantees. Different language idioms.
What's Different from the Node.js Version
This isn't a line-by-line port. Go changes the design in meaningful ways:
| Concern | Node.js | Go |
|---|---|---|
| Type Safety | Runtime validation | Compile-time via Step[T] generics |
| Cancellation |
AbortController (optional) |
context.Context (first-class) |
| Concurrency | Single-threaded event loop | Goroutines + race detector |
| Step Results |
any with runtime checks |
Generic type parameter T
|
The Go version catches an entire class of bugs at compile time that the Node version can only catch at runtime.
1. The Core API: Generic Steps
Every step is parameterized by its return type. No interface{} casting. No runtime type assertions.
err = tx.Run(ctx, func(ctx context.Context, tx *saga.Transaction) error {
// Step 1: Reserve inventory (returns string)
reservationID, err := saga.Step(ctx, tx, "reserve-inventory", saga.StepDefinition[string]{
IdempotencyKey: "order-123-inv",
Execute: func(ctx context.Context) (string, error) {
return inventory.Reserve(ctx, sku, qty)
},
Compensate: func(ctx context.Context, id string) error {
return inventory.Release(ctx, id)
},
})
if err != nil {
return err
}
// Step 2: Charge payment (returns string)
_, err = saga.Step(ctx, tx, "charge-payment", saga.StepDefinition[string]{
IdempotencyKey: "order-123-pay",
Execute: func(ctx context.Context) (string, error) {
return gateway.Charge(ctx, amount)
},
Compensate: func(ctx context.Context, chargeID string) error {
return gateway.Refund(ctx, chargeID)
},
})
return err
})
The compensate function receives the exact type returned by execute. If Execute returns (string, error), Compensate receives string. The compiler enforces this.
2. The Context Mandate
Go's context.Context is the mechanism for timeout enforcement. The engine cancels the context when deadlines are exceeded. This only works if your functions cooperate.
// This respects cancellation
Execute: func(ctx context.Context) (string, error) {
req, _ := http.NewRequestWithContext(ctx, "POST", url, body)
resp, err := client.Do(req)
// ...
}
// This ignores cancellation (timeouts become lies)
Execute: func(ctx context.Context) (string, error) {
resp, err := http.Post(url, "application/json", body)
// ...
}
The 15-minute execution limit and per-step timeouts are enforced via context cancellation. If you pass ctx to every I/O call, it works. If you don't, the engine has no way to interrupt your function.
3. Hard Guarantees
Same as the Node.js version, enforced at the library level:
| Guarantee | Enforcement |
|---|---|
| Idempotency | Required. Returns ErrIdempotencyRequired if keys are missing at transaction or step level. |
| Durability | State committed to PostgreSQL before the next step executes. |
| Concurrency |
pg_try_advisory_lock prevents double-execution across processes. |
| Time-Boxed | 15-minute hard limit, checked before every step. |
| Visibility | Failed compensations move to dead_letter for manual audit via saga-admin CLI. |
4. The JSON Serialization Contract
On crash recovery, step results are reconstructed from PostgreSQL via json.Unmarshal. This means your result types must follow Go's JSON serialization rules:
// Works: exported fields survive round-trip
type OrderResult struct {
ID string `json:"id"`
Amount float64 `json:"amount"`
}
// Broken on resume: unexported fields are silently zeroed
type badResult struct {
id string // json.Unmarshal cannot see this
}
This is a hard requirement, not a suggestion. If your step returns a struct with unexported fields, those fields will be zero-valued after a crash recovery. The saga will continue with corrupted state.
5. Error Handling the Go Way
All errors support errors.Is() and errors.As():
if errors.Is(err, saga.ErrExecutionTimeout) {
// Workflow exceeded 15-minute limit
}
var compErr *saga.CompensationFailedError
if errors.As(err, &compErr) {
log.Printf("Compensation failed at step: %s", compErr.FailedStep)
log.Printf("Original error: %s", compErr.OriginalError)
}
Seven sentinel errors, seven corresponding error types with structured fields. Standard Go error handling, no custom error-checking patterns to learn.
6. PgBouncer Compatibility
Advisory locks are session-scoped. This matters for connection pooling:
| Connection Setup | Compatible |
|---|---|
*sql.DB (direct) |
Yes |
| PgBouncer (session mode) | Yes |
| PgBouncer (transaction mode) | No |
If you run PgBouncer in transaction mode, lock ownership is lost between queries. The engine won't warn you. Your workflows will silently lose mutual exclusion.
7. Explicit Refusals
Same philosophy as the Node.js version:
- No workflows > 15 minutes. Use Temporal for long-running processes.
-
No auto-recovery from dead letters. If compensation fails, a human investigates.
saga-admin retry <id>is intentionally manual. - No distributed transactions. Single-process, single-database. We coordinate side effects; we don't replace your DB's ACID properties.
8. CLI: saga-admin
Operational visibility without a dashboard:
# Build the CLI
go build -o saga-admin ./cmd/saga-admin
# List dead letter workflows
saga-admin -db "$DATABASE_URL" dead-letter
# Investigate a specific workflow
saga-admin -db "$DATABASE_URL" show order-123
# Retry after fixing the root cause
saga-admin -db "$DATABASE_URL" retry order-123
# View aggregate stats
saga-admin -db "$DATABASE_URL" stats
Setup
import (
"database/sql"
"os"
saga "github.com/grafikui/saga-engine-go"
_ "github.com/lib/pq"
)
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
storage, _ := saga.NewPostgresStorage(db, "transactions")
lock := saga.NewPostgresLock(db)
tx, _ := saga.NewTransaction("order-123", storage, saga.TransactionOptions{
IdempotencyKey: "order-123-v1",
Lock: lock,
Input: map[string]any{"orderId": "123"},
})
Single PostgreSQL table. No migrations framework required. The schema is in the README.
Conclusion
Saga Engine Go brings the same crash-resilient saga execution to the Go ecosystem. Type-safe generics, context-based cancellation, and a single PostgreSQL dependency.
If you're already using the Node.js version, the Go port follows the same mental model. If you're new to Saga Engine, pick whichever runtime your services are built on.
Links:
Top comments (0)