
Series Navigation
7 PartsComplete Event-Driven Architecture & Apache Kafka Blog Series
Series Overview
Introduction to Event-Driven Architecture - Building Your First Kafka Example
A Beginner's Guide
Designing Events - The Heart of Event-Driven Architecture
Poor event design leads to tight coupling, versioning nightmares, and systems that are hard to evolve.
Introduction to Apache Kafka - The Event Streaming Platform
This is where traditional message queues fall short, and where Apache Kafka excels.
Hands-On Kafka - Building Your First Event-Driven Application
In this part, we'll build a complete event-driven e-commerce system using Kafka.
Advanced Kafka Concepts - Mastering Production Kafka
Let's dive deep into advanced concepts that separate hobby projects from production systems.
Advanced Event-Driven Patterns - CQRS, Event Sourcing, and Sagas
Let's explore advanced architectural patterns that leverage events to build sophisticated, scalable applications.
Operating Event-Driven Systems in Production
Now comes the hard part: running it reliably in production
Part 6: Advanced Event-Driven Patterns - CQRS, Event Sourcing, and Sagas
Introduction
You've mastered Kafka and built event-driven systems. Now let's explore advanced architectural patterns that leverage events to build sophisticated, scalable applications.
What we'll cover:
- CQRS (Command Query Responsibility Segregation)
- Event Sourcing as a paradigm shift
- Saga Pattern for distributed transactions
- Outbox and Inbox patterns for reliability
- How these patterns work together
graph TB
subgraph "Advanced Patterns"
A[CQRS<br/>Separate reads/writes]
B[Event Sourcing<br/>Events as truth]
C[Saga Pattern<br/>Distributed transactions]
D[Outbox Pattern<br/>Atomic operations]
E[Inbox Pattern<br/>Idempotent consumers]
end
F[Production<br/>Event-Driven<br/>System]
A --> F
B --> F
C --> F
D --> F
E --> F
style F fill:#00b894
style A fill:#74b9ff
style B fill:#a29bfe
style C fill:#fd79a8
style D fill:#fdcb6e
style E fill:#95e1d3
CQRS (Command Query Responsibility Segregation)
The Problem with Single Models
Traditional applications use the same data model for everything - writing new orders, displaying order lists, generating reports, and powering analytics dashboards. This creates a fundamental tension:
graph TB
subgraph "Traditional Architecture"
API[API Layer]
Service[Business Logic]
DB[(Database<br/>Same Model)]
API -->|Write| Service
API -->|Read| Service
Service -->|CRUD| DB
Note1[Same model for:<br/>- Complex writes<br/>- Simple reads<br/>- Reports<br/>- Analytics]
end
style DB fill:#ff7675
Writes need:
- Normalized data for consistency
- Business rule enforcement
- Transaction boundaries
- Simple, focused models
Reads need:
- Denormalized data for performance
- Pre-joined data to avoid complex queries
- Multiple specialized views for different use cases
- Caching and optimization
Trying to serve both needs with one model is like using a Swiss Army knife for surgery. It can technically work, but you're better off with specialized tools.
CQRS Solution
Command Query Responsibility Segregation means exactly what it says: separate the responsibility for commands (writes) from queries (reads).
graph TB
subgraph "CQRS Architecture"
API[API Layer]
subgraph "Write Side"
CMD[Command Handler]
WriteDB[(Write Database<br/>Normalized)]
Events[Event Bus]
end
subgraph "Read Side"
Projection[Event Projections]
ReadDB1[(Read DB 1<br/>Orders List)]
ReadDB2[(Read DB 2<br/>Analytics)]
ReadDB3[(Search Index<br/>Elasticsearch)]
end
API -->|Commands| CMD
API -->|Queries| ReadDB1
API -->|Queries| ReadDB2
API -->|Queries| ReadDB3
CMD --> WriteDB
CMD --> Events
Events --> Projection
Projection --> ReadDB1
Projection --> ReadDB2
Projection --> ReadDB3
end
style CMD fill:#ff6b6b
style Projection fill:#74b9ff
style Events fill:#95e1d3
CQRS Implementation
Write Side (Commands):
- Receives commands expressing intent: "Place this order", "Cancel that shipment"
- Validates business rules
- Updates the write database (normalized, transactional)
- Publishes events describing what happened
Read Side (Queries):
- Listens to events from the write side
- Builds specialized read models optimized for specific queries
- Can use different databases for different needs (MongoDB for documents, Elasticsearch for search, Redis for caching)
- Serves queries fast without impacting writes
Why This Works
When a customer places an order:
- Command Handler validates the order, saves it to the write database, and publishes "OrderPlaced" event
- Multiple Projections listen to this event:
- Order List projection adds a summary to MongoDB for the customer dashboard
- Analytics projection updates daily sales totals in PostgreSQL
- Search projection indexes the order in Elasticsearch for admin searches
- Notification projection triggers email confirmation
Each read model is purpose-built for its specific use case. Your customer dashboard queries don't compete with your analytics reports. Your search system doesn't slow down order placement.
The Trade-Off: Eventual Consistency
The catch? Your read models aren't updated instantly. There's a small delay (usually milliseconds to seconds) between writing an order and seeing it in all read views. This is called eventual consistency.
For most business cases, this is fine—even preferable. When you click "Place Order" on Amazon, the confirmation page can say "Order received!" before every microservice has finished updating. The order will appear in your order history shortly.
When NOT to use CQRS:
- Simple CRUD applications
- When you need immediate read-after-write consistency
- Small applications where the complexity outweighs the benefits
CQRS Benefits
✅ Scalability - Scale reads and writes independently
✅ Performance - Optimize each side separately
✅ Flexibility - Multiple read models for different use cases
✅ Simplicity - Each model focused on its purpose
CQRS Challenges
⚠️ Complexity - More moving parts
⚠️ Eventual Consistency - Reads lag behind writes
⚠️ Data Duplication - Multiple copies of data
Event Sourcing Deep Dive
Event Sourcing: Store all changes as a sequence of events. Current state = replay all events.
graph LR
subgraph "Traditional Storage"
A1[Current State<br/>Order: SHIPPED] -.->|Lost history| X1[❌]
end
subgraph "Event Sourcing"
E1[OrderPlaced] --> E2[PaymentReceived]
E2 --> E3[OrderShipped]
E3 --> E4[Current State:<br/>SHIPPED]
E1 -.->|Replay| E4
E2 -.->|Replay| E4
E3 -.->|Replay| E4
end
style X1 fill:#ff7675
style E4 fill:#00b894
A Paradigm Shift: Events Before Models
Event Sourcing is not mainly about objects, aggregates, or persistence tricks. It is about treating software as a system that remembers its experiences as a stream of events, and only then builds whatever models it needs on top.
Events before models
Most explanations frame Event Sourcing as "storing every change to state," usually tied to a domain model and a persistence pattern. In this view, events are diffs on objects, and the main benefit is a perfect audit log or the ability to rebuild state. That is useful, but it is not the most interesting part.
A broader view is: events are descriptions of things that happened, independent of any particular object model. They are traces of what the system perceived and did. The system can later construct many different models from those experiences, instead of betting everything on a single "correct" domain model decided up front.
The single-model trap
Traditional software design tries to squeeze reality into one coherent, durable data model that should work now and stay flexible for an unknown future. This "single model fallacy" feels efficient at first but tends to calcify; changing the model becomes harder with every feature and migration.
This mirrors a more "platonic" mindset: assuming there is one true model of the world to discover and encode. Constructivist thinking instead says that each observer builds its own models from experience, and those models are always partial and revisable. Event Sourcing fits this: first collect experiences (events), then build and rebuild models as needed.
ES as a first worldview
Imagine if developers learned from day one that a system is an organism reacting to stimuli, recording its experiences as events, and deriving temporary, replaceable models from that history. CRUD databases, aggregates, projections, microservices, and specialized data models would then be optimizations on top of the event stream, not the foundation.
In that worldview, pluralism is normal: one event stream, many read models, many ways of looking at the same history. Software becomes more about evolving behavior in a changing environment, less about defending a single schema.
What "ES-first" really means
"ES-first" does not mean throwing away OOP, FP, SQL, NoSQL, or monoliths. It means:
- Events are the primary source of truth; models are secondary, derived, and disposable.
- The system expects to build new models from the same events as requirements, insights, and environments change.
- Your main job is to create valuable behavior, not to freeze the world into one perfect schema.
That is why Event Sourcing feels less like a persistence pattern and more like a paradigm shift: from monolithic models to, experience-first software.
How It Works
Instead of storing your order as:
Order #123: Status = SHIPPED, Total = $299, Items = 3
You store the history:
Event 1: OrderCreated (customer: Alice, items: [laptop, mouse])
Event 2: ItemAdded (item: keyboard, price: $49)
Event 3: PaymentReceived (amount: $299, method: credit_card)
Event 4: OrderShipped (tracking: 1Z999AA1)
Your current state? Replay all the events. Want to see what the order looked like yesterday? Replay events up to yesterday. Need a new report? Build a new projection from the same events.
The Power: Time Travel and Flexibility
Time Travel: "Show me what this customer's order history looked like on Black Friday" → Replay events up to that date.
New Insights: "We need a report of all cancelled orders with reasons" → Build a new projection from existing events, no database migration needed.
Complete Audit Trail: Every change is recorded with who, what, when, and why. Perfect for regulated industries.
Debugging: "What happened to order #123?" → Read the event stream: created, payment failed, customer added new card, payment succeeded, shipped.
Events Before Models: The Real Insight
The traditional approach asks: "What's the right data model?" Event Sourcing asks: "What happened?"
Models are temporary. Business needs change. Reports evolve. Event Sourcing lets you rebuild models as needed while keeping the raw truth: the events themselves.
This is why Event Sourcing feels less like a persistence pattern and more like a paradigm shift. You're not just changing how you store data—you're changing how you think about state, time, and truth in your system.
The Practical Side: Snapshots
Replaying 10 million events every time you load an order? That's slow. Solution: snapshots.
Every 100 (or 1000) events, save a snapshot of the current state. Then replay only events since the last snapshot. Like bookmarks in a long book.
When to Use Event Sourcing
Great for:
- Audit-heavy domains (finance, healthcare, legal)
- Complex business domains where history matters
- Systems that need time-travel queries
- Applications with evolving reporting needs
Avoid when:
- Simple CRUD is enough
- You can't tolerate the complexity
- Your team isn't ready for the mindset shift
Complete Event Sourcing Implementation
📚 Hands-On Guide: For a complete step-by-step walkthrough demonstrating event sourcing with Kafka, see the Event Sourcing Pattern Demonstration guide. This practical tutorial walks you through:
- OrderService: Creating orders and watching events flow to Kafka
- EventReplay: Observing state rebuilding by replaying events step-by-step
- TimeTravel: Querying historical order state at any point in time
- Kafka UI: Visualizing events stored in Kafka with complete audit trails
The guide demonstrates key concepts: events as the source of truth, state rebuilding through event replay, version tracking for concurrency control, and time-travel queries. You'll see exactly how Kafka stores immutable events and how aggregates rebuild their state by replaying the event stream.
Sagas: Distributed Transactions That Actually Work
The Distributed Transaction Problem
Sagas manage distributed transactions across multiple services.
graph TB
subgraph "Order Fulfillment Saga"
Start[Order Placed]
Start --> Reserve[Reserve Inventory]
Reserve -->|Success| Payment[Process Payment]
Reserve -->|Failure| RollbackStart[Cancel Order]
Payment -->|Success| Ship[Schedule Shipping]
Payment -->|Failure| RollbackInv[Release Inventory]
Ship -->|Success| Complete[Order Complete]
Ship -->|Failure| RollbackPay[Refund Payment]
RollbackPay --> RollbackInv
RollbackInv --> RollbackStart
end
style Complete fill:#00b894
style RollbackStart fill:#ff7675
You have three microservices: Inventory, Payment, and Shipping. A customer places an order. You need to:
- Reserve inventory
- Charge payment
- Schedule shipping
Each step must succeed for the order to complete. If payment fails after you've reserved inventory, you need to release that inventory back.
Traditional solution? A distributed transaction with two-phase commit. Reality? That doesn't scale and creates tight coupling.
The Saga Solution
A Saga is a sequence of local transactions, where each transaction updates one service and publishes an event. If a step fails, compensating transactions undo the previous steps.
Think of it like planning a trip:
- Book flight ✓
- Book hotel ✓
- Book rental car ✗ (failed!)
- Compensation: Cancel hotel
- Compensation: Cancel flight
📚 Hands-On Guide: For complete working examples of both Choreography and Orchestration patterns, see the Saga Pattern Examples Guide. The guide includes:
- Choreography Example: Services coordinating autonomously via events (Order → Inventory → Payment flow)
- Orchestration Example: Central saga orchestrator managing all steps and compensations
- Persistence Example: Saga state persistence for recovery after crashes
Each example demonstrates compensating transactions, state tracking, and how to handle failures gracefully. You'll see the complete flow from order placement through inventory reservation, payment processing, and shipping scheduling.
Two Approaches: Choreography vs Orchestration
Choreography (Event-Driven): Each service knows what to do when it sees an event. No central coordinator.
OrderService: Publishes "OrderPlaced"
↓
InventoryService: Hears "OrderPlaced" → Reserves items → Publishes "InventoryReserved"
↓
PaymentService: Hears "InventoryReserved" → Charges customer → Publishes "PaymentReceived"
↓
ShippingService: Hears "PaymentReceived" → Schedules delivery
If payment fails:
PaymentService: Publishes "PaymentFailed"
↓
InventoryService: Hears "PaymentFailed" → Releases inventory
Pros: Loose coupling, no single point of failure
Cons: Hard to understand the full workflow, difficult to track saga state
Services react to events independently:
Orchestration (Centralized): One orchestrator tells each service what to do.
SagaOrchestrator for Order #123:
Step 1: Call InventoryService.Reserve() → Success
Step 2: Call PaymentService.Charge() → Failed!
Compensation: Call InventoryService.Release()
Result: Saga failed, order cancelled
Pros: Clear workflow, easy to track, easier to debug
Cons: Orchestrator is a single point of failure, more coupling
Choosing Your Approach
Use Choreography when:
- Services need to remain highly independent
- Multiple sagas might be triggered by the same event
- You want maximum scalability and resilience
Use Orchestration when:
- You need clear visibility into saga state
- The workflow is complex with many steps
- You need timeout handling and retry logic
The Critical Detail: Saga State
Sagas aren't fire-and-forget. You need to track state:
- Which steps completed?
- Which compensations to run if something fails?
- Is the saga still running or stuck?
Store saga state in a database. If your orchestrator crashes mid-saga, you can recover and continue or compensate.
Outbox Pattern: Atomic Database + Events
Problem: How to atomically update database AND publish event?
sequenceDiagram
participant Service
participant Database
participant OutboxTable
participant Publisher
participant Kafka
Service->>Database: BEGIN TRANSACTION
Service->>Database: Update order
Service->>OutboxTable: Insert event
Service->>Database: COMMIT
Note over Database,OutboxTable: Both succeed or both fail
Publisher->>OutboxTable: Poll for events
OutboxTable-->>Publisher: Return unpublished events
Publisher->>Kafka: Publish events
Kafka-->>Publisher: ACK
Publisher->>OutboxTable: Mark as published
Note over Service,Kafka: Atomic database + event!
The Dual-Write Problem
You need to do two things atomically:
- Save an order to the database
- Publish "OrderPlaced" event to Kafka
The naive approach:
db.Save(order);
kafka.Publish("OrderPlaced", order);
What goes wrong:
- Database succeeds, Kafka fails → Event is lost, other services never see the order
- Kafka succeeds, database fails → Event published for an order that doesn't exist
You can't use a database transaction to cover both—Kafka isn't transactional.
The Outbox Solution
Use your database to remember what to publish.
-
In a single database transaction:
- Save the order to the
orderstable - Save the event to the
outboxtable
- Save the order to the
-
A separate process (Outbox Publisher):
- Polls the outbox table
- Publishes unpublished events to Kafka
- Marks them as published
Now it's atomic: Either both the order and outbox event are saved, or neither is. Even if Kafka is down, the event sits safely in your outbox until you can publish it.
Why This Works
The outbox table is in your database, so it participates in your database transaction. Publishing to Kafka happens outside the transaction, which is fine—if it fails, the event remains in the outbox for retry.
Bonus: The outbox pattern naturally provides event ordering and retry logic.
Inbox Pattern (Idempotent Consumer) read: Exactly-Once Processing
Problem: Ensure each event is processed exactly once.
The Duplicate Event Problem
You're consuming events from Kafka. Your consumer processes "OrderPlaced", updates inventory, then crashes before committing the Kafka offset.
Kafka redelivers the event. Now you've updated inventory twice for one order.
The Inbox Solution
Track which events you've processed in your database.
In a single database transaction:
1. Check inbox table: Have we processed event #xyz?
2. If yes → Skip
3. If no → Process the event
4. Save event ID to inbox table
Even if Kafka redelivers the event a hundred times, you process it exactly once.
Why This Works
The inbox check and your business logic are in the same database transaction. Either both happen or neither happens.
Combined with Outbox: You get end-to-end exactly-once processing:
- Producer uses Outbox → Guarantees each event is published at least once
- Consumer uses Inbox → Guarantees each event is processed at most once
- Result: Exactly-once semantics across systems
Putting It All Together: E-Commerce System
Here's how these patterns work together in a real system:
Order Service (Write Side):
- Uses Event Sourcing to track order history as events
- Uses Outbox Pattern to atomically save events and publish to Kafka
- Handles commands: PlaceOrder, CancelOrder, UpdateOrder
Read Services (Query Side via CQRS):
- Order List Projection: Builds a denormalized view of all orders for customer dashboard
- Analytics Projection: Maintains daily/weekly sales totals
- Search Projection: Keeps Elasticsearch index up-to-date for admin search
- All use Inbox Pattern to ensure exactly-once processing
Order Fulfillment Saga:
- Coordinates Inventory, Payment, and Shipping services
- Uses Orchestration for visibility and control
- Persists saga state for recovery after failures
- Implements compensating transactions for rollback
The Flow:
- Customer clicks "Place Order"
- Order Service saves event to outbox, returns confirmation
- Outbox Publisher sends "OrderPlaced" to Kafka
- Saga Orchestrator starts order fulfillment workflow
- Read projections update their views (eventual consistency)
- Each service uses Inbox to ensure exactly-once processing
The Result: A resilient, scalable system where:
- No event is lost (Outbox)
- No event is processed twice (Inbox)
- Complete audit trail (Event Sourcing)
- Optimized reads (CQRS)
- Distributed transactions work (Saga)
Key Takeaways
- ✅ CQRS: Separates models for optimal read/write performance—use when you have complex queries or need independent scaling.
- ✅ Event Sourcing: Stores history as events, enabling time travel and flexible model evolution—use for audit-heavy or complex domains.
- ✅ Sagas: Manage distributed transactions with compensating actions—use Choreography for loose coupling, Orchestration for control.
- ✅ Outbox Pattern: Guarantees atomic database writes and event publishing—use whenever you publish events.
- ✅ Inbox Pattern: Ensures exactly-once event processing—use in all consumers handling critical business logic.
These aren't just academic patterns. They're battle-tested solutions to real problems in distributed systems. Understanding when and how to apply them separates production-ready systems from prototypes.
Next Steps
In Part 7, we tackle the hardest part: running these systems reliably in production.
- Monitoring distributed systems
- Debugging when things go wrong
- Testing strategies for complex workflows
- Deployment patterns that minimize risk
- Incident response and recovery
You now have the patterns. Next, we learn to operate them. 🚀
Questions about this post?
Send a message and I'll get back to you.