Adetayo Akinsanya unkletayo.dev

Event-Driven Systems: CQRS (Command Query Responsibility Segregation) & Event Sourcing

Deconstructing write-side commands, read-side projections, append-only event streams, and materialization replay

Part 14 in Series — Catch up on the previous article: Distributed Caching Patterns: Cache-Aside, Write-Through, Write-Back, and Cache Stampedes (Part 13) before diving into this post.

Why You Need This in Real Life

Consider an enterprise financial banking application. In traditional CRUD (Create, Read, Update, Delete) architectures, an account balance is represented as a single mutable row in a database table:

UPDATE accounts SET balance = 450.00 WHERE account_id = 'ACC-101';

When an auditor arrives six months later and asks: “How did Account ACC-101 drop from 1,000to1,000 to 450 on June 12th? Was it a withdrawal, a wire transfer fee, or a malicious fraud transaction?”

The database cannot answer. The previous state was overwritten by the UPDATE statement. All historical context vanished.

Event Sourcing solves this by storing state changes as an immutable sequence of domain events (AccountOpened, MoneyDeposited, MoneyWithdrawn). State is never overwritten; current balances are derived by replaying event streams.

When paired with CQRS (Command Query Responsibility Segregation), systems decouple heavy write domain logic from high-speed read queries.


Part 1: Deconstructing CQRS Architecture

CQRS separates an application’s data model into two distinct, independently scalable pipelines:

                            +-----------------------------+
                            |     Client Application      |
                            +--------------+--------------+
                                           |
                    +----------------------+----------------------+
                    |                                             |
                    v (Commands: Writes / Mutations)              v (Queries: Reads / Scans)
        +-----------------------+                     +-----------------------+
        |   Command Handler     |                     |    Query Handler      |
        +-----------+-----------+                     +-----------+-----------+
                    |                                             |
                    v                                             v
        +-----------------------+                     +-----------------------+
        | Write DB (Normalized) |                     |  Read DB (Projections)|
        |  (PostgreSQL / Event  |                     |  (Elasticsearch /     |
        |      Store)           |                     |   Redis Read Views)   |
        +-----------+-----------+                     +-----------------------+
                    |                                             ^
                    | Event Bus (Kafka / RabbitMQ)                |
                    +---------------------------------------------+
                            Async Event Projection Sync

Command Side vs Query Side

  • Command Model (Write-Side): Handles business domain logic, validates invariants, and emits events. Optimized for high transactional integrity (Normalized RDBMS or Event Store).
  • Query Model (Read-Side): Optimized for fast retrieval and complex search queries. Uses denormalized read projections (Elasticsearch for text search, Redis for key lookups, Neo4j for graphs).
  • Sync Pipeline: Events emitted by the Command Model update the Read Model asynchronously via an Event Bus (Eventual Consistency).

Part 2: Event Sourcing Mechanics

In Event Sourcing, the Event Store is the absolute source of truth.

Instead of storing current state, the system appends immutable event objects to an append-only log:

Event Stream for Account "ACC-101":
1. Event: AccountCreated   { id: "ACC-101", owner: "Alice", balance: 0.00 }   (Version 1)
2. Event: MoneyDeposited   { id: "ACC-101", amount: 1000.00 }                  (Version 2)
3. Event: MoneyWithdrawn   { id: "ACC-101", amount: 200.00 }                   (Version 3)
4. Event: FeeCharged       { id: "ACC-101", amount: 35.00 }                    (Version 4)
-----------------------------------------------------------------------------------------
Calculated Current State (Replay 1..4): Balance = $765.00

Event Replay & Snapshots

To prevent performance degradation when replaying millions of historical events for long-lived entities, the engine periodically writes State Snapshots:

[ Event 1 .. Event 1000 ] ---> [ Snapshot at Version 1000: Balance = $5,400.00 ]
                                         |
                                         +---> Replay Event 1001 .. 1005 ---> Current State ($5,420.00)

To load an entity, the system fetches the latest Snapshot (Version 1000) and replays only subsequent events (Versions 1001–1005), reducing load time from seconds to milliseconds.


Part 3: Advantages & Trade-Offs

DimensionTraditional CRUDCQRS + Event Sourcing
AuditabilityPoor (Historical states overwritten).Perfect 100% complete audit trail of all historical events.
Time Travel / DebuggingImpossible to reconstruct past state.Replay events to any point in time to inspect historic state.
Read/Write ScalingDatabase scaling bottlenecked by shared schema.Read and Write databases scale independently.
ComplexityLow complexity; straightforward.High complexity (Eventual consistency, schema evolution).
ConsistencyImmediate Strong Consistency.Eventual Consistency between Write and Read stores.

Part 4: Runnable Java Event Sourcing & Projection Engine

package com.example.cqrs;

import java.util.*;

public class AccountAggregate {

    public sealed interface AccountEvent permits AccountCreatedEvent, MoneyDepositedEvent, MoneyWithdrawnEvent {
        String accountId();
        int version();
    }

    public record AccountCreatedEvent(String accountId, String owner, int version) implements AccountEvent {}
    public record MoneyDepositedEvent(String accountId, double amount, int version) implements AccountEvent {}
    public record MoneyWithdrawnEvent(String accountId, double amount, int version) implements AccountEvent {}

    // State derived from event replay
    private String accountId;
    private String owner;
    private double balance = 0.0;
    private int currentVersion = 0;

    // Replay event stream to reconstruct state
    public void replay(List<AccountEvent> events) {
        for (AccountEvent event : events) {
            apply(event);
        }
    }

    private void apply(AccountEvent event) {
        switch (event) {
            case AccountCreatedEvent e -> {
                this.accountId = e.accountId();
                this.owner = e.owner();
                this.balance = 0.0;
            }
            case MoneyDepositedEvent e -> this.balance += e.amount();
            case MoneyWithdrawnEvent e -> this.balance -= e.amount();
        }
        this.currentVersion = event.version();
    }

    // Business Command Execution
    public MoneyWithdrawnEvent withdraw(double amount) {
        if (amount > balance) {
            throw new IllegalArgumentException("Insufficient funds! Balance: $" + balance);
        }
        MoneyWithdrawnEvent event = new MoneyWithdrawnEvent(accountId, amount, currentVersion + 1);
        apply(event); // Update local aggregate state
        return event; // Return event to append to Event Store
    }

    public double getBalance() {
        return balance;
    }

    public int getCurrentVersion() {
        return currentVersion;
    }
}

Next Steps

Now that we understand CQRS, Event Sourcing, and event streams, we will compare Distributed Message Queues in Part 15: dissecting Kafka log-based architecture vs RabbitMQ AMQP brokers.

References & Further Reading

  1. Cloud Native Computing Foundation. gRPC HTTP/2 Transport & Protocol Buffer Specification. gRPC Docs.
  2. GraphQL Foundation. GraphQL Specification (October 2021 Edition). GraphQL Spec.
  3. Fielding, R. T. (2000). Architectural Styles and the Design of Network-based Software Architectures (REST Dissertation). UC Irvine.

Up Next in Series →

Part 15: Distributed Message Queues: Log-Based (Kafka) vs AMQP Broker-Based (RabbitMQ)

Continue to Part 15 →