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
- The core idea of Event Sourcing
- The Event Store
- State reconstruction (replay)
- CQRS: separating read and write
- Projections and read models
- Complete example of bank account events
- Common mistakes and tips
- Exercises and solutions
- Conclusion
- 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:
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 |
- 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_idgroups all the events of a single entity. To reconstruct account 42, we read all events withstream_id = 'account-42'ordered byversion.versionis the ordering number within the stream. TheUNIQUE (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
UPDATEorDELETEon this table; onlyINSERT.
- 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:
rehydratestarts from an emptyAccountand applies each event sequentially. The result is the current state.- The
applymethod 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.
- 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| RMAdvantages 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.
- 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_viewis 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.
- 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
sealedinterface guarantees that theswitchinapply(section 3) covers all possible cases; if you add a new event, the compiler forces you to handle it. - The
withdrawmethod 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
- You have an account with the events:
AccountOpened(0),MoneyDeposited(200),MoneyWithdrawn(70),MoneyDeposited(30). What is the balance after rehydration? Explain the process. - 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?
- Design the denormalized read table
movements_viewthat allows displaying an account's statement (date, type, amount, resulting balance) without replaying events on each query.
Solutions
- Balance = 0 + 200 − 70 + 30 = 160. We start from an empty account and apply the events in order:
AccountOpenedsets 0,MoneyDeposited(200)adds up to 200,MoneyWithdrawn(70)drops to 130,MoneyDeposited(30)rises to 160. - 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.
- 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
- What Is Application Architecture?
- The Role of the Software Architect
- Quality Attributes and Non-Functional Requirements
- Architectural Decisions and Trade-offs
- Architecture Documentation: Views and the C4 Model
Module 2: Design Principles and Tactics
- Coupling, Cohesion and Separation of Concerns
- SOLID Principles Applied to Architecture
- DRY, KISS, YAGNI and Other Design Principles
- Architectural Tactics for Quality Attributes
- Managing Technical Debt
Module 3: Architectural Styles and Patterns
- Monolithic Architecture
- Layered Architecture (N-Tier)
- Client-Server Architecture
- Hexagonal Architecture (Ports and Adapters)
- Clean and Onion Architecture
Module 4: Distributed Architectures and Microservices
- Introduction to Distributed Systems
- Microservices Architecture
- Service Decomposition and Bounded Contexts
- API Gateway, Service Discovery and Inter-Service Communication
- Resilience Patterns: Circuit Breaker, Retry and Bulkhead
- The CAP Theorem and Data Consistency
Module 5: Event-Driven Architectures and Messaging
- Fundamentals of Event-Driven Architecture
- Asynchronous Messaging: Queues and Brokers
- Event Patterns: Event Sourcing and CQRS
- Managing Distributed Transactions: The Saga Pattern
- Real-Time Data Streaming
Module 6: Domain-Driven Design (DDD)
- Core DDD Concepts
- Strategic Design: Bounded Contexts and Ubiquitous Language
- Tactical Design: Entities, Aggregates and Repositories
- Context Mapping
Module 7: Data and Persistence
- Persistence Strategies: SQL vs NoSQL
- Data Access Patterns: Repository, Unit of Work and DAO
- Database per Service and Distributed Data Management
- Caching and Invalidation Strategies
Module 8: Cloud Architecture and Deployment
- Cloud Computing Fundamentals (IaaS, PaaS, SaaS)
- Containers and Orchestration with Docker and Kubernetes
- Serverless Architecture
- Cloud-Native Design Patterns
- Infrastructure as Code (IaC)
Module 9: Quality, Security and Observability
- Scalability: Horizontal vs Vertical and Load Balancing
- High Availability and Fault Tolerance
- Security by Design and Authentication/Authorization
- Observability: Logging, Metrics and Tracing
- Performance and Load Testing
