State Management Patterns in Distributed Systems: Saga, Outbox, and Event Sourcing with PostgreSQL
Introduction: The Problem of Distributed Transactions
When a monolith is broken down into microservices, transactionality is the first casualty. In the classic ACID world, a single database guarantees atomicity: all or nothing. In a distributed system, the same operation — placing an order, for example — touches the order service, payment service, warehouse service, and notification service. Each has its own database, and there is no single transaction spanning all of them.
Two-phase commit (2PC) theoretically solves this problem, but in practice creates a bottleneck: the coordinator can crash between phases, leaving the system in an indeterminate state. Moreover, 2PC locks participant resources for the entire duration of the transaction, which kills performance under high load.
The architecture community responded to this challenge with three patterns that have become the de facto standard in 2024–2026: Saga, Transactional Outbox, and Event Sourcing. In this article, we'll explore each in detail and then demonstrate a practical implementation in Go with PostgreSQL and Redis.
The Saga Pattern: Managing Long-Running Transactions
A Saga is a sequence of local transactions, each of which publishes an event or message that triggers the next step. If one step fails, the Saga executes compensating transactions for all preceding steps, rolling the system back to a consistent state.
Choreography vs. Orchestration
There are two fundamentally different approaches to implementing a Saga:
- Choreography: each service reacts to events and publishes its own. There is no central coordinator. The order service publishes
OrderCreated, the payment service subscribes to that event, reserves funds, and publishesPaymentReserved, the warehouse service subscribes toPaymentReserved, and so on. - Orchestration: a central Saga Orchestrator explicitly commands each service: "reserve payment," "allocate stock," "send notification." The orchestrator tracks the state of the entire process.
Choreography is simpler to start with — there is no single point of failure — but as the number of services grows, the dependency graph becomes difficult to debug. Orchestration provides centralized control and observability, but the orchestrator becomes a concentration point for business logic and a potential bottleneck.
Compensating Transactions
A key requirement of the Saga pattern: every local transaction must have a compensating counterpart. If the "charge payment" step succeeded but the subsequent "reserve stock" step failed, the system must execute "refund payment." Compensations must be idempotent: a repeated call must not result in a double refund.
Pros of Saga: no distributed locks, high availability, each service is independent. Cons: temporary inconsistency (eventual consistency), complexity of designing compensations, difficulty debugging in choreography.
Transactional Outbox Pattern: Guaranteed Event Delivery
Saga and Event Sourcing both rely on reliable event publication. But how do you guarantee an event will be published even if the service crashes immediately after committing a transaction? This is where the Transactional Outbox pattern comes in.
The idea is simple and elegant: instead of publishing an event directly to a message broker, the service writes the event to a dedicated outbox table within the same transaction as the business data. A separate background process (a Message Relay or Polling Worker) reads unprocessed records from the outbox and publishes them to the broker. Atomicity is guaranteed by the database: either both the business record and the outbox record are created, or neither is.
SQL Schema for the Outbox in PostgreSQL
CREATE TABLE outbox_events (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n aggregate_type VARCHAR(100) NOT NULL,\n aggregate_id VARCHAR(100) NOT NULL,\n event_type VARCHAR(100) NOT NULL,\n payload JSONB NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n published_at TIMESTAMPTZ,\n retry_count INT NOT NULL DEFAULT 0,\n status VARCHAR(20) NOT NULL DEFAULT 'PENDING'\n CHECK (status IN ('PENDING', 'PUBLISHED', 'FAILED'))\n);\n\nCREATE INDEX idx_outbox_status_created\n ON outbox_events (status, created_at)\n WHERE status = 'PENDING';\nThe partial index (WHERE status = 'PENDING') is critical for performance: the polling worker hits this index on every iteration, and without it a full table scan becomes a problem once the table reaches several million rows.
Event Sourcing: State as a Sequence of Events
Event Sourcing inverts the traditional data storage model. Instead of storing the current state of an entity (updating a row in a table), we store the sequence of events that led to that state. The current state is reconstructed by replaying all events.
For a bank account, for example, you don't store a field balance = 1500. Instead, you store events: AccountOpened(0), MoneyDeposited(2000), MoneyWithdrawn(500). The balance of 1500 is computed at read time.
Storing Events in PostgreSQL
CREATE TABLE event_store (\n id BIGSERIAL PRIMARY KEY,\n stream_id VARCHAR(200) NOT NULL,\n stream_version BIGINT NOT NULL,\n event_type VARCHAR(100) NOT NULL,\n event_data JSONB NOT NULL,\n metadata JSONB NOT NULL DEFAULT '{}',\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n UNIQUE (stream_id, stream_version)\n);\n\nCREATE INDEX idx_event_store_stream\n ON event_store (stream_id, stream_version);\nThe UNIQUE (stream_id, stream_version) constraint enforces optimistic locking: if two processes attempt to write an event with the same version for the same stream, PostgreSQL will reject one of the operations, preventing the conflict.
Snapshots: for large streams, a full replay becomes expensive. The solution is to periodically save a snapshot of the current state and replay only the events that occurred after the snapshot.
Practical Outbox Implementation in Go with PostgreSQL
Let's walk through a complete Polling Worker implementation for the Transactional Outbox in Go, using pgx for PostgreSQL access and standard concurrency patterns.
Data Structures
package outbox\n\nimport (\n "context"\n "time"\n "github.com/google/uuid"\n)\n\ntype Event struct {\n ID uuid.UUID\n AggregateType string\n AggregateID string\n EventType string\n Payload []byte\n CreatedAt time.Time\n RetryCount int\n}\n\ntype Publisher interface {\n Publish(ctx context.Context, event Event) error\n}\nPolling Worker
package outbox\n\nimport (\n "context"\n "fmt"\n "log/slog"\n "time"\n\n "github.com/jackc/pgx/v5/pgxpool"\n)\n\ntype Worker struct {\n db *pgxpool.Pool\n publisher Publisher\n batchSize int\n interval time.Duration\n}\n\nfunc NewWorker(db *pgxpool.Pool, pub Publisher) *Worker {\n return &Worker{\n db: db,\n publisher: pub,\n batchSize: 100,\n interval: 500 * time.Millisecond,\n }\n}\n\nfunc (w *Worker) Run(ctx context.Context) error {\n ticker := time.NewTicker(w.interval)\n defer ticker.Stop()\n\n for {\n select {\n case <-ctx.Done():\n return ctx.Err()\n case <-ticker.C:\n if err := w.processBatch(ctx); err != nil {\n slog.Error("outbox: batch processing failed", "error", err)\n }\n }\n }\n}\n\nfunc (w *Worker) processBatch(ctx context.Context) error {\n tx, err := w.db.Begin(ctx)\n if err != nil {\n return fmt.Errorf("begin tx: %w", err)\n }\n defer tx.Rollback(ctx)\n\n // SELECT FOR UPDATE SKIP LOCKED — the key to horizontal scaling\n rows, err := tx.Query(ctx, `\n SELECT id, aggregate_type, aggregate_id, event_type, payload, created_at, retry_count\n FROM outbox_events\n WHERE status = 'PENDING'\n ORDER BY created_at\n LIMIT $1\n FOR UPDATE SKIP LOCKED\n `, w.batchSize)\n if err != nil {\n return fmt.Errorf("query events: %w", err)\n }\n\n var events []Event\n for rows.Next() {\n var e Event\n if err := rows.Scan(\n &e.ID, &e.AggregateType, &e.AggregateID,\n &e.EventType, &e.Payload, &e.CreatedAt, &e.RetryCount,\n ); err != nil {\n return fmt.Errorf("scan event: %w", err)\n }\n events = append(events, e)\n }\n rows.Close()\n\n for _, event := range events {\n if err := w.publisher.Publish(ctx, event); err != nil {\n slog.Warn("outbox: publish failed", "event_id", event.ID, "error", err)\n _, _ = tx.Exec(ctx,\n `UPDATE outbox_events SET retry_count = retry_count + 1,\n status = CASE WHEN retry_count >= 4 THEN 'FAILED' ELSE 'PENDING' END\n WHERE id = $1`, event.ID)\n continue\n }\n _, _ = tx.Exec(ctx,\n `UPDATE outbox_events SET status = 'PUBLISHED', published_at = NOW() WHERE id = $1`,\n event.ID)\n }\n\n return tx.Commit(ctx)\n}\nThe FOR UPDATE SKIP LOCKED directive in PostgreSQL allows multiple polling worker instances to run in parallel: each will claim its own set of rows without competing with the others, enabling horizontal scaling of Outbox processing.
Writing to the Outbox Inside a Business Transaction
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) error {\n tx, err := s.db.Begin(ctx)\n if err != nil {\n return err\n }\n defer tx.Rollback(ctx)\n\n // 1. Create the order\n orderID := uuid.New()\n _, err = tx.Exec(ctx,\n `INSERT INTO orders (id, user_id, total) VALUES ($1, $2, $3)`,\n orderID, req.UserID, req.Total)\n if err != nil {\n return fmt.Errorf("insert order: %w", err)\n }\n\n // 2. In the same transaction, write the event to the Outbox\n payload, _ := json.Marshal(map[string]any{\n "order_id": orderID,\n "user_id": req.UserID,\n "total": req.Total,\n })\n _, err = tx.Exec(ctx, `\n INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload)\n VALUES ('Order', $1, 'OrderCreated', $2)\n `, orderID.String(), payload)\n if err != nil {\n return fmt.Errorf("insert outbox: %w", err)\n }\n\n return tx.Commit(ctx)\n}\nRedis Integration for Buffering and Deduplication
Redis complements the Outbox Pattern naturally in two scenarios: buffering high-frequency events and deduplication on the consumer side.
Deduplication with Redis
Even with the Outbox in place, events may be delivered to a consumer more than once (at-least-once delivery). On the consumer side, Redis provides efficient deduplication via SET NX EX:
func (c *Consumer) isProcessed(ctx context.Context, eventID string) (bool, error) {\n key := "processed_event:" + eventID\n // SET key 1 NX EX 86400 — set if not exists, TTL 24 hours\n set, err := c.redis.SetNX(ctx, key, 1, 24*time.Hour).Result()\n if err != nil {\n return false, err\n }\n // set=true means the key was just created — first time processing this event\n return !set, nil\n}\n\nfunc (c *Consumer) Handle(ctx context.Context, event Event) error {\n already, err := c.isProcessed(ctx, event.ID.String())\n if err != nil || already {\n return err // skip duplicate\n }\n return c.processEvent(ctx, event)\n}\nRedis Streams as an Intermediate Buffer
Under peak load, the polling worker may struggle to keep up with reading from PostgreSQL. Redis Streams (XADD/XREADGROUP) serve as a buffer between the polling worker and the final broker (Kafka, RabbitMQ). The worker publishes to the Redis Stream atomically, and a separate consumer group forwards events to Kafka. This reduces latency and shields Kafka from load spikes.
Monitoring and Debugging Distributed Transactions
Distributed tracing is a mandatory tool for systems using Saga and Outbox. Trace IDs must propagate through all events: stored in the metadata field of the outbox_events table and read by consumers to restore context. Use OpenTelemetry to instrument your Go services.
Key metrics for monitoring the Outbox:
- outbox_pending_count — the number of unpublished events (a critical SLO indicator);
- outbox_publish_latency_seconds — the delay between event creation and publication;
- outbox_failed_count — events in FAILED status requiring manual intervention;
- saga_compensation_total — the number of compensating transactions executed.
To diagnose stuck Sagas, add a saga_state table with a last_updated_at field and configure an alert when no updates have occurred for more than N minutes. This lets you detect stalled orchestrations before they impact users.
PostgreSQL provides powerful debugging tools: pg_stat_activity shows active locks, EXPLAIN ANALYZE reveals the query plan for the polling worker. Keep an eye on the autovacuum metric for the outbox_events table: with a high rate of row updates (PENDING → PUBLISHED), without timely vacuuming the table will bloat.
Pattern Comparison: When to Use What
Choosing between Saga, Outbox, and Event Sourcing is not an either/or decision — in practice they are often used together. However, their goals and trade-offs differ significantly.
- Saga addresses the problem of coordinating business processes that span multiple services. Use Saga wherever you have multi-step business transactions with the possibility of rollback. Prefer orchestration for complex logic and centralized monitoring; choreography for simple linear processes with few participants.
- Transactional Outbox is a universal pattern for reliable event publication. Use it whenever a service modifies state in a database and needs to notify other services. The Outbox eliminates the dual-write problem and is a building block for any event-driven architecture.
- Event Sourcing is justified when change history is a first-class business requirement: financial systems, audit trails, systems requiring temporal queries. Event Sourcing significantly increases system complexity: queries become harder, schema evolution is a challenge, and snapshots become necessary. Don't adopt it by default just because it's fashionable.
A typical production configuration for a microservice system in 2026: Saga Orchestration for business processes + Transactional Outbox for each service + Event Sourcing for aggregates with rich change history + Redis for deduplication and buffering. PostgreSQL with the pgcrypto and pg_partman extensions (partitioning the event_store table by time) is a solid foundation for all three patterns without needing specialized event stores in the early stages.
Conclusion
State management in distributed systems is one of the fundamental challenges of backend architecture. Saga, Outbox, and Event Sourcing are not silver bullets, but in the right combination they deliver data consistency, reliable event delivery, and a complete change history while preserving service independence. PostgreSQL as a reliable foundation, Go as a performant runtime, and Redis as a fast buffer form a stack capable of serving high-load systems with predictable behavior under pressure and transparent debuggability.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →