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.”

⚡ TL;DR — Key takeaways
  • 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

ApproachLatencyDB LoadOps OverheadWhen to Choose
**Polling Publisher**5‑10 s (configurable)Moderate (SELECT … WHERE processed = false)Simple Helm chart, no extra infraLow‑volume billing, quick MVP
**CDC (Debezium) + Kafka Connect**< 1 sMinimal (replication slots)Manage Kafka/ZK, Debezium connectorsHigh‑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)

  1. **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)
);
  1. **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
}
  1. **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.
  1. **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)
        }
    }
}
  1. **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**.

  1. **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();
  1. **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
        }
    }
}
  1. **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

Written by

’m Nilesh, a Software Development Engineer with 2+ years of experience, specializing in Go, JavaScript, Python, Docker, Kubernetes, Git, Jenkins, microservices, and system design (LLD/HLD), backed by a strong foundation in data structures and algorithms. Alongside my engineering journey, I bring 4+ years of hands-on experience in SEO, where I’ve worked extensively on content strategy, keyword research, technical SEO, and organic growth, helping products and businesses scale efficiently by aligning solid technology with search-driven performance.