I was on call at 02:13 am when a customer support ticket hit the queue: *“I was billed twice for the same month, and the refund never came.”* The root cause? Our billing service wrote the invoice to Postgres, then fire‑and‑forgot a Kafka produce call. The pod crashed right after the DB commit, so the event never left the box. In the morning we spent three half‑days hunting logs, replaying DB rows by hand, and apologising. The fix? An **Outbox Pattern** that lives inside the same transaction as the invoice. The moment we wired it up, duplicate‑charge incidents dropped from “daily” to “once a quarter.”
- Persist outbound billing events in the same DB transaction that writes the invoice.
- Run a separate, idempotent publisher (poller or CDC) to push events to Kafka/Redis Streams.
- Use idempotency keys and dead‑letter queues to survive retries and poison pills.
- Leverage KEDA, Helm, and GitOps to auto‑scale and version‑control the relay.
- Observe lag, back‑pressure, and schema evolution with OpenTelemetry + Prometheus.
Before you start: Kubernetes 1.30+, Go 1.23+, PostgreSQL 16 (logical replication), Debezium 2.7 (optional), Apache Kafka 3.5, Redis 7, KEDA 2.8, Helm 3.14, OpenTelemetry 1.12, Prometheus 2.53, Grafana 10.2.
How the Outbox Pattern Guarantees Reliable Billing Events in Kubernetes
The Outbox Pattern in Kubernetes reliably publishes billing events by saving them to a database table within the same transaction as business data. A separate, resilient process (a publisher) then reads these records and publishes them to a message broker, ensuring events are never lost even if the service crashes post‑transaction.
Why Reliable Billing Event Publishing Matters in Microservices
The Problem of Lost Transactions
In a naïve two‑step flow—*write to DB → produce to Kafka*—a crash between the two steps silently drops the event. That translates to a missing charge, a missed payment, or an over‑charge that the downstream accounting system never sees.
Business & Compliance Risks
Financial regulators in 2026 require **audit‑ready trails** for every charge. Missing events can trigger fines, revocation of merchant licences, and endless manual reconciliation. A single lost invoice can cost a SaaS company six‑figure penalties.
The Single Source of Truth Challenge
When the invoice lives in Postgres but the charge lives in a Kafka topic, you have two sources of truth. Keeping them in sync without a pattern leads to the classic *dual‑write problem*—the number one cause of eventual‑consistency bugs (Datadog, 2025).
Architectural Overview: The Outbox Pattern (2026 Perspective)
Traditional 2‑Phase Commit vs. The Outbox
2PC tries to lock both DB and broker, but it’s heavyweight and rarely supported by Kafka. The Outbox sidesteps coordination by persisting the outbound message *inside* the DB transaction. The commit guarantees both rows are durable; the publisher later plays catch‑up.
Decomposing the Pattern: Poller vs. Transactional Log Tailing
| Approach | Latency | DB Load | Ops Overhead | When to Choose |
|---|---|---|---|---|
| **Polling Publisher** | 5‑10 s (configurable) | Moderate (SELECT … WHERE processed = false) | Simple Helm chart, no extra infra | Low‑volume billing, quick MVP |
| **CDC (Debezium) + Kafka Connect** | < 1 s | Minimal (replication slots) | Manage Kafka/ZK, Debezium connectors | High‑throughput, sub‑second SLA |
The poller runs as a sidecar or separate Deployment, scanning the `outbox_events` table for rows where `published = false`. CDC tails the WAL, turning each INSERT into a change event instantly. Both approaches need a *reliable* consumer on the broker side.
2026 Enforcement: The Rise of SDKs and Operators
Platform teams now ship an **Outbox Operator** that watches CRDs like `OutboxRelay` and injects KEDA scalers, Prometheus ServiceMonitors, and OPA policies automatically. The official Go SDK (`github.com/nilesh/outbox/v2`) abstracts transaction helpers and idempotency key generation, so you stop rolling your own boilerplate.
Hands‑On Implementation in Modern Kubernetes (2026 Tooling)
Below are two production‑grade stacks you can copy‑paste into your repo. Pick the one that matches your team’s expertise.
Option 1: Go + Debezium CDC & Kafka (Cloud‑Native)
- **Schema** – Add an outbox table to your existing `invoices` schema.
-- version: PostgreSQL 16
CREATE TABLE outbox_events (
id BIGSERIAL PRIMARY KEY,
aggregate_id UUID NOT NULL, -- invoice_id
event_type TEXT NOT NULL, -- e.g. "InvoiceCreated"
payload JSONB NOT NULL, -- CloudEvents body
created_at TIMESTAMPTZ DEFAULT now(),
published BOOLEAN DEFAULT FALSE,
retry_count SMALLINT DEFAULT 0,
CONSTRAINT uniq_event UNIQUE (aggregate_id, event_type, created_at)
);
- **Transactional Helper** – Wrap the invoice insert and outbox insert in one function.
// go 1.23
package billing
import (
"context"
"database/sql"
"encoding/json"
"log"
_ "github.com/jackc/pgx/v5/stdlib"
)
type Invoice struct {
ID string
Amount int64
// …
}
type CloudEvent struct {
SpecVersion string `json:"specversion"`
ID string `json:"id"`
Source string `json:"source"`
Type string `json:"type"`
Time string `json:"time"`
Data json.RawMessage `json:"data"`
}
func CreateInvoice(ctx context.Context, db *sql.DB, inv Invoice) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
// Rollback if not already committed
_ = tx.Rollback()
}()
// 1️⃣ Insert invoice
_, err = tx.ExecContext(ctx,
`INSERT INTO invoices (id, amount) VALUES ($1, $2)`,
inv.ID, inv.Amount)
if err != nil {
return err
}
// 2️⃣ Build CloudEvent payload
payload, err := json.Marshal(inv)
if err != nil {
return err
}
event := CloudEvent{
SpecVersion: "1.0",
ID: inv.ID, // using invoice ID as idempotency key
Source: "/svc/billing",
Type: "InvoiceCreated",
Time: time.Now().UTC().Format(time.RFC3339),
Data: payload,
}
eventJSON, err := json.Marshal(event)
if err != nil {
return err
}
// 3️⃣ Insert outbox record
_, err = tx.ExecContext(ctx,
`INSERT INTO outbox_events (aggregate_id, event_type, payload)
VALUES ($1, $2, $3)`,
inv.ID, "InvoiceCreated", eventJSON)
if err != nil {
return err
}
// 4️⃣ Commit both rows atomically
if err = tx.Commit(); err != nil {
return err
}
log.Printf("invoice %s and outbox persisted", inv.ID)
return nil
}
- **Debezium Connector** – Deploy a `PostgresConnector` via Helm. The connector writes change events to Kafka topic `outbox.events`.
apiVersion: kafka.strimzi.io/v1beta2
kind: KafkaConnector
metadata:
name: outbox-pg-connector
spec:
class: io.debezium.connector.postgresql.PostgresConnector
tasksMax: 2
config:
database.hostname: pg-billing
database.port: "5432"
database.user: debezium
database.password: ${DB_PASSWORD}
database.dbname: billing
schema.include.list: public
table.include.list: public.outbox_events
publication.autocreate.mode: filtered
snapshot.mode: never
transforms: unwrap
transforms.unwrap.type: io.debezium.transforms.ExtractNewRecordState
transforms.unwrap.drop.tombstones: "false"
topic.prefix: outbox.
- **Publisher Service** – A tiny Go consumer that reads the `outbox.events` topic, marks rows as published, and handles retries.
// go 1.23
package publisher
import (
"context"
"database/sql"
"log"
"time"
"github.com/segmentio/kafka-go"
)
func RunRelay(ctx context.Context, db *sql.DB, broker string) error {
r := kafka.NewReader(kafka.ReaderConfig{
Brokers: []string{broker},
GroupID: "outbox-relay",
Topic: "outbox.events",
MinBytes: 10e3,
MaxBytes: 10e6,
})
defer r.Close()
for {
m, err := r.FetchMessage(ctx)
if err != nil {
if err == context.Canceled {
return nil
}
log.Printf("fetch error: %v", err)
continue
}
// Decode payload (assume CloudEvent JSON)
var event struct {
ID string `json:"id"`
AggregateID string `json:"aggregate_id"`
}
if err := json.Unmarshal(m.Value, &event); err != nil {
log.Printf("bad payload %s: %v", string(m.Value), err)
// send to DLQ (see later)
continue
}
// Mark as published (idempotent UPDATE)
_, err = db.ExecContext(ctx,
`UPDATE outbox_events SET published = TRUE, retry_count = 0
WHERE id = $1 AND published = FALSE`,
event.ID)
if err != nil {
log.Printf("db update failed for %s: %v", event.ID, err)
// retry later; Kafka will redeliver
continue
}
if err := r.CommitMessages(ctx, m); err != nil {
log.Printf("commit failed: %v", err)
}
}
}
- **Deploy with Helm & KEDA** – The Helm chart bundles the Go publisher deployment, KEDA ScaledObject, ServiceMonitor, and OPA policy.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: outbox-relay-scaler
spec:
scaleTargetRef:
name: outbox-relay
pollingInterval: 5
cooldownPeriod: 30
triggers:
- type: kafka
metadata:
bootstrapServers: kafka:9092
topic: outbox.events
lagThreshold: "500"
**Tip:** Keep the `lagThreshold` low (≈ 500) for billing – you can’t afford a backlog of charges.
Option 2: Postgres LISTEN/NOTIFY + Redis Streams (Simpler Stack)
If your team prefers a single‑process approach without Debezium, you can push events via `LISTEN/NOTIFY` and have a lightweight Go consumer pump them into **Redis Streams**.
- **Trigger Function** – Fires on each `INSERT` into `outbox_events`.
CREATE OR REPLACE FUNCTION notify_outbox()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
PERFORM pg_notify(
'outbox_channel',
json_build_object(
'id', NEW.id,
'payload', NEW.payload
)::text);
RETURN NEW;
END;
$$;
CREATE TRIGGER outbox_notify
AFTER INSERT ON outbox_events
FOR EACH ROW EXECUTE FUNCTION notify_outbox();
- **Go Listener** – Subscribes via `pgx` and writes to Redis Streams.
// go 1.23
package listener
import (
"context"
"log"
"github.com/jackc/pgx/v5"
"github.com/redis/go-redis/v9"
)
func RunListener(ctx context.Context, pgConn *pgx.Conn, rdb *redis.Client) error {
_, err := pgConn.Exec(ctx, "LISTEN outbox_channel")
if err != nil {
return err
}
log.Println("listening on outbox_channel")
for {
notif, err := pgConn.WaitForNotification(ctx)
if err != nil {
if ctx.Err() != nil {
return nil // graceful shutdown
}
log.Printf("notification error: %v", err)
continue
}
// Write to Redis Stream with idempotent key as message ID
msgID := notif.Payload // contains JSON with id/payload
// Add as XADD <stream> * <field> <value>
_, err = rdb.XAdd(ctx, &redis.XAddArgs{
Stream: "billing.events",
Values: map[string]interface{}{
"msg": msgID,
},
}).Result()
if err != nil {
log.Printf("redis XAdd failed: %v", err)
// Optionally push to a DLQ list
}
}
}
- **Consumer** – A separate Go service (or Benthos pipeline) reads from `billing.events`, acknowledges, and marks the DB row as published.
# benthos config (benthos.yaml)
input:
redis_streams:
urls: [ "redis://redis:6379" ]
streams:
- name: billing.events
output:
kafka:
brokers: [ "kafka:9092" ]
topic: billing.events
max_in_flight: 10
**Warning:** `LISTEN/NOTIFY` is best for < 10 k events/sec. Beyond that, the PostgreSQL backend can become a bottleneck.
Critical Production Gotchas & Real Error Handling Strategies
Idempotency Keys for Retry Safety
Every outbound event must carry a **globally unique ID**—we reuse the invoice UUID. On the consumer side, store processed IDs in a compact Redis SET (`processed_ids`). Before processing, `SADD` the ID; if the return is 0, skip it.
if added, _ := rdb.SAdd(ctx, "processed_ids", event.ID).Result(); added == 0 {
log.Printf("duplicate %s, skipping", event.ID)
continue
}
Poison Pill Message Handling (DO NOT Retry Forever)
A malformed event (e.g., broken JSON) will cause the consumer to loop forever. Detect > 5 consecutive failures for the same key and push to a **dead‑letter stream** (`billing.dlq`).
if err := json.Unmarshal(m.Value, &event); err != nil {
// Increment retry counter in DB
_, dbErr := db.ExecContext(ctx,
`UPDATE outbox_events SET retry_count = retry_count + 1
WHERE id = $1`, event.ID)
if dbErr != nil {
log.Printf("retry counter failed: %v", dbErr)
}
if event.RetryCount >= 5 {
// forward to DLQ
rdb.XAdd(ctx, &redis.XAddArgs{
Stream: "billing.dlq",
Values: map[string]interface{}{
"msg": string(m.Value),
"err": err.Error(),
},
})
// Ack to avoid endless retry
r.CommitMessages(ctx, m)
}
continue
}
Monitoring: Metrics, Tracing, & Alerting Configurations
Expose Prometheus counters:
var (
outboxPublished = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "outbox_published_total",
Help: "Total number of events successfully published.",
}, []string{"event_type"})
outboxLag = promauto.NewGauge(prometheus.GaugeOpts{
Name: "outbox_lag_seconds",
Help: "Seconds since the oldest unpublished outbox row.",
})
)
Set up an OpenTelemetry span around the whole publish flow:
ctx, span := tracer.Start(ctx, "outbox.Publish")
defer span.End()
// ...publish logic...
span.SetAttributes(attribute.String("event.id", event.ID))
Create Grafana alerts:
- **Outbox Lag > 30 s** → Page on‑call.
- **Dead‑Letter Queue size > 100** → Open a ticket.
Database Polling Backpressure and Scaling
When you choose the poller, avoid hammering Postgres. Use a **key