DEV Community

Grafikui
Grafikui Subscriber

Posted on

Saga Engine Go: Type-Safe Distributed Transactions with Zero Infrastructure

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
})
Enter fullscreen mode Exit fullscreen mode

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)
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"},
})
Enter fullscreen mode Exit fullscreen mode

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:

Star on GitHub

GitHub | pkg.go.dev

Top comments (0)