So far we have used events as a mechanism for communication between services. In this lesson we take a further step and turn them into a storage mechanism. Event Sourcing proposes a radical idea: instead of storing the current state of an entity (the typical row in a table that gets overwritten), we store the complete sequence of events that brought it to that state. The current state stops being the primary data and becomes a consequence that is derived from the events.

This pattern usually goes hand in hand with CQRS (Command Query Responsibility Segregation), which separates the model that writes (commands) from the model that reads (queries). Together they enable full auditing, independent scaling of reads and writes, and the ability to "travel in time". It is a powerful but demanding approach, so you will also learn when not to use it.

Contents

  1. The core idea of Event Sourcing
  2. The Event Store
  3. State reconstruction (replay)
  4. CQRS: separating read and write
  5. Projections and read models
  6. Complete example of bank account events
  7. Common mistakes and tips
  8. Exercises and solutions
  9. Conclusion

  1. The core idea of Event Sourcing

In the traditional (CRUD) model, a bank transfer translates into UPDATE accounts SET balance = 50 WHERE id = 1. The previous value (100) is lost forever.

With Event Sourcing we store the facts:

1. AccountOpened(initialBalance=0)
2. MoneyDeposited(amount=100)
3. MoneyWithdrawn(amount=50)

The current balance (50) is not stored: it is calculated by summing the events. Immediate advantages:

  • Perfect auditing: you have the complete history of why the state is what it is.
  • Temporal debugging: you can reconstruct the state at any point in the past.
  • New views after the fact: if tomorrow you need a new projection, you generate it by replaying the already-existing events.
Aspect Traditional CRUD Event Sourcing
What is stored Current state (overwritten) All events (appended)
History Lost Complete and immutable
Main operation UPDATE / DELETE APPEND (only add)
Auditing Requires extra tables Intrinsic
Complexity Low High

  1. The Event Store

The Event Store is the database where the events are persisted. Its fundamental characteristic is that it is append-only: events are never modified or deleted, only added at the end. Each event is associated with a stream (normally one per entity/aggregate).

-- Minimal structure of a relational event store
CREATE TABLE events (
    id            BIGSERIAL PRIMARY KEY,   -- global insertion order
    stream_id     VARCHAR(64) NOT NULL,    -- which entity it belongs to (e.g. account-42)
    version       INT NOT NULL,            -- sequence number within the stream
    type          VARCHAR(100) NOT NULL,   -- "MoneyDeposited", etc.
    data          JSONB NOT NULL,          -- event payload
    occurred_at   TIMESTAMP NOT NULL,
    UNIQUE (stream_id, version)            -- optimistic concurrency control
);

Explanation of each column:

  • stream_id groups all the events of a single entity. To reconstruct account 42, we read all events with stream_id = 'account-42' ordered by version.
  • version is the ordering number within the stream. The UNIQUE (stream_id, version) constraint implements optimistic concurrency: if two processes try to write version 5 at the same time, one will fail, preventing them from clobbering each other.
  • data (JSONB in PostgreSQL) stores the event's content flexibly.
  • There is never an UPDATE or DELETE on this table; only INSERT.

  1. State reconstruction (replay)

To obtain the current state of an entity, we read its events in order and apply them one by one on top of an empty initial state. This is called replay or rehydration.

public class Account {
    private String id;
    private BigDecimal balance = BigDecimal.ZERO;
    private boolean open = false;

    // Reconstructs the account by replaying its event history
    public static Account rehydrate(List<AccountEvent> history) {
        Account account = new Account();
        for (AccountEvent e : history) {
            account.apply(e); // we apply each event in order
        }
        return account;
    }

    // Each event type modifies the state deterministically
    private void apply(AccountEvent e) {
        switch (e) {
            case AccountOpened ao -> {
                this.id = ao.accountId();
                this.balance = ao.initialBalance();
                this.open = true;
            }
            case MoneyDeposited md -> this.balance = this.balance.add(md.amount());
            case MoneyWithdrawn mw  -> this.balance = this.balance.subtract(mw.amount());
        }
    }
}

Key points:

  • rehydrate starts from an empty Account and applies each event sequentially. The result is the current state.
  • The apply method uses pattern matching over sealed types (Java 21): each event knows how it mutates the state. It is fully deterministic: the same events always produce the same state.
  • Optimization (snapshots): replaying thousands of events on every read is costly. The solution is snapshots: every N events a "photo" of the state is saved, and when rehydrating we start from the last snapshot and only replay the subsequent events.

  1. CQRS: separating read and write

CQRS separates operations into two distinct models:

  • Write side (Command): receives commands, validates business rules, and generates events. Optimized for consistency.
  • Read side (Query): serves queries from data models prepared for fast reading (denormalized). Optimized for performance.
flowchart LR
    U[User] -->|Command| W[Write Model]
    W -->|generates| ES[(Event Store)]
    ES -->|events| PR[Projector]
    PR -->|updates| RM[(Read Model<br/>denormalized)]
    U -->|Query| RM

Advantages of separating the two sides:

  • Independent scaling: there are usually many more reads than writes; you can replicate the read model without touching the write model.
  • Optimal models: the write model can be a normalized aggregate; the read model, a flat table ready to render a specific screen.

Important: CQRS does not require using Event Sourcing, nor vice versa. But combined they fit naturally: the events from the write side feed the projections of the read side.

  1. Projections and read models

A projection is an event consumer that keeps a read model up to date. Each time a new event arrives, the projection updates its view.

// Projection that maintains a summary of balances for fast listings
@Component
public class BalanceProjection {

    private final JdbcTemplate jdbc;

    public BalanceProjection(JdbcTemplate jdbc) { this.jdbc = jdbc; }

    @EventListener
    public void on(MoneyDeposited e) {
        // Updates the denormalized read table
        jdbc.update("UPDATE balance_view SET balance = balance + ? WHERE account_id = ?",
                e.amount(), e.accountId());
    }

    @EventListener
    public void on(MoneyWithdrawn e) {
        jdbc.update("UPDATE balance_view SET balance = balance - ? WHERE account_id = ?",
                e.amount(), e.accountId());
    }
}

Explanation:

  • balance_view is a denormalized table intended only for reading (for example, to instantly show a list of accounts with their balance, without replaying events).
  • Each method reacts to an event type and updates the view. The read model is, at its core, a derived cache of the event store: if it gets corrupted, it can be regenerated by replaying all the events from scratch.
  • This introduces eventual consistency: after a write, the view may take milliseconds to reflect it. The user experience must be designed with this in mind.

  1. Complete example of bank account events

Let's look at the events modeled as Java sealed types:

// Sealed interface: only these types can be account events
public sealed interface AccountEvent
        permits AccountOpened, MoneyDeposited, MoneyWithdrawn {
    String accountId();
    Instant occurredAt();
}

public record AccountOpened(String accountId, BigDecimal initialBalance,
                            Instant occurredAt) implements AccountEvent {}

public record MoneyDeposited(String accountId, BigDecimal amount,
                              Instant occurredAt) implements AccountEvent {}

public record MoneyWithdrawn(String accountId, BigDecimal amount,
                             Instant occurredAt) implements AccountEvent {}

And the write side validating a business rule before emitting the event:

public class Account {
    // ... state and rehydration from before ...

    // COMMAND: withdraw money. Validates and RETURNS the resulting event.
    public MoneyWithdrawn withdraw(BigDecimal amount) {
        if (!open) throw new IllegalStateException("Account closed");
        if (amount.compareTo(balance) > 0)
            throw new InsufficientBalanceException(id, amount, balance);
        // The rule holds: we generate the event (it does not yet mutate the state)
        return new MoneyWithdrawn(id, amount, Instant.now());
    }
}

Key points:

  • The sealed interface guarantees that the switch in apply (section 3) covers all possible cases; if you add a new event, the compiler forces you to handle it.
  • The withdraw method validates the rule "you cannot withdraw more than your balance" and, if it passes, generates the event. The state will be updated when that event is applied and persisted. This way, writing and validation stay in the aggregate.

Common Mistakes and Tips

  • Applying Event Sourcing to the entire system. It is complex. Reserve it for domains where auditing and history provide real value (finance, insurance, logistics). For a configuration CRUD, it is overkill.
  • Changing the meaning of an already-stored event. Events are immutable and eternal; you must version them (OrderCreatedV2) and maintain compatibility, not edit them.
  • Forgetting snapshots. Without them, entities with thousands of events become slow to rehydrate.
  • Expecting immediate consistency on reads. CQRS is eventually consistent; inform the user or use read-after-write strategies when it is critical.
  • Tip: start with just CQRS (without Event Sourcing) if you only need to scale reads; add Event Sourcing later if you need the history.

Exercises

  1. You have an account with the events: AccountOpened(0), MoneyDeposited(200), MoneyWithdrawn(70), MoneyDeposited(30). What is the balance after rehydration? Explain the process.
  2. Explain why an event must express an already-validated fact and not an order. What would happen if we stored commands in the event store instead of events?
  3. Design the denormalized read table movements_view that allows displaying an account's statement (date, type, amount, resulting balance) without replaying events on each query.

Solutions

  1. Balance = 0 + 200 − 70 + 30 = 160. We start from an empty account and apply the events in order: AccountOpened sets 0, MoneyDeposited(200) adds up to 200, MoneyWithdrawn(70) drops to 130, MoneyDeposited(30) rises to 160.
  2. An event is an accomplished fact: it already passed the validations, so replaying it must never fail. If we stored commands, replaying the history would require re-validating rules that could have changed, and a command could be rejected upon replay, breaking the deterministic reconstruction of the state.
  3. For example:
CREATE TABLE movements_view (
    id            BIGSERIAL PRIMARY KEY,
    account_id    VARCHAR(64) NOT NULL,
    date          TIMESTAMP NOT NULL,
    type          VARCHAR(20) NOT NULL,   -- DEPOSIT / WITHDRAWAL
    amount        NUMERIC(15,2) NOT NULL,
    resulting_balance NUMERIC(15,2) NOT NULL
);
CREATE INDEX idx_mov_account ON movements_view (account_id, date);

The projection fills in resulting_balance on each event, so that the statement is queried with a simple SELECT ... WHERE account_id = ? ORDER BY date.

Conclusion

Event Sourcing turns the sequence of events into the source of truth, offering full auditing and the ability to reconstruct any past state through replay, optimized with snapshots. CQRS separates the write model from the read model, allowing them to be scaled separately and feeding the read side with projections. We saw that they are powerful but demanding and that they should be applied judiciously.

In the next lesson, "Managing Distributed Transactions: The Saga Pattern", we will tackle a problem that arises naturally in these systems: how to maintain the consistency of an operation that spans several services when we cannot use a traditional ACID transaction.

Application Architecture Course

Module 1: Fundamentals of Application Architecture

Module 2: Design Principles and Tactics

Module 3: Architectural Styles and Patterns

Module 4: Distributed Architectures and Microservices

Module 5: Event-Driven Architectures and Messaging

Module 6: Domain-Driven Design (DDD)

Module 7: Data and Persistence

Module 8: Cloud Architecture and Deployment

Module 9: Quality, Security and Observability

Module 10: Evolution, Governance and Case Studies

© Copyright 2026. All rights reserved