Event sourcing is an architectural pattern that revolves around the idea of treating state changes as a sequence of immutable events. Instead of storing the current state of your data, you store a full sequence of events that led to that state. At first glance, this might seem like an unnecessary layer of complexity, but it’s an approach that can fundamentally change how you build, scale, and maintain complex systems.
Traditional applications typically store only the latest state of data (CRUD approach), updating or overwriting records as needed. This works well for many scenarios, but has downsides at scale:
With event sourcing, instead of overwriting data, you store every change as an immutable event in a log. The current state can be rebuilt by replaying these events anytime. This approach provides a complete audit trail, allows you to restore previous states, and supports easy integration with other systems by publishing event streams.
Event sourcing is a specialized pattern that can improve scalability, auditability, and performance, but it comes with significant complexity. Switching to event sourcing transforms how your system manages data and is best suited for applications with high scalability needs or where a detailed history of changes is essential. For most systems, this added complexity may not be necessary.
To understand Event Sourcing, it's essential to grasp its fundamental building blocks. These components work together to create a robust and auditable system.
An event is an immutable record of a significant business action that has occurred. Instead of representing the state of an entity, it represents a change. For example, OrderCreated
, ItemAddedToCart
, and PaymentProcessed
are all events. Each event contains the necessary data to describe the change.
Key characteristics of an event:
The Event Store is the backbone of an Event Sourcing system. It's a specialized database that persists events in the order they occurred. Unlike a traditional database that stores the current state, the Event Store is an append-only log of events.
The Event Store's primary responsibilities are:
An Aggregate is a core concept from Domain-Driven Design (DDD) that fits perfectly with Event Sourcing. It represents a transactional boundary, a cluster of domain objects that can be treated as a single unit. In an Event Sourcing context, an aggregate's state is derived by replaying a stream of events.
When an aggregate receives a command, it validates it, and if the command is valid, it produces one or more events. The aggregate's state is represented by these events and derived by applying the events in the order they occurred.
A Command is a request to perform an action that changes the state of the system. For example, CreateOrder
, AddItemToCart
, and ProcessPayment
are commands. Commands are sent to aggregates, which then decide whether to accept or reject them.
Key differences between a Command and an Event:
While the Event Store is the single source of truth for writes, it's not optimized for queries. Replaying events every time you need to query the state of the system would be highly inefficient. This is where Projections come in.
A Projection is a read-optimized view of the data that is generated by listening to the stream of events and updating a separate read model (often in a different database, like a relational or NoSQL database). You can have multiple projections of the same data, each tailored for a specific query or use case. This is one of the most powerful features of Event Sourcing, providing immense flexibility for your read models.
Event Sourcing naturally leads to CQRS, which separates the write model from the read model:
Key Benefits of CQRS with Event Sourcing:
In our banking example below, the AccountAggregate
represents the command side (write model), while the AccountBalanceProjection
represents the query side (read model).
Let's implement a simplified banking system using Event Sourcing. We'll model account operations where commands are processed by aggregates that generate events, and projections build read models from these events.
First, we'll define the commands (intents) and events (facts) using sealed interfaces and records. For more details on sealed classes and pattern matching, check out our Java 23 Pattern Matching blog:
// Commands - represent intents to perform actions public sealed interface Command permits CreateAccount, DepositMoney, WithdrawMoney, TransferMoney {} public record CreateAccount(String accountId, String owner, double initialBalance) implements Command {} public record DepositMoney(String accountId, double amount) implements Command {} public record WithdrawMoney(String accountId, double amount) implements Command {} public record TransferMoney(String fromAccountId, String toAccountId, double amount) implements Command {} // Events - represent facts that have occurred public sealed interface Event permits AccountCreated, MoneyDeposited, MoneyWithdrawn, InsufficientFunds { String accountId(); LocalDateTime timestamp(); } public record AccountCreated(String accountId, String owner, double initialBalance, LocalDateTime timestamp) implements Event { public AccountCreated(String accountId, String owner, double initialBalance) { this(accountId, owner, initialBalance, LocalDateTime.now()); } } public record MoneyDeposited(String accountId, double amount, LocalDateTime timestamp) implements Event { public MoneyDeposited(String accountId, double amount) { this(accountId, amount, LocalDateTime.now()); } } public record MoneyWithdrawn(String accountId, double amount, LocalDateTime timestamp) implements Event { public MoneyWithdrawn(String accountId, double amount) { this(accountId, amount, LocalDateTime.now()); } } public record InsufficientFunds(String accountId, double requestedAmount, double availableBalance, LocalDateTime timestamp) implements Event { public InsufficientFunds(String accountId, double requestedAmount, double availableBalance) { this(accountId, requestedAmount, availableBalance, LocalDateTime.now()); } }
Next, we need a simple in-memory Event Store. In a real-world application, you'd use a persistent data store like a dedicated event store database or a relational/NoSQL database.
public class EventStore { private final List<Event> events = new ArrayList<>(); public void appendEvent(Event event) { events.add(event); } public List<Event> getEvents(String accountId) { return events.stream() .filter(event -> event.getAccountId().equals(accountId)) .collect(Collectors.toList()); } }
Account Aggregate is a class that represents the state of an account. It is responsible for processing commands and generating events(processCommand
method). It is also responsible for rebuilding the state of the account from the event history(fromHistory
method).
public class AccountAggregate { private String accountId; private String owner; private double balance; public AccountAggregate(String accountId, String owner, double initialBalance) { this.accountId = accountId; this.owner = owner; this.balance = initialBalance; } // Load from event history public static AccountAggregate fromHistory(String accountId, List<Event> events) { AccountAggregate aggregate = new AccountAggregate(); events.forEach(aggregate::apply); return aggregate; } private AccountAggregate() {} // Command processing methods public List<Event> processCommand(Command command) { switch (command) { case CreateAccount(String accountId, String owner, double initialBalance) -> { return List.of(new AccountCreated(accountId, owner, initialBalance)); } case DepositMoney(String accountId, double amount) -> { MoneyDeposited event = new MoneyDeposited(accountId, amount); return List.of(event); } case WithdrawMoney(String accountId, double amount) -> { if (this.balance < amount) { return List.of(new InsufficientFunds(accountId, amount, this.balance)); } MoneyWithdrawn event = new MoneyWithdrawn(accountId, amount); return List.of(event); } } } // Apply events to rebuild state public void apply(Event event) { switch (event) { case AccountCreated(String accountId, String owner, double initialBalance, LocalDateTime timestamp) -> { this.accountId = accountId; this.owner = owner; this.balance = initialBalance; } case MoneyDeposited(String accountId, double amount, LocalDateTime timestamp) -> { this.balance += amount; } case MoneyWithdrawn(String accountId, double amount, LocalDateTime timestamp) -> { this.balance -= amount; } case InsufficientFunds(String accountId, double requestedAmount, double availableBalance, LocalDateTime timestamp) -> { // No state change for insufficient funds } } } // Getters public String getAccountId() { return accountId; } public String getOwner() { return owner; } public double getBalance() { return balance; } }
Projections listen to events and build optimized read models. This represents the Query Side of CQRS - denormalized, read-only views optimized for specific query patterns. Here's an example of building an account balance projection:
// Read model for account balances public record AccountBalance(String accountId, String owner, double balance, LocalDateTime lastUpdated) {} // Projection that builds account balance views from events public class AccountBalanceProjection { private final Map<String, AccountBalance> accountBalances = new HashMap<>(); public void handle(Event event) { switch (event) { case AccountCreated(String accountId, String owner, double initialBalance, LocalDateTime timestamp) -> { accountBalances.putIfAbsent(accountId, new AccountBalance(accountId, owner, initialBalance, timestamp)); } case MoneyDeposited(String accountId, double amount, LocalDateTime timestamp) -> { accountBalances.computeIfPresent(accountId, (key, current) -> new AccountBalance(accountId, current.owner(), current.balance() + amount, timestamp)); } case MoneyWithdrawn(String accountId, double amount, LocalDateTime timestamp) -> { accountBalances.computeIfPresent(accountId, (key, current) -> new AccountBalance(accountId, current.owner(), current.balance() - amount, timestamp)); } case InsufficientFunds(String accountId, double requestedAmount, double availableBalance, LocalDateTime timestamp) -> { // No balance change for insufficient funds } } } public AccountBalance getAccountBalance(String accountId) { return accountBalances.get(accountId); } public List<AccountBalance> getAllAccountBalances() { return new ArrayList<>(accountBalances.values()); } }
Here's the main service that orchestrates command processing and event handling:
public class BankingService { private final EventStore eventStore; private final AccountBalanceProjection projection; public BankingService(EventStore eventStore, AccountBalanceProjection projection) { this.eventStore = eventStore; this.projection = projection; } public void processCommand(Command command) { List<Event> events = switch (command) { case CreateAccount(String accountId, String owner, double initialBalance) -> { AccountAggregate aggregate = new AccountAggregate(accountId, owner, initialBalance); yield aggregate.processCommand(command); } case DepositMoney(String accountId, double amount) -> { AccountAggregate aggregate = loadAggregate(accountId); yield aggregate.processCommand(command); } case WithdrawMoney(String accountId, double amount) -> { AccountAggregate aggregate = loadAggregate(accountId); yield aggregate.processCommand(command); } case TransferMoney(String fromAccountId, String toAccountId, double amount) -> { // First, attempt withdrawal from source account AccountAggregate fromAggregate = loadAggregate(fromAccountId); List<Event> withdrawalEvents = fromAggregate.processCommand(new WithdrawMoney(fromAccountId, amount)); // If withdrawal successful, deposit to destination account if (withdrawalEvents.stream().noneMatch(e -> e instanceof InsufficientFunds)) { AccountAggregate toAggregate = loadAggregate(toAccountId); List<Event> depositEvents = toAggregate.processCommand(new DepositMoney(toAccountId, amount)); // Combine all events List<Event> allEvents = new ArrayList<>(withdrawalEvents); allEvents.addAll(depositEvents); yield allEvents; } yield withdrawalEvents; } }; // Store events and update projections events.forEach(event -> { eventStore.appendEvent(event); projection.handle(event); }); } private AccountAggregate loadAggregate(String accountId) { List<Event> events = eventStore.getEvents(accountId); return AccountAggregate.fromHistory(accountId, events); } public AccountBalance getAccountBalance(String accountId) { return projection.getAccountBalance(accountId); } }
The following diagram illustrates how the event sourcing flow works in the above implementation.
This diagram shows:
Now let us see how we can coordinate the above components to create a complete application.
public class BankingApplication { public static void main(String[] args) { // Initialize components EventStore eventStore = new EventStore(); AccountBalanceProjection projection = new AccountBalanceProjection(); BankingService bankingService = new BankingService(eventStore, projection); // 1. Create accounts bankingService.processCommand(new CreateAccount("ACC001", "John Doe", 1000.0)); bankingService.processCommand(new CreateAccount("ACC002", "Jane Smith", 500.0)); // 2. Perform transactions bankingService.processCommand(new DepositMoney("ACC001", 200.0)); bankingService.processCommand(new WithdrawMoney("ACC001", 150.0)); // 3. Transfer money between accounts bankingService.processCommand(new TransferMoney("ACC001", "ACC002", 300.0)); // 4. Try to withdraw more than available (should fail gracefully) bankingService.processCommand(new WithdrawMoney("ACC001", 1000.0)); // 5. Query current balances from projections System.out.println("Account Balances:"); System.out.println(bankingService.getAccountBalance("ACC001")); // Balance: 650.0 System.out.println(bankingService.getAccountBalance("ACC002")); // Balance: 800.0 // 6. Rebuild state from events (demonstrates event sourcing) List<Event> acc001Events = eventStore.getEvents("ACC001"); AccountAggregate rebuiltAccount = AccountAggregate.fromHistory("ACC001", acc001Events); System.out.println("Rebuilt account balance: " + rebuiltAccount.getBalance()); // 650.0 // 7. Show all events for audit trail System.out.println("\nEvent History for ACC001:"); acc001Events.forEach(event -> System.out.println(event)); } }
This demonstrates the complete Event Sourcing workflow:
While it's valuable to understand how to implement Event Sourcing from scratch, in a real-world project, you'd likely leverage a framework to handle the boilerplate and provide additional features. Here are a couple of popular options in the JVM ecosystem:
Axon Framework is a comprehensive open source framework for building scalable, and event sourced applications. It provides building blocks for CQRS (Command Query Responsibility Segregation) and Event Sourcing, including annotations to define aggregates, commands, and events, as well as an EventStore
and EventBus
. You can find the documentation here.
Lagom is also an open source framework for building systems of Reactive microservices in Java or Scala. Lagom builds on Akka and Play, proven technologies that are in production in some of the most demanding applications today. But this has reached its end of life and is no longer maintained. Akka now provides commercial solutions for building event sourced systems. You can find more information here. You can also find the documentation for Lagom here.
Event Sourcing is a powerful pattern, but it's not a silver bullet. It's crucial to understand its trade-offs before adopting it.
Below are some guidelines to help you decide if Event Sourcing is right for your project.
Event Sourcing is more than just a data storage technique; it's a paradigm shift that can lead to more robust, scalable, and maintainable applications. By treating state changes as a sequence of immutable events, you unlock a host of benefits, from complete auditability to flexible read models.
However, as with any powerful tool, it's not the right choice for every job. The increased complexity and the need to manage eventual consistency mean that you should carefully consider the trade-offs before adopting Event Sourcing. But for complex domains where understanding the past is just as important as knowing the present, Event Sourcing is an invaluable pattern that is well worth the investment.
Get instant AI-powered summaries of YouTube videos and websites. Save time while enhancing your learning experience.