Back to Event-Driven Architecture & CQRS
Q8Question 8 of 8

What is Event Sourcing, its advantages, disadvantages, and example?

Key Concepts
  • Event Sourcing stores every change as an immutable domain event and rebuilds current state by replaying the log.
  • The event log is the single source of truth; the current balance is a derived calculation, not a stored fact.
  • Snapshots avoid replaying the full history by periodically saving the rehydrated state at a known event version.
  • Advantages include a complete audit trail, temporal queries, retroactive bug fixes, and freedom to build new read models.
  • Disadvantages include higher complexity, awkward querying without projections, and hard event schema evolution.
  • Event Sourcing and CQRS are frequently paired but are not the same thing: one is about persistence, the other about separating read and write models.
  • Use a purpose-built event store like EventStoreDB or an append-only Kafka log with snapshots rather than hand-rolling storage.

Definition

Event Sourcing stores every change as an immutable event and rebuilds current state by replaying the log, so the history is the truth and the balance is just a calculation.
  • Event Sourcing – A persistence pattern where the source of truth is an append-only, ordered log of immutable domain events rather than the current state.
  • Rehydration – Loading all events for one aggregate and folding them one by one into an in-memory object to reconstruct current state.
  • Snapshot – A periodically saved copy of the rehydrated state so you only replay events after the snapshot, not the full history.
  • Projection – A separate read model built by consuming the event stream, optimized for specific query patterns.
  • Event upcasting – Transforming old event versions into the current schema during replay, since events are immutable forever.

Think of Rajesh's bank account at National Bank of India. A normal system stores one number: balance is 950000 rupees. Event Sourcing instead stores the history like a passbook: opened with 1000000, then transferred 50000 to Priya, then received 20000 salary interest. To know the balance today, you start at zero and add up every line. The passbook never gets erased; you only ever add new lines to the bottom.

Understanding the Concepts

What is Event Sourcing?

In a traditional system you do an UPDATE on a row and the old value is gone forever. Event Sourcing forbids destructive updates. Every state change is captured as a domain event appended to an event store.

JAVA
package com.bank.eventsourcing;

import java.math.BigDecimal;
import java.time.Instant;

public sealed interface AccountEvent {
    String accountId();
    Instant occurredAt();

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

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

    record MoneyWithdrawn(String accountId, BigDecimal amount,
                          String description, Instant occurredAt) implements AccountEvent {}
}
TEXT
Event store for account SAV2001:
  1. AccountOpened(SAV2001, 1000000.00, 2024-01-01)
  2. MoneyWithdrawn(SAV2001, 50000.00, "Transfer to Priya", 2024-01-05)
  3. MoneyDeposited(SAV2001, 20000.00, "Salary interest", 2024-01-15)

How do you reconstruct current state by replaying events?

You load all events for one aggregate and fold them one by one into an in-memory object. This folding is called rehydration.

JAVA
package com.bank.eventsourcing;

import java.math.BigDecimal;
import java.util.List;

public class Account {

    private String accountId;
    private BigDecimal balance = BigDecimal.ZERO;
    private int version = 0;

    public static Account rehydrate(List<AccountEvent> events) {
        Account account = new Account();
        for (AccountEvent event : events) {
            account.apply(event);
        }
        return account;
    }

    private void apply(AccountEvent event) {
        switch (event) {
            case AccountEvent.AccountOpened e -> {
                this.accountId = e.accountId();
                this.balance = e.initialDeposit();
            }
            case AccountEvent.MoneyDeposited e ->
                    this.balance = this.balance.add(e.amount());
            case AccountEvent.MoneyWithdrawn e ->
                    this.balance = this.balance.subtract(e.amount());
        }
        this.version++;
    }

    public static void main(String[] args) {
        var events = List.of(
                new AccountEvent.AccountOpened("SAV2001",
                        new BigDecimal("1000000.00"), java.time.Instant.now()),
                new AccountEvent.MoneyWithdrawn("SAV2001",
                        new BigDecimal("50000.00"), "Transfer to Priya", java.time.Instant.now()),
                new AccountEvent.MoneyDeposited("SAV2001",
                        new BigDecimal("20000.00"), "Salary interest", java.time.Instant.now())
        );

        Account account = Account.rehydrate(events);
        System.out.println("Account: " + account.accountId);
        System.out.println("Balance: " + account.balance);
        System.out.println("Version: " + account.version);
    }

    // Getters
    public String getAccountId() { return accountId; }
    public BigDecimal getBalance() { return balance; }
    public int getVersion() { return version; }
}
TEXT
Expected output:
Account: SAV2001
Balance: 970000.00
Version: 3

How do snapshots avoid replaying the full history?

Replaying thousands of events on every read is slow. A snapshot saves the rehydrated state at a known version, and you only replay events after it.

JAVA
package com.bank.eventsourcing;

import java.math.BigDecimal;

public record AccountSnapshot(
        String accountId,
        BigDecimal balance,
        int version
) {
    public static AccountSnapshot of(Account account) {
        return new AccountSnapshot(
                account.getAccountId(),
                account.getBalance(),
                account.getVersion()
        );
    }

    public static void main(String[] args) {
        // After 500 events, take a snapshot
        System.out.println("Snapshot at version 500: balance = 820000.00");
        System.out.println("On next load: load snapshot + replay events 501-510");
        System.out.println("Instead of replaying all 510 events from scratch");
    }
}
TEXT
Expected output:
Snapshot at version 500: balance = 820000.00
On next load: load snapshot + replay events 501-510
Instead of replaying all 510 events from scratch

What are the advantages of Event Sourcing?

The advantages are compelling for banking: a complete audit trail, temporal queries, retroactive bug fixes, and the freedom to build new read models later.

JAVA
package com.bank.eventsourcing;

import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;

public class TemporalQuery {

    public static BigDecimal balanceAsOf(List<AccountEvent> events, Instant cutoff) {
        Account account = new Account();
        for (AccountEvent event : events) {
            if (event.occurredAt().isAfter(cutoff)) break;
            // account.apply(event); // Apply only events up to cutoff
        }
        return account.getBalance();
    }

    public static void main(String[] args) {
        System.out.println("Advantages of Event Sourcing:");
        System.out.println("  1. Audit trail: every change is recorded with timestamp and reason");
        System.out.println("  2. Temporal queries: 'What was Rajesh balance on Jan 3rd?'");
        System.out.println("  3. Bug fix replay: fix the logic, replay events, get correct state");
        System.out.println("  4. New read models: replay events into any new projection shape");
        System.out.println("  5. Debugging: replay the exact sequence that led to a bug");
    }
}
TEXT
Expected output:
Advantages of Event Sourcing:
  1. Audit trail: every change is recorded with timestamp and reason
  2. Temporal queries: 'What was Rajesh balance on Jan 3rd?'
  3. Bug fix replay: fix the logic, replay events, get correct state
  4. New read models: replay events into any new projection shape
  5. Debugging: replay the exact sequence that led to a bug

What are the disadvantages?

The model is more complex than CRUD, querying requires separate projections, and event schema evolution is hard because events are immutable forever.

JAVA
package com.bank.eventsourcing;

public class EventSourcingDisadvantages {
    public static void main(String[] args) {
        System.out.println("Disadvantages:");
        System.out.println("  1. Complexity: harder to build and reason about than CRUD");
        System.out.println("  2. Querying: 'all accounts > 100K' requires a separate projection");
        System.out.println("  3. Schema evolution: MoneyWithdrawn v1 vs v2 needs upcasting");
        System.out.println("  4. Eventual consistency: projections lag behind the event log");
        System.out.println("  5. Storage: event log grows unbounded without compaction");
    }
}
TEXT
Expected output:
Disadvantages:
  1. Complexity: harder to build and reason about than CRUD
  2. Querying: 'all accounts > 100K' requires a separate projection
  3. Schema evolution: MoneyWithdrawn v1 vs v2 needs upcasting
  4. Eventual consistency: projections lag behind the event log
  5. Storage: event log grows unbounded without compaction

How is Event Sourcing different from CQRS?

Event Sourcing is about how you persist (append-only events). CQRS is about separating the write model from the read model. They are frequently paired but are not the same thing.

| Aspect | Event Sourcing | CQRS |
|---|---|---|
| Focus | Persistence mechanism | Architectural separation |
| Core idea | Store events, derive state | Separate write model from read model |
| Can be used alone | Yes, without CQRS | Yes, without Event Sourcing |
| Common pairing | Event log naturally feeds CQRS projections | Read projections naturally consume event streams |

JAVA
package com.bank.eventsourcing;

public class EventSourcingVsCqrs {
    public static void main(String[] args) {
        System.out.println("Event Sourcing WITHOUT CQRS:");
        System.out.println("  Store events, rebuild state on read from event log");
        System.out.println("  Same model for reads and writes");
        System.out.println();
        System.out.println("CQRS WITHOUT Event Sourcing:");
        System.out.println("  Normal JPA entities for writes");
        System.out.println("  Separate denormalized table for reads");
        System.out.println("  Sync via @TransactionalEventListener");
        System.out.println();
        System.out.println("Event Sourcing WITH CQRS (common):");
        System.out.println("  Event log is the write store");
        System.out.println("  Projections are the read store");
    }
}
TEXT
Expected output:
Event Sourcing WITHOUT CQRS:
  Store events, rebuild state on read from event log
  Same model for reads and writes

CQRS WITHOUT Event Sourcing:
  Normal JPA entities for writes
  Separate denormalized table for reads
  Sync via @TransactionalEventListener

Event Sourcing WITH CQRS (common):
  Event log is the write store
  Projections are the read store

What about event schema evolution?

Once an event is written it is immutable forever. If the shape of MoneyWithdrawn changes, you must support old versions with upcasting.

JAVA
package com.bank.eventsourcing;

import java.math.BigDecimal;

public class EventUpcaster {

    // V1 event stored amount as double (old, incorrect)
    // V2 event stores amount as BigDecimal string (correct)

    public static AccountEvent.MoneyWithdrawn upcast(String rawJson) {
        // Parse the raw JSON and check the version
        // If version 1: convert double to BigDecimal
        // If version 2: use BigDecimal directly
        System.out.println("Upcasting v1 event: amount 50000.0 -> BigDecimal 50000.00");
        return new AccountEvent.MoneyWithdrawn(
                "SAV2001",
                new BigDecimal("50000.00"),
                "Transfer to Priya",
                java.time.Instant.now()
        );
    }

    public static void main(String[] args) {
        upcast("{\"amount\": 50000.0, \"version\": 1}");
    }
}
TEXT
Expected output:
Upcasting v1 event: amount 50000.0 -> BigDecimal 50000.00

What should a senior engineer know?

Use a purpose-built event store like EventStoreDB or an append-only Kafka log with snapshots. The apply method is used both for new events and for replay, guaranteeing consistency. Monitor event store size, snapshot frequency, and projection lag.

TEXT
Production checklist:
  [x] Events are immutable, append-only
  [x] Snapshots taken every 100-500 events
  [x] Projections rebuild from events for new read models
  [x] Schema registry for event version compatibility
  [x] Upcasters for backward compatibility
  [x] Event store: EventStoreDB or Kafka with compaction
  [x] Monitor: projection lag, event store size, replay time

Visual Overview

DIAGRAM
  Event Sourcing Flow:

  Command: "Withdraw 50000 from SAV2001"
       │
       ▼
  ┌──────────────┐     ┌──────────────────────────────┐
  │   Account    │     │       Event Store             │
  │  Aggregate   │────▶│  1. AccountOpened(1000000)    │
  │              │     │  2. MoneyWithdrawn(50000)     │
  │ validate()   │     │  3. MoneyDeposited(20000)     │
  │ apply(event) │     │  ... append-only, immutable   │
  └──────────────┘     └──────────────┬───────────────┘
                                      │
                          ┌───────────┴───────────┐
                          │                       │
                          ▼                       ▼
                   ┌──────────────┐        ┌──────────────┐
                   │  Projection  │        │   Snapshot   │
                   │  (read model)│        │  (version N) │
                   │  balance view│        │  balance:    │
                   │  for queries │        │  970000.00   │
                   └──────────────┘        └──────────────┘

Key Takeaway

Event Sourcing is a persistence pattern where the source of truth is an append-only, ordered log of immutable domain events rather than the current state. Instead of storing a row saying the balance is 970000, you store every event: opened with 1000000, withdrew 50000 for Priya's transfer, deposited 20000 from salary interest. The current balance is derived by replaying those events.

The advantages are compelling for banking. You get a complete, tamper-evident audit trail for free, which regulators at National Bank of India demand. You can answer temporal queries like what Rajesh's balance was at midnight on month-end by replaying only up to that timestamp. You can fix bugs and replay corrected logic, and you can build entirely new read models later by replaying the same events into a new projection. The disadvantages are equally real: it is more complex than CRUD, querying requires separate projections, and event schema evolution is hard because events are immutable forever.

At National Bank of India, auditors once could not explain why Rajesh's balance dropped by 50000 on a given day because the account table had overwritten the old value. Pankaj moved that ledger to Event Sourcing so every change became an appended event, and after that they could reconstruct any historical balance. The team pairs Event Sourcing with CQRS so the append-only write log feeds denormalized read projections, and in production they use EventStoreDB with periodic snapshots.

Remember one sentence: Event Sourcing persists every state change as an immutable, ordered event and derives current state by replaying the log, trading extra complexity for a perfect audit trail, time travel, and the freedom to build new read models from the same history.

---

Share
Use ← → arrow keys to navigate