DEV Community

Usman Khan
Usman Khan

Posted on Originally published at ctousman.com

Microservices Event Pipelines: Outbox Pattern, CDC, and Idempotent Event Sourcing

In distributed microservice architectures, synchronizing database updates with downstream event streams is a notorious source of silent data drift. Naive dual-writing within application code fails when database commits succeed but message broker network writes time out. Enforcing transactional consistency requires decoupled, log-based event publishing.


1. The Fallacy of Application-Level Dual Writing

Consider a standard e-commerce or SaaS subscription workflow: an application endpoint updates the user record in PostgreSQL and immediately dispatches a UserCreated event to Kafka or RabbitMQ.

  • Failure Scenario A: The database transaction commits successfully, but the message broker goes offline or drops the connection. The event is lost forever, resulting in state divergence downstream.
  • Failure Scenario B: The message broker accepts the event payload, but the local database transaction rolls back due to a constraint violation. Downstream workers process an event for entity state that does not exist in the primary datastore.

Distributed two-phase commits (2PC) solve this in theory, but introduce severe availability bottlenecks, network latency, and operational coupling that degrade microservice performance at scale.


2. Enforcing Atomicity: The Transactional Outbox Pattern

The Transactional Outbox Pattern eliminates dual-write risk by persisting domain entity mutations and event notifications within the same local ACID transaction. Instead of sending messages over the network directly, the application writes events to a dedicated outbox table.

Database Schema for Outbox Records

-- Outbox Table DDL in PostgreSQL
CREATE TABLE transactional_outbox (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type VARCHAR(255) NOT NULL,
    aggregate_id VARCHAR(255) NOT NULL,
    event_type VARCHAR(255) NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CLOCK_TIMESTAMP(),
    processed_at TIMESTAMPTZ NULL
);

-- Index for high-throughput polling or CDC sequence scan
CREATE INDEX idx_outbox_unprocessed
  ON transactional_outbox (created_at)
  WHERE processed_at IS NULL;
Enter fullscreen mode Exit fullscreen mode

Atomic Transaction Execution

// Example Node.js/TypeScript Transaction Handler
async function createTenantAccount(tenantData: TenantPayload): Promise<Tenant> {
  return await db.transaction(async (tx) => {
    // 1. Insert primary entity record into operational database
    const tenant = await tx.tenants.create({ data: tenantData });

    // 2. Insert event payload into outbox table in the SAME transaction
    await tx.outbox.create({
      data: {
        aggregateType: 'TENANT',
        aggregateId: tenant.id,
        eventType: 'TENANT_PROVISIONED',
        payload: {
          tenantId: tenant.id,
          plan: tenant.plan,
          ownerEmail: tenant.email,
          createdAt: tenant.createdAt,
        },
      },
    });

    return tenant; // Guaranteed atomicity: both succeed or both roll back
  });
}
Enter fullscreen mode Exit fullscreen mode

3. Change Data Capture (CDC) with Debezium and Kafka

Once events reside in the outbox table, they must be streamed out to external message brokers without polling overhead. Polling (e.g. SELECT * FROM outbox WHERE processed = false) causes lock contention and table bloat under high throughput.

Log-Based Change Data Capture

Using Debezium attached to the PostgreSQL Write-Ahead Log (WAL) or MySQL Binary Log (binlog), event records are read directly from the transaction log without issuing SQL queries against active tables.

  • No table locks: Debezium tails the transaction log asynchronously and takes no read locks on production application tables. The one thing to monitor is replication slot lag: if the connector stalls, PostgreSQL retains WAL on disk until it catches up.
  • At-least-once delivery: Every write to the outbox table produces a change event streamed into a dedicated Kafka topic.
  • Automated cleanup: A background job or partition-maintenance routine drops historical outbox records once Kafka has acknowledged ingestion.

4. Idempotent Consumer Design in Event Processing

Because log-based CDC and distributed brokers provide at-least-once delivery, downstream consumers must assume duplicate events will arrive. Handlers must guarantee strict idempotency using a lock or deduplication store.

// Event Consumer Handler with Redis Lock & Deduplication
async function handleTenantProvisionedEvent(event: KafkaEvent): Promise<void> {
  const { eventId, payload } = event;
  const dedupKey = `event:processed:${eventId}`;

  // 1. Claim the event with a short-lived lock (5 min),
  //    so a crashed worker can't block the event forever
  const claimed = await redis.set(dedupKey, 'PROCESSING', 'EX', 300, 'NX');

  if (!claimed) {
    const state = await redis.get(dedupKey);
    if (state === 'COMPLETED') {
      console.log(`[DEDUPLICATION] Event ${eventId} already processed. Skipping.`);
      return;
    }
    // Still in-flight on another worker: fail so the broker redelivers later
    throw new Error(`Event ${eventId} is in-flight on another worker`);
  }

  try {
    // 2. Execute idempotent downstream business logic
    await provisioningService.setupIsolatedResources(payload.tenantId);

    // 3. Mark completed and keep the dedup record for 7 days
    await redis.set(dedupKey, 'COMPLETED', 'EX', 604800);
  } catch (error) {
    // Release the lock so retries can execute
    await redis.del(dedupKey);
    throw error; // Trigger broker retry or Dead Letter Queue (DLQ) routing
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Key Architectural Takeaways

  • Never dual-write across network boundaries: Commit domain state changes and event records inside a single local database transaction.
  • Leverage database log tailing: Use log-based CDC (Debezium / Kafka Connect) instead of SQL table polling to stream events without locking overhead.
  • Enforce consumer-side idempotency: Design consumers with atomic deduplication locks that survive crashes, so unavoidable redeliveries are handled safely.

Originally published at ctousman.com.

About the Author:
I'm Usman Khan, Fractional CTO & Systems Architect. I advise high-growth SaaS and e-commerce platforms on event-driven architecture, distributed systems, and backend performance.

Need an architectural review of your event pipelines? Book a 30-min strategy call

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.