Go Distributed Transaction Outbox: 5 Core Patterns for Reliable Event-Driven Architecture
The Problem: Event-Driven Pain Points
An e-commerce order system refactored to event-driven architecture frequently experienced data inconsistency — "order created but inventory not deducted." Investigation revealed: message sending and database operations not in the same transaction causing message loss, network jitter triggering duplicate consumption, improper partition key selection causing event ordering issues, and overly long outbox polling intervals causing downstream delays. These four compounding issues turned "eventual consistency" into "occasional consistency." The Transactional Outbox pattern is the core solution, ensuring atomicity between business operations and event publishing.
Core Concepts at a Glance
| Concept | Description | Importance |
|---|---|---|
| Transactional Outbox | Write events to an Outbox table within the same business transaction, ensuring atomicity | ⭐⭐⭐⭐⭐ |
| Event-Driven | Decouple services through event notifications instead of synchronous calls | ⭐⭐⭐⭐⭐ |
| Message Reliability | Ensure messages are not lost, not duplicated, and delivered in order | ⭐⭐⭐⭐⭐ |
| Idempotent Consumption | Consumer produces the same result when processing the same message multiple times | ⭐⭐⭐⭐⭐ |
| CDC | Change Data Capture, monitoring database Binlog for real-time event publishing | ⭐⭐⭐⭐⭐ |
| Debezium | Open-source CDC platform supporting MySQL/PostgreSQL change capture | ⭐⭐⭐⭐ |
| Message Retry | Retry mechanism for failed message consumption with backoff strategy | ⭐⭐⭐⭐ |
| Event Sourcing | Using event sequences as the source of truth, supporting state rebuild and audit | ⭐⭐⭐ |
Problem Analysis: 5 Major Challenges in Transactional Outbox
1. Atomicity of Business Operations and Message Sending: The traditional approach writes to DB first then sends a message — two operations that cannot guarantee atomicity. DB write succeeds but message send fails, downstream services never receive the event; message sent first but DB write fails, creating ghost events.
2. Message Ordering Guarantee: Events for the same aggregate root must be consumed in order, but improper Kafka partition key selection or concurrent relay sending can cause reordering, leading downstream to execute business logic based on stale state.
3. Idempotent Consumption Implementation: Network retransmission, relay duplicate sending, and consumer restarts all cause duplicate consumption. Without idempotency, the same order might deduct inventory twice.
4. Outbox Polling Latency: Polling relies on periodically scanning the Outbox table — intervals too long increase latency, too short waste database resources. Under high concurrency, polling becomes a performance bottleneck.
5. CDC Configuration Complexity: Debezium requires deploying Kafka Connect, configuring Connectors, and managing Schema changes — high operational cost. Production environments must also consider Binlog format, GTID, and high availability.
Pattern 1: Outbox Table Design and Transactional Write
The Outbox table is written within the same database transaction as the business table, ensuring atomicity between business operations and event records. Event status starts as PENDING, sent asynchronously by the relay.
package outbox
import (
"context"
"database/sql"
"encoding/json"
"time"
)
type OutboxEvent struct {
ID int64 `json:"id"`
AggregateID string `json:"aggregate_id"`
EventType string `json:"event_type"`
Payload json.RawMessage `json:"payload"`
Status string `json:"status"`
Retries int `json:"retries"`
CreatedAt time.Time `json:"created_at"`
}
type OutboxRepository struct {
db *sql.DB
}
func NewOutboxRepository(db *sql.DB) *OutboxRepository {
return &OutboxRepository{db: db}
}
func (r *OutboxRepository) SaveWithTx(ctx context.Context, tx *sql.Tx, event *OutboxEvent) error {
query := `INSERT INTO outbox_events (aggregate_id, event_type, payload, status, created_at)
VALUES (?, ?, ?, 'PENDING', NOW())`
result, err := tx.ExecContext(ctx, query,
event.AggregateID, event.EventType, event.Payload)
if err != nil {
return err
}
event.ID, _ = result.LastInsertId()
return nil
}
type OrderService struct {
db *sql.DB
outbox *OutboxRepository
}
func (s *OrderService) CreateOrder(ctx context.Context, orderID, userID string, items []string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
`INSERT INTO orders (id, user_id, items, status, created_at) VALUES (?, ?, ?, 'CREATED', NOW())`,
orderID, userID, items)
if err != nil {
return err
}
payload, _ := json.Marshal(map[string]interface{}{
"order_id": orderID,
"user_id": userID,
"items": items,
"action": "order_created",
})
event := &OutboxEvent{
AggregateID: orderID,
EventType: "order.created",
Payload: payload,
}
if err := s.outbox.SaveWithTx(ctx, tx, event); err != nil {
return err
}
return tx.Commit()
}
Outbox table DDL:
CREATE TABLE outbox_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
aggregate_id VARCHAR(128) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSON NOT NULL,
status ENUM('PENDING','SENT','FAILED') DEFAULT 'PENDING',
retries INT DEFAULT 0,
created_at DATETIME(3) NOT NULL,
INDEX idx_status_created (status, created_at),
INDEX idx_aggregate_id (aggregate_id)
) ENGINE=InnoDB;
Pattern 2: Polling Relay Sender
The polling relay periodically scans Outbox events with PENDING status, publishes to Kafka, then updates status to SENT. Key point: use SELECT ... FOR UPDATE SKIP LOCKED to prevent duplicate sending across multiple instances.
package outbox
import (
"context"
"database/sql"
"fmt"
"log"
"time"
"github.com/segmentio/kafka-go"
)
type PollingRelay struct {
db *sql.DB
writer *kafka.Writer
batchSize int
interval time.Duration
}
func NewPollingRelay(db *sql.DB, kafkaAddr, topic string, batchSize int, interval time.Duration) *PollingRelay {
return &PollingRelay{
db: db,
writer: &kafka.Writer{
Addr: kafka.TCP(kafkaAddr),
Topic: topic,
Balancer: &kafka.LeastBytes{},
BatchTimeout: 10 * time.Millisecond,
},
batchSize: batchSize,
interval: interval,
}
}
func (r *PollingRelay) Start(ctx context.Context) {
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := r.pollAndPublish(ctx); err != nil {
log.Printf("polling relay error: %v", err)
}
}
}
}
func (r *PollingRelay) pollAndPublish(ctx context.Context) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx,
`SELECT id, aggregate_id, event_type, payload, retries
FROM outbox_events
WHERE status = 'PENDING' AND retries < 5
ORDER BY created_at ASC
LIMIT ? FOR UPDATE SKIP LOCKED`, r.batchSize)
if err != nil {
return err
}
defer rows.Close()
var events []OutboxEvent
for rows.Next() {
var e OutboxEvent
if err := rows.Scan(&e.ID, &e.AggregateID, &e.EventType, &e.Payload, &e.Retries); err != nil {
return err
}
events = append(events, e)
}
if len(events) == 0 {
return nil
}
var messages []kafka.Message
for _, e := range events {
messages = append(messages, kafka.Message{
Key: []byte(e.AggregateID),
Value: e.Payload,
Headers: []kafka.Header{
{Key: "event_type", Value: []byte(e.EventType)},
{Key: "event_id", Value: []byte(fmt.Sprintf("%d", e.ID))},
},
})
}
if err := r.writer.WriteMessages(ctx, messages...); err != nil {
for _, e := range events {
tx.ExecContext(ctx, `UPDATE outbox_events SET retries = retries + 1 WHERE id = ?`, e.ID)
}
return fmt.Errorf("kafka write failed: %w", err)
}
for _, e := range events {
if _, err := tx.ExecContext(ctx, `UPDATE outbox_events SET status = 'SENT' WHERE id = ?`, e.ID); err != nil {
return err
}
}
return tx.Commit()
}
Pattern 3: CDC Change Data Capture (Debezium)
CDC captures Outbox table changes in real-time by monitoring database Binlog — no polling needed, lower latency. Debezium is a production-grade CDC solution running through Kafka Connect.
Debezium MySQL Connector configuration:
{
"name": "outbox-connector",
"config": {
"connector.class": "io.debezium.connector.mysql.MySqlConnector",
"database.hostname": "mysql",
"database.port": "3306",
"database.user": "debezium",
"database.password": "dbz_pass",
"database.server.id": "184054",
"database.server.name": "outbox_server",
"database.include.list": "order_db",
"table.include.list": "order_db.outbox_events",
"database.history.kafka.bootstrap.servers": "kafka:9092",
"database.history.kafka.topic": "schema-changes",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.route.topic.replacement": "order-events",
"transforms.outbox.table.field.event.id": "id",
"transforms.outbox.table.field.event.key": "aggregate_id",
"transforms.outbox.table.field.event.type": "event_type",
"transforms.outbox.table.field.event.payload": "payload",
"transforms.outbox.table.fields.additional.placement": "status:header:eventStatus"
}
}
Go consumer integration:
package consumer
import (
"context"
"log"
"github.com/segmentio/kafka-go"
)
type OutboxEventHandler struct {
reader *kafka.Reader
}
func NewOutboxEventHandler(kafkaAddr, topic, groupID string) *OutboxEventHandler {
return &OutboxEventHandler{
reader: kafka.NewReader(kafka.ReaderConfig{
Brokers: []string{kafkaAddr},
Topic: topic,
GroupID: groupID,
MinBytes: 10e3,
MaxBytes: 10e6,
}),
}
}
func (h *OutboxEventHandler) Start(ctx context.Context) {
for {
msg, err := h.reader.ReadMessage(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
log.Printf("read message error: %v", err)
continue
}
eventType := ""
for _, hdr := range msg.Headers {
if hdr.Key == "event_type" {
eventType = string(hdr.Value)
break
}
}
log.Printf("received event: type=%s key=%s", eventType, string(msg.Key))
}
}
Pattern 4: Idempotent Consumption and Deduplication
Idempotent consumption is the safety net of event-driven architecture. Deduplication through a consumption record table ensures the same event is never processed twice.
package consumer
import (
"context"
"database/sql"
"fmt"
)
type IdempotentHandler struct {
db *sql.DB
}
func NewIdempotentHandler(db *sql.DB) *IdempotentHandler {
return &IdempotentHandler{db: db}
}
func (h *IdempotentHandler) Handle(ctx context.Context, eventID string, handler func(ctx context.Context) error) error {
tx, err := h.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var status string
err = tx.QueryRowContext(ctx,
`SELECT status FROM consume_records WHERE event_id = ? FOR UPDATE`, eventID).Scan(&status)
if err == nil {
if status == "PROCESSED" {
return nil
}
return fmt.Errorf("event %s in status %s, skip", eventID, status)
}
if err != sql.ErrNoRows {
return err
}
_, err = tx.ExecContext(ctx,
`INSERT INTO consume_records (event_id, status, created_at) VALUES (?, 'PROCESSING', NOW())`, eventID)
if err != nil {
return err
}
if err := handler(ctx); err != nil {
tx.ExecContext(ctx, `UPDATE consume_records SET status = 'FAILED' WHERE event_id = ?`, eventID)
return err
}
_, err = tx.ExecContext(ctx, `UPDATE consume_records SET status = 'PROCESSED' WHERE event_id = ?`, eventID)
if err != nil {
return err
}
return tx.Commit()
}
Consumption record table:
CREATE TABLE consume_records (
event_id VARCHAR(128) PRIMARY KEY,
status ENUM('PROCESSING','PROCESSED','FAILED') DEFAULT 'PROCESSING',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
) ENGINE=InnoDB;
Pattern 5: Production-Grade Outbox Framework (with Monitoring)
A production-grade outbox needs: health checks, metrics collection, graceful shutdown, dead letter queues, and alerting. This framework integrates all the above patterns.
package outbox
import (
"context"
"database/sql"
"log"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/segmentio/kafka-go"
)
type OutboxFramework struct {
db *sql.DB
writer *kafka.Writer
relay *PollingRelay
handler *IdempotentHandler
cancel context.CancelFunc
wg sync.WaitGroup
eventsPublished prometheus.Counter
eventsFailed prometheus.Counter
relayLatency prometheus.Histogram
}
func NewOutboxFramework(db *sql.DB, kafkaAddr, topic string) *OutboxFramework {
f := &OutboxFramework{
db: db,
writer: kafka.NewWriter(kafka.WriterConfig{
Brokers: []string{kafkaAddr},
Topic: topic,
Balancer: &kafka.LeastBytes{},
BatchTimeout: 10 * time.Millisecond,
}),
relay: NewPollingRelay(db, kafkaAddr, topic, 100, 500*time.Millisecond),
handler: NewIdempotentHandler(db),
}
f.eventsPublished = prometheus.NewCounter(prometheus.CounterOpts{
Name: "outbox_events_published_total",
Help: "Total number of outbox events published",
})
f.eventsFailed = prometheus.NewCounter(prometheus.CounterOpts{
Name: "outbox_events_failed_total",
Help: "Total number of outbox events failed",
})
f.relayLatency = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "outbox_relay_latency_seconds",
Help: "Latency from event creation to publish",
Buckets: prometheus.DefBuckets,
})
prometheus.MustRegister(f.eventsPublished, f.eventsFailed, f.relayLatency)
return f
}
func (f *OutboxFramework) Start() {
ctx, cancel := context.WithCancel(context.Background())
f.cancel = cancel
f.wg.Add(1)
go func() {
defer f.wg.Done()
f.relay.Start(ctx)
}()
f.wg.Add(1)
go func() {
defer f.wg.Done()
f.monitorPendingEvents(ctx)
}()
log.Println("outbox framework started")
}
func (f *OutboxFramework) monitorPendingEvents(ctx context.Context) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
var pending int
f.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM outbox_events WHERE status = 'PENDING'`).Scan(&pending)
if pending > 1000 {
log.Printf("ALERT: %d pending outbox events, possible relay lag", pending)
}
}
}
}
func (f *OutboxFramework) Shutdown() {
f.cancel()
f.wg.Wait()
f.writer.Close()
log.Println("outbox framework shutdown complete")
}
Pitfall Guide
❌ Write DB first then send message, two operations without transaction guarantee ✅ Use Outbox table to write events in the same transaction, ensuring atomicity
❌ Polling relay without locking, multiple instances send duplicates
✅ Use FOR UPDATE SKIP LOCKED for lock-free mutual exclusion consumption
❌ Random Kafka message keys causing event reordering ✅ Use aggregate_id as partition key to ensure ordering for the same aggregate root
❌ Consumer without idempotency, duplicate consumption causes business errors ✅ Consumption record table + idempotent Handler ensures each event is processed once
❌ Outbox table grows indefinitely, query performance degrades ✅ Periodically archive SENT events, migrate to history table after 7 days
Error Troubleshooting
| Error Symptom | Possible Cause | Solution |
|---|---|---|
| PENDING events accumulating | Relay not started or Kafka unreachable | Check relay goroutine status and Kafka connection |
| Consumer receives duplicate events | Send succeeded but status update failed | Check transaction commit logic, ensure send and status update are atomic |
| Event consumption order wrong | Partition key not using aggregate_id | Unify using aggregate root ID as Kafka message Key |
| Debezium Connector stopped | Binlog format not ROW or insufficient permissions | Confirm binlog_format=ROW, grant REPLICATION privileges |
| Idempotent table deadlock | Concurrent consumption of same event with FOR UPDATE | Use unique index + INSERT IGNORE instead of SELECT FOR UPDATE |
| Polling latency too high | Batch size too small or interval too long | Increase batch_size to 200+, shorten interval to 200ms |
| Outbox table query slowdown | Large data volume without index | Add (status, created_at) composite index, archive periodically |
| Kafka message send timeout | Kafka cluster pressure or network jitter | Increase WriteTimeout, enable retry and idempotent producer |
| Consume records table bloat | Expired records not cleaned | Periodically delete PROCESSED records older than 7 days |
| CDC delay of several minutes | Debezium snapshot.mode misconfigured | Use schema_only to avoid full snapshot, verify Binlog retention |
Advanced Optimization
1. Multi-Tenant Outbox: Add a tenant_id field to the Outbox table, relay sends by tenant shard to prevent large tenants from blocking small ones.
2. Event Compression: Use gzip compression for the Payload field — large event bodies (e.g., order details) can achieve 70% compression, reducing Kafka bandwidth and storage costs.
3. Priority Queue: Add a priority field to the Outbox table. High-priority events (payment success) are sent first, low-priority events (notifications) are deferred.
4. Write Degradation: When Kafka is unavailable, the Outbox table serves as a persistent buffer. The relay automatically degrades to local storage mode and replays when Kafka recovers.
5. Event Schema Registry: Use Confluent Schema Registry to manage event schema versions. Consumers deserialize by version, preventing schema changes from causing consumption failures.
Comparison Analysis
| Dimension | Outbox Polling | CDC (Debezium) | Transactional MQ | Saga Events |
|---|---|---|---|---|
| Latency | Medium (100ms-1s) | Low (<100ms) | Low (<50ms) | Medium |
| Implementation Complexity | Low | High | Medium | High |
| Operational Cost | Low | High (Kafka Connect) | Medium | High |
| Database Dependency | Strong (polling pressure) | Weak (Binlog monitoring) | None | Medium |
| Message Ordering | ✅ Partition key control | ✅ Binlog ordered | ✅ Transaction message ordered | ⚠️ Needs extra design |
| Idempotency Support | ⚠️ Self-implemented | ⚠️ Self-implemented | ✅ MQ built-in | ⚠️ Self-implemented |
| Use Case | Small-medium scale, quick adoption | Large scale, low-latency requirements | RocketMQ ecosystem | Long transaction orchestration |
Summary and Outlook
The Transactional Outbox is the cornerstone of event-driven architecture reliability, solving the atomicity problem between business operations and event publishing. The polling approach is simple to implement and suitable for quick adoption; the CDC approach offers lower latency for large-scale scenarios. Both require idempotent consumption to guarantee eventual consistency. Future trends include: eBPF-based database change monitoring replacing Binlog parsing, Serverless event buses simplifying Outbox relay, and AI-driven message routing and anomaly detection. Mastering these 5 core patterns enables building production-grade reliable event-driven architectures.
Recommended Online Tools
- JSON Formatter - Format Outbox event Payload
- Hash Calculator - Generate aggregate ID hash partition keys
- Curl to Code - Quickly generate Kafka API test code
- Timestamp Converter - Convert between event timestamps and readable time
Try these browser-local tools — no sign-up required →