Kafka Streams & Patterns
Real-world Kafka usage — event sourcing, CQRS, CDC, sagas, dead letter queues, and the Kafka Streams processing library.
Table of Contents
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.
| Aspect | Kafka Streams | Apache Flink |
|---|---|---|
| Deployment | Library in your app (JAR dependency) | Separate cluster (JobManager + TaskManagers) |
| Scaling | Add more app instances (same as scaling any service) | Configure parallelism in Flink cluster |
| State management | Local RocksDB + changelog topics for recovery | Managed state backends (RocksDB, heap) |
| Sources/Sinks | Kafka only (input and output) | Kafka, files, databases, custom sources |
| Complexity | Simple — good for moderate stream processing | Powerful — complex event processing, advanced windowing |
| Best for | Kafka-to-Kafka transformations, enrichment, aggregation | Complex CEP, multi-source joins, ML pipelines |
KStream vs KTable
Kafka Streams has two core abstractions that represent different interpretations of the same underlying data:
| Abstraction | Interpretation | Analogy | Example |
|---|---|---|---|
| KStream | Unbounded stream of events (every record is a new fact) | Bank transaction log — every entry matters | User clicked, order placed, payment received |
| KTable | Changelog of latest state per key (updates replace previous) | Bank account balance — only current value matters | User profile (latest), inventory count (current), config (latest) |
| GlobalKTable | KTable replicated to ALL instances (for lookups) | Reference data dictionary available everywhere | Country codes, product catalog, exchange rates |
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 entries — u1's old value is replaced. Operations: KStream: filter, map, flatMap, branch, merge, join (windowed) KTable: filter, mapValues, join (non-windowed), aggregate KStream → KTable: groupByKey().aggregate() (stateful transformation) KTable → KStream: toStream() (emit changes as events)
Windowing
| Window Type | How It Works | Use Case |
|---|---|---|
| Tumbling | Fixed-size, non-overlapping windows (e.g., every 5 min) | Hourly aggregations, daily counts |
| Hopping | Fixed-size, overlapping windows (e.g., 5 min window, 1 min advance) | Moving averages, sliding metrics |
| Sliding | Window defined by time difference between records | Joins within a time range |
| Session | Dynamic windows based on activity gaps (inactivity gap closes window) | User sessions, click streams, activity tracking |
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.
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 trail — every change is recorded ✓ Time travel — rebuild state at any point in time ✓ Multiple projections — different views from same events ✓ Debugging — replay 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.
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.
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 Example — E-commerce: Command side: POST /orders → validates → produces "OrderCreated" to Kafka POST /payments → validates → produces "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 simple — just 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
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.
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.
Happy path: 1. Order Service → publishes "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 Service → stock 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 coordinator — harder to see the full picture - Compensating transactions must be idempotent - Monitoring requires distributed tracing (OpenTelemetry)
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.
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.created → orders.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 inspection — ops team reviews and fixes 2. Automated retry — separate consumer retries DLQ after delay 3. Alerting — trigger PagerDuty/Slack on DLQ growth 4. Replay — fix 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
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.
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).
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.
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.