Go Event-Driven Architecture with Outbox Pattern: Reliable Transactional Messaging
Summary
- Event-Driven Architecture (EDA) is the mainstream microservice decoupling approach in 2026
- The Outbox pattern solves the dual-write consistency problem by writing business data and messages in the same transaction
- At-Least-Once delivery + consumer idempotency = production-grade message reliability
- Go + Kafka + PostgreSQL is the golden stack for EDA in 2026
- Complete solution from theory to Go implementation, including Outbox Relay and dead letter queue handling
Table of Contents
- Why Event-Driven Architecture
- The Dual-Write Problem
- Outbox Pattern Principles
- Go Outbox Relay Implementation
- Kafka Consumer and Idempotency
- Event Schema Design
- Production Deployment and Monitoring
- Interview Topics and Architecture Selection
- Summary and Further Reading
Why Event-Driven Architecture
| Issue | Synchronous Calls | Event-Driven |
|---|---|---|
| Coupling | Strong — A waits for B | Loose — A publishes event |
| Availability | Downstream failure cascades | Consumer catches up after recovery |
| Extensibility | New consumer requires caller changes | New consumer subscribes to topic |
| Peak handling | Blocking, avalanche risk | Async buffering via message queue |
Good for: order creation → inventory/logistics/points/SMS (1 write, N reads); user registration; payment completion; CDC-driven search index updates.
Not for: queries requiring immediate response; financial transfers needing strong consistency.
The Dual-Write Problem
Order service must: (1) write PostgreSQL, (2) send Kafka message.
If step 1 succeeds but step 2 fails → order created but inventory unaware → overselling.
| Solution | Consistency | Complexity | Recommendation |
|---|---|---|---|
| Outbox (local message table) | Strong | Medium | ★★★★★ |
| Distributed transaction (2PC/XA) | Strong | High | ★★ (poor performance) |
| Saga | Eventual | High | ★★★★ (long transactions) |
| Message first, then DB | Inconsistent | Low | ★ (not recommended) |
Outbox Pattern Principles
Write the message as a record in the same database transaction as business data. An independent Relay process reads the Outbox table and publishes to Kafka.
CREATE TABLE outbox_events (
id BIGSERIAL PRIMARY KEY,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ,
retry_count INT NOT NULL DEFAULT 0
);
CREATE INDEX idx_outbox_pending ON outbox_events (created_at)
WHERE status = 'pending';
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
order := &Order{ID: uuid.New().String(), UserID: req.UserID, Amount: req.Amount, Status: "created"}
_, err = tx.ExecContext(ctx,
`INSERT INTO orders (id, user_id, amount, status) VALUES ($1, $2, $3, $4)`,
order.ID, order.UserID, order.Amount, order.Status,
)
if err != nil {
return nil, err
}
eventPayload, _ := json.Marshal(map[string]interface{}{
"order_id": order.ID, "user_id": order.UserID, "amount": order.Amount, "items": req.Items,
})
_, err = tx.ExecContext(ctx,
`INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload)
VALUES ($1, $2, $3, $4)`,
"order", order.ID, "order.created", eventPayload,
)
if err != nil {
return nil, err
}
return order, tx.Commit()
}
Go Outbox Relay Implementation
Key technique: FOR UPDATE SKIP LOCKED — multiple Relay instances skip rows locked by other transactions.
func (r *OutboxRelay) processBatch(ctx context.Context) error {
rows, err := r.db.QueryContext(ctx, `
SELECT id, aggregate_type, aggregate_id, event_type, payload
FROM outbox_events
WHERE status = 'pending'
ORDER BY created_at
LIMIT $1
FOR UPDATE SKIP LOCKED
`, r.batchSize)
// ... publish to Kafka, mark published or failed
}
Partition by aggregate_id for ordering. Failed events: retry_count threshold → dead letter.
Kafka Consumer and Idempotency
At-Least-Once delivery requires idempotent consumers:
func (h *InventoryHandler) HandleOrderCreated(ctx context.Context, msg *kafka.Message) error {
eventID := getHeader(msg, "event_id")
var processed bool
h.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM processed_events WHERE event_id = $1)`, eventID,
).Scan(&processed)
if processed {
return nil
}
tx, _ := h.db.BeginTx(ctx, nil)
defer tx.Rollback()
// business logic + INSERT INTO processed_events
return tx.Commit()
}
Event Schema Design
Use CloudEvents standard format with backward-compatible schema evolution.
| Strategy | Description | Use Case |
|---|---|---|
| Backward compatible | New fields optional | Most scenarios |
| Dual-write transition | Publish v1 and v2 events | Major schema changes |
| Consumer version routing | Different consumer groups per version | Multi-team evolution |
Production Deployment and Monitoring
| Metric | Alert Threshold | Meaning |
|---|---|---|
| outbox_pending_count | > 1000 for 5min | Relay cannot keep up |
| outbox_oldest_pending_age | > 60s | Message delay too high |
| outbox_failed_count | > 0 | Publish failures |
| relay_publish_latency_p99 | > 5s | Relay performance degradation |
Interview Topics and Architecture Selection
Q1: Outbox vs CDC?
Outbox writes events at application layer with business semantics (order.created). CDC captures row-level changes (INSERT/UPDATE). Complex business → Outbox; simple data sync → CDC.
Q2: What if Relay goes down?
Pending events are not lost. Relay resumes on recovery. May cause temporary delay but no message loss.
Q3: Outbox vs Saga?
EDA for 1-write-N-read notifications. Saga for long cross-service transactions with compensation. Can combine both.
Outbox vs CDC (Debezium)
| Dimension | Outbox | CDC |
|---|---|---|
| Event semantics | Business events | Row-level changes |
| Code intrusion | Write outbox in transaction | Zero intrusion |
| Best for | Complex domain events | Simple data sync |
Saga + Outbox Combination
Orchestration-style Saga with compensation: payment fails → refund → cancel order. Each step emits Outbox events for downstream awareness.
Message Ordering
Partition by aggregate_id for per-order ordering. Consumer validates state transitions; out-of-order events trigger retry or DLQ.
CDC Evolution with Debezium
Debezium Outbox Event Router replaces custom Relay — reads outbox table from WAL, routes to Kafka by aggregate type.
Dead Letter Queue and Replay
After 5 retries, mark failed and send to DLQ. Ops replay: UPDATE outbox_events SET status='pending' WHERE id=$1.
Full-Chain Observability
Propagate traceparent in Kafka headers. Dashboard: outbox pending count, consumer lag, business consistency checks.
Hands-On: Local Outbox + Kafka
Docker Compose with PostgreSQL + Kafka. Create order → verify outbox pending → start Relay → confirm consumer processes. Fault injection: kill Relay, stop Kafka — verify business writes remain atomic.
2026 Trends
Outbox starters in Spring Boot/Go frameworks, Event Sourcing revival, serverless consumers, event-driven AI automation, CloudEvents standardization.
Summary and Further Reading
Key takeaways:
- Dual-write consistency is EDA's #1 challenge; Outbox is the optimal solution
- Business data and events must commit in the same DB transaction
- Relay uses FOR UPDATE SKIP LOCKED for multi-instance deployment
- Consumers must be idempotent: processed_events table + business unique constraints
- Event schema follows CloudEvents standard
Related reading:
References:
Try these browser-local tools — no sign-up required →