All Journal Notes

The Transactional Outbox Pattern in Go: Reliable Event-Driven Architecture Without Dual-Write Anomalies

How we engineered go-app-kit to eliminate dual-write data loss across distributed microservices using PostgreSQL transactional outbox queues, Change Data Capture, and idempotent consumers.

In distributed architectures, the most pernicious failure mode is the dual-write problem.

Consider what happens when an order service completes a checkout: 1. The service writes the new order to a PostgreSQL database. 2. The service attempts to publish an order.created event to an Apache Kafka or RabbitMQ message broker.

What happens if the network cable between the application and the message broker drops right after step 1? The database commits the order, but downstream inventory and billing services never receive the event. If you reverse the order (publish to Kafka first, then commit to Postgres), a database rollback leaves an orphaned event in Kafka that charges the customer for an order that doesn't exist.

In [go-app-kit](https://github.com/Abeta-dev/go-app-kit), we solved this architectural dilemma using the Transactional Outbox Pattern.

Here is how we implemented it in Go with production-grade reliability.


1. The Outbox Table Schema

Instead of publishing directly to an external network broker during the request lifecycle, the business transaction and the outbox event are committed inside the same ACID database transaction:

sql
CREATE TABLE outbox_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type VARCHAR(64) NOT NULL,
    aggregate_id VARCHAR(64) NOT NULL,
    event_type VARCHAR(64) NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    published_at TIMESTAMPTZ,
    status VARCHAR(16) NOT NULL DEFAULT 'PENDING'
);

CREATE INDEX idx_outbox_pending ON outbox_events (created_at) 
WHERE status = 'PENDING';

2. Atomic Transaction Execution in Go

In go-app-kit, repository operations accept a transaction context. The business entity and the outbox event are persisted atomically:

go
func (s *OrderService) CreateOrder(ctx context.Context, order *Order) error {
    return s.db.WithTransaction(ctx, func(tx *sqlx.Tx) error {
        // 1. Insert order entity
        if err := s.orderRepo.InsertTx(ctx, tx, order); err != nil {
            return fmt.Errorf("failed to insert order: %w", err)
        }

        // 2. Insert outbox event in the SAME transaction
        event := OutboxEvent{
            AggregateType: "ORDER",
            AggregateID:   order.ID,
            EventType:     "OrderCreated",
            Payload:       order.ToJSON(),
            Status:        "PENDING",
        }
        if err := s.outboxRepo.InsertTx(ctx, tx, event); err != nil {
            return fmt.Errorf("failed to append outbox event: %w", err)
        }

        return nil // Both commit atomically or both roll back!
    })
}

If the database crashes, both rows are rolled back. There is zero risk of data inconsistency between state and events.


3. Change Data Capture (CDC) & The Polling Relayer

Once committed, a background publisher process reads pending outbox events and forwards them to the message broker. In go-app-kit, we provide two relay strategies: 1. PostgreSQL WAL Logical Replication: Streaming mutations directly from PostgreSQL's Write-Ahead Log using pgoutput, achieving sub-10ms event publishing latency. 2. Batch Polling with Row-Level Locking: For smaller deployments, using SELECT ... FOR UPDATE SKIP LOCKED to allow multiple publisher pods to process outbox batches concurrently without contention.

go
func (p *OutboxRelayer) PollBatch(ctx context.Context, batchSize int) ([]OutboxEvent, error) {
    query := `
        UPDATE outbox_events
        SET status = 'PROCESSING'
        WHERE id IN (
            SELECT id FROM outbox_events
            WHERE status = 'PENDING'
            ORDER BY created_at ASC
            LIMIT $1
            FOR UPDATE SKIP LOCKED
        )
        RETURNING id, aggregate_type, aggregate_id, event_type, payload;
    `
    var events []OutboxEvent
    err := p.db.SelectContext(ctx, &events, query, batchSize)
    return events, err
}

4. Idempotent Consumer Handling

Because the transactional outbox guarantees at-least-once delivery, consumers must be designed to be strictly idempotent. In go-app-kit, downstream event handlers utilize monotonic message ID deduplication tables, preventing duplicate balance deductions or ghost order fulfillment.


5. Summary

The Transactional Outbox Pattern transforms fragile distributed transactions into rock-solid, verifiable local ACID guarantees. By incorporating this pattern into [go-app-kit](https://github.com/Abeta-dev/go-app-kit), we provide Go developers with enterprise-grade resilience right out of the box.

UG

Umesh Gupta

@umesh0492

Founder & Software Architect

Founder of Abeta. Software architect focusing on high-throughput distributed systems, financial math engines, and @abeta.dev/react-libs.

View all articles by Umesh Gupta