Kafka StreamsEvent SourcingCQRSCDCFan-outSagaDLQKafka Connect

Kafka Streams & Patterns

Real-world Kafka usage — event sourcing, CQRS, CDC, sagas, dead letter queues, and the Kafka Streams processing library.

40 min read9 sections
01

Kafka Streams Overview

Kafka Streams is a client library for building stream processing applications. Unlike Flink or Spark Streaming, it does not require a separate processing cluster — it runs inside your application as a library. It reads from Kafka, processes data, and writes back to Kafka.

🏭

Library vs Framework

Flink is like renting a factory — you deploy your code to their infrastructure. Kafka Streams is like buying a machine for your own workshop — it runs inside your existing application (a Spring Boot service, a microservice, a CLI tool). No separate cluster to manage, no job submission, no resource negotiation. Just add the dependency and start processing.

AspectKafka StreamsApache Flink
DeploymentLibrary in your app (JAR dependency)Separate cluster (JobManager + TaskManagers)
ScalingAdd more app instances (same as scaling any service)Configure parallelism in Flink cluster
State managementLocal RocksDB + changelog topics for recoveryManaged state backends (RocksDB, heap)
Sources/SinksKafka only (input and output)Kafka, files, databases, custom sources
ComplexitySimple — good for moderate stream processingPowerful — complex event processing, advanced windowing
Best forKafka-to-Kafka transformations, enrichment, aggregationComplex CEP, multi-source joins, ML pipelines
02

KStream vs KTable

Kafka Streams has two core abstractions that represent different interpretations of the same underlying data:

AbstractionInterpretationAnalogyExample
KStreamUnbounded stream of events (every record is a new fact)Bank transaction log — every entry mattersUser clicked, order placed, payment received
KTableChangelog of latest state per key (updates replace previous)Bank account balance — only current value mattersUser profile (latest), inventory count (current), config (latest)
GlobalKTableKTable replicated to ALL instances (for lookups)Reference data dictionary available everywhereCountry codes, product catalog, exchange rates
KStream vs KTable — The Dualitytext
Input topic "user-updates" (key = user-id):
  offset 0: {key: "u1", value: {name: "Alice", city: "NYC"}}
  offset 1: {key: "u2", value: {name: "Bob", city: "LA"}}
  offset 2: {key: "u1", value: {name: "Alice", city: "SF"}}  ← Alice moved

As KStream (event stream):
  Record 0: u1 → {name: "Alice", city: "NYC"}   ← event: Alice was in NYC
  Record 1: u2 → {name: "Bob", city: "LA"}      ← event: Bob was in LA
  Record 2: u1 → {name: "Alice", city: "SF"}    ← event: Alice moved to SF
  All 3 records exist and matter.

As KTable (changelog / current state):
  u1 → {name: "Alice", city: "SF"}   ← latest value for u1
  u2 → {name: "Bob", city: "LA"}     ← latest value for u2
  Only 2 entriesu1's old value is replaced.

Operations:
  KStream: filter, map, flatMap, branch, merge, join (windowed)
  KTable: filter, mapValues, join (non-windowed), aggregate
  KStreamKTable: groupByKey().aggregate() (stateful transformation)
  KTableKStream: toStream() (emit changes as events)

Windowing

Window TypeHow It WorksUse Case
TumblingFixed-size, non-overlapping windows (e.g., every 5 min)Hourly aggregations, daily counts
HoppingFixed-size, overlapping windows (e.g., 5 min window, 1 min advance)Moving averages, sliding metrics
SlidingWindow defined by time difference between recordsJoins within a time range
SessionDynamic windows based on activity gaps (inactivity gap closes window)User sessions, click streams, activity tracking
03

Event Sourcing with Kafka

In event sourcing, the event log is the source of truth — not the current state in a database. Every state change is captured as an immutable event. Current state is derived by replaying events from the beginning. Kafka's append-only, durable log is a natural fit for this pattern.

Event Sourcing — Orders Exampletext
Topic: "orders.events" (compacted for snapshots, or delete for full history)

Events for order-123:
  offset 0: {key: "order-123", event: "OrderCreated", items: [...], total: 99}
  offset 1: {key: "order-123", event: "PaymentReceived", amount: 99}
  offset 2: {key: "order-123", event: "OrderShipped", trackingId: "TRK-456"}
  offset 3: {key: "order-123", event: "OrderDelivered", timestamp: "..."}

Rebuilding current state:
  Start with empty state {}
  Apply OrderCreated    → {status: "created", items: [...], total: 99}
  Apply PaymentReceived → {status: "paid", items: [...], total: 99}
  Apply OrderShipped    → {status: "shipped", trackingId: "TRK-456"}
  Apply OrderDelivered  → {status: "delivered", deliveredAt: "..."}

Benefits:
Complete audit trailevery change is recorded
Time travelrebuild state at any point in time
Multiple projectionsdifferent views from same events
Debuggingreplay events to reproduce bugs

Trade-offs:
Storage grows with every event (mitigate with snapshots)
Replay time increases over time (mitigate with periodic snapshots)
Schema evolution is harder (events are immutable, schemas change)

💡 Snapshots for Fast Recovery

Don't replay from the beginning every time. Periodically create a snapshot (the current state at offset N) and store it. On recovery, load the latest snapshot and replay only events after that offset. This bounds recovery time regardless of total event count.

04

CQRS Pattern

CQRS (Command Query Responsibility Segregation)separates the write model from the read model. Commands (writes) produce events to Kafka. Query services consume those events and build read-optimized views. Kafka is the bridge between the two.

CQRS with Kafkatext
Architecture:

  ┌─────────────┐     ┌─────────────┐     ┌─────────────────────┐
Command   │     │    Kafka    │     │    Query Service
Service   │────▶│   Topics    │────▶│  (Read Projections)  │
  │  (Writes)   │     │             │     │                      │
  └─────────────┘     └─────────────┘     └─────────────────────┘
        │                    │                       │
   Validates &          Durable event           Builds optimized
   produces events      log (source             read models:
   to Kafka             of truth)               - Elasticsearch
                                                - Redis cache
                                                - Denormalized DB

ExampleE-commerce:
  Command side:
    POST /ordersvalidatesproduces "OrderCreated" to Kafka
    POST /paymentsvalidatesproduces "PaymentProcessed" to Kafka

  Query side (multiple independent consumers):
    Consumer 1: Builds order-status view in Redis (fast lookups)
    Consumer 2: Builds search index in Elasticsearch (full-text search)
    Consumer 3: Builds analytics aggregates in ClickHouse (reporting)
    Consumer 4: Sends notification emails (side effect)

Benefits:
Read and write models scale independently
Read models optimized for specific query patterns
Multiple read models from same event stream
Write side is simplejust validate and produce events

When to Use CQRS

  • Read and write patterns are fundamentally different (complex writes, simple reads or vice versa)
  • You need multiple read representations of the same data (search, cache, analytics)
  • Read and write loads need to scale independently
  • You're already using event sourcing — CQRS is a natural complement

When NOT to Use CQRS

  • Simple CRUD applications where read and write models are the same
  • When eventual consistency between write and read is unacceptable
  • Small teams that can't manage the operational complexity
05

Fan-out & Saga Patterns

Fan-out Pattern

One topic, multiple consumer groups — each group independently processes all records. A single event triggers multiple downstream actions without the producer knowing about any of them.

Fan-out — One Event, Multiple Reactionstext
Topic: "orders.created"

Producer: Order Service publishes OrderCreated event (once)

Consumer Group "inventory":    → Reserves stock
Consumer Group "payments":     → Initiates payment charge
Consumer Group "notifications": → Sends confirmation email
Consumer Group "analytics":    → Updates real-time dashboard
Consumer Group "fraud":        → Runs fraud detection rules

Each group:
  - Gets ALL records independently
  - Has its own offsets (different processing speeds)
  - Can be scaled independently
  - Can be added/removed without affecting others

Why this beats point-to-point:
  - Order Service doesn't know about downstream consumers
  - Adding a new consumer requires zero changes to the producer
  - Each consumer can fail independently without affecting others

Saga Pattern (Choreography)

A saga is a sequence of local transactions across multiple services. In the choreography approach, each service publishes events and reacts to events from others — Kafka is the communication backbone.

Choreography Saga — Order Fulfillmenttext
Happy path:
  1. Order Servicepublishes "OrderCreated" to orders.events
  2. Payment Service (consumes OrderCreated) → charges card
publishes "PaymentSucceeded" to payments.events
  3. Inventory Service (consumes PaymentSucceeded) → reserves stock
publishes "StockReserved" to inventory.events
  4. Shipping Service (consumes StockReserved) → creates shipment
publishes "ShipmentCreated" to shipping.events
  5. Order Service (consumes ShipmentCreated) → updates order status

Compensation (failure path):
  3. Inventory Servicestock unavailable
publishes "StockReservationFailed" to inventory.events
  4. Payment Service (consumes StockReservationFailed) → refunds payment
publishes "PaymentRefunded" to payments.events
  5. Order Service (consumes PaymentRefunded) → marks order as cancelled

Challenges:
  - Debugging distributed flows is hard (use correlation IDs)
  - No central coordinatorharder to see the full picture
  - Compensating transactions must be idempotent
  - Monitoring requires distributed tracing (OpenTelemetry)
06

Dead Letter Queues

A Dead Letter Queue (DLQ) is a separate topic where failed messages are routed when a consumer cannot process them after exhausting retries. This prevents a single bad message (poison pill) from blocking the entire consumer.

DLQ Patterntext
Consumer processing flow:

  poll() → records
  for each record:
    try:
      process(record)           // Happy path
    catch (RetriableException):
      retry up to 3 times       // Transient failure (network, timeout)
    catch (NonRetriableException):
      publish to DLQ topic      // Permanent failure (bad data, schema mismatch)
      commit offset             // Move past the poison pill
      alert operations team

DLQ topic naming: "<original-topic>.dlq"
  orders.createdorders.created.dlq

DLQ record should include:
  - Original record (key, value, headers)
  - Error message and stack trace
  - Original topic, partition, offset
  - Timestamp of failure
  - Retry count

DLQ processing strategies:
  1. Manual inspectionops team reviews and fixes
  2. Automated retryseparate consumer retries DLQ after delay
  3. Alertingtrigger PagerDuty/Slack on DLQ growth
  4. Replayfix the bug, then replay DLQ back to original topic

Poison Pill Detection

  • Deserialization failures — record doesn't match expected schema
  • Validation failures — required fields missing, invalid values
  • Processing exceptions — null pointer, division by zero on bad data
  • Timeout on external calls — downstream service permanently unavailable for this record
  • Schema incompatibility — producer upgraded schema without backward compatibility
07

Change Data Capture (CDC)

CDC (Change Data Capture) captures row-level changes from a database (INSERT, UPDATE, DELETE) and publishes them to Kafka topics. This turns your database into an event source without modifying application code.

CDC with Debeziumtext
Architecture:

  ┌──────────────┐     ┌───────────────┐     ┌─────────────┐
PostgreSQL  │     │   Debezium    │     │    Kafka
  │   (source)   │────▶│  (connector)  │────▶│   Topics
  │              │     │               │     │             │
  └──────────────┘     └───────────────┘     └─────────────┘
        │                                          │
   WAL (Write-Ahead                          Downstream consumers:
   Log) captures                             - Elasticsearch (search sync)
   every change                              - Redis (cache invalidation)
                                             - Data warehouse (analytics)
                                             - Another microservice

How Debezium works:
  1. Connects to PostgreSQL's WAL (Write-Ahead Log)
  2. Reads every INSERT/UPDATE/DELETE as it happens
  3. Converts each change to a Kafka record:
     - Key: primary key of the row
     - Value: {before: {...}, after: {...}, op: "u", ts_ms: ...}
  4. Publishes to topic: "dbserver.schema.table"

CDC record structure:
  {
    "before": {"id": 1, "name": "Alice", "email": "old@email.com"},
    "after":  {"id": 1, "name": "Alice", "email": "new@email.com"},
    "op": "u",        // c=create, u=update, d=delete, r=read (snapshot)
    "ts_ms": 1706745600000,
    "source": {"db": "orders", "table": "users", "lsn": 12345}
  }

CDC Use Cases

  • Search index sync — keep Elasticsearch in sync with PostgreSQL without dual writes
  • Cache invalidation — invalidate Redis cache when source data changes
  • Data warehouse sync — stream changes to ClickHouse/BigQuery for analytics
  • Microservice decoupling — other services react to DB changes without coupling to the source
  • Audit logging — capture every change for compliance without modifying application code

💡 Kafka Connect

Debezium runs as a Kafka Connect source connector. Kafka Connect is a framework for scalable, fault-tolerant data pipelines — it manages connectors that move data in/out of Kafka without writing custom consumers/producers. Source connectors pull data in (Debezium, JDBC, S3). Sink connectors push data out (Elasticsearch, S3, JDBC).

08

Interview Questions

Q:When would you use Kafka Streams vs Apache Flink?

A: Kafka Streams: when your pipeline is Kafka-to-Kafka, complexity is moderate (filtering, enrichment, simple aggregations), and you want to avoid managing a separate cluster. Flink: when you need complex event processing, multi-source joins, advanced windowing, or non-Kafka sources/sinks. Kafka Streams is a library (runs in your app); Flink is a framework (requires its own cluster).

Q:How does event sourcing with Kafka differ from traditional CRUD?

A: In CRUD, you store current state and overwrite on updates — history is lost. In event sourcing, you store every state change as an immutable event in Kafka. Current state is derived by replaying events. Benefits: complete audit trail, time travel, multiple projections from same events. Trade-offs: more storage, replay time grows, schema evolution is harder.

Q:Explain the fan-out pattern and why Kafka is better than point-to-point for it.

A: Fan-out: one event triggers multiple independent downstream actions. With Kafka, you publish once to a topic and multiple consumer groups each get all records independently. Adding a new consumer requires zero changes to the producer. With point-to-point queues, you'd need separate queues per consumer, the producer must know about all consumers, and adding a new one requires producer changes.

Q:What is a Dead Letter Queue and when do you need one?

A: A DLQ is a separate topic for messages that a consumer cannot process after retries. Without a DLQ, a single bad message (poison pill) blocks the entire partition — the consumer retries forever. With a DLQ, the bad message is moved aside, the consumer continues processing, and ops can inspect/fix/replay the DLQ later. Essential for any production consumer.

Q:How does CDC with Debezium work and what problems does it solve?

A: Debezium reads the database's WAL (Write-Ahead Log) and publishes every row change (INSERT/UPDATE/DELETE) to Kafka topics. It solves: (1) Dual-write problems — no need to write to both DB and Kafka. (2) Coupling — downstream services react to DB changes without knowing the source service. (3) Cache/search sync — keep derived stores in sync without application-level change tracking. It's at-least-once delivery, so downstream consumers must be idempotent.

09

Common Mistakes

📸

Event sourcing without snapshots

Replaying millions of events from offset 0 on every service restart. Recovery time grows linearly with event count, eventually taking hours.

Create periodic snapshots of materialized state. On restart, load the latest snapshot and replay only events after that point. This bounds recovery time regardless of total event count.

☠️

No DLQ for production consumers

Retrying failed records infinitely, blocking the entire partition. A single poison pill message stops all processing for that consumer.

Implement a DLQ pattern: retry a bounded number of times, then route to a dead letter topic. Continue processing other records. Alert on DLQ growth and have a process to inspect/replay.

🔗

Tight coupling in choreography sagas

Services directly depending on each other's event schemas without contracts. A schema change in one service silently breaks downstream consumers.

Use Schema Registry with compatibility modes. Define clear event contracts. Each service should be able to evolve independently. Use correlation IDs for distributed tracing.

🔨

Using Kafka Streams for everything

Building complex multi-source joins and ML pipelines in Kafka Streams. It's a library for moderate complexity, not a full-featured stream processing framework.

Kafka Streams is great for Kafka-to-Kafka transformations. For complex CEP, multi-source joins, or non-Kafka sources, use Flink. Choose the right tool for the complexity level.

🔄

CDC without idempotent consumers

Assuming CDC delivers exactly-once to downstream systems. Debezium connector restarts and rebalances WILL produce duplicate change events.

CDC (Debezium) provides at-least-once delivery. Downstream consumers WILL see duplicates (connector restarts, rebalances). Design consumers to be idempotent: use upserts, deduplication tables, or idempotency keys.