Event Sourcing Explained with code examples in Java

    Event Sourcing Explained with code examples in Java

    18/10/2025

    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.

    What is the problem with the traditional CRUD approach?

    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:

    • Performance: Performance can degrade with heavy write loads and data contention.
    • Scalability: Locks and synchronous updates become bottlenecks under high concurrency.
    • Auditability: The history of changes is lost unless a separate audit log is maintained.

    Solution

    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.

    Core Concepts of Event Sourcing

    To understand Event Sourcing, it's essential to grasp its fundamental building blocks. These components work together to create a robust and auditable system.

    Write Side (Commands) Command Aggregate (Command Processor) Event Store Generates Event Read Side (Queries) Query Projection Read Model Updates

    1. Events

    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:

    • Immutable: Once an event is created, it can never be changed or deleted.
    • Past Tense: Events are named in the past tense because they represent something that has already happened.
    • Data-Rich: An event should contain all the relevant information about the state change.

    2. Event Store

    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:

    • Appending Events: Storing new events in a sequential and atomic manner.
    • Reading Event Streams: Retrieving the sequence of events for a specific aggregate or entity.

    3. Aggregates

    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.

    4. Commands

    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:

    • Intent vs. Fact: A command represents an intent to do something, while an event represents something that has already happened.
    • Can be Rejected: A command can be rejected by an aggregate if it's invalid, while an event is a fact and cannot be rejected.

    5. Projections

    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.

    6. CQRS (Command Query Responsibility Segregation)

    Event Sourcing naturally leads to CQRS, which separates the write model from the read model:

    • Command Side (Write Model): Handles commands, validates business rules, and generates events. This is where your aggregates live and where business logic is enforced.
    • Query Side (Read Model): Consists of projections that build optimized views for queries. These are denormalized, read-only views that can be stored in different databases optimized for specific query patterns.

    Key Benefits of CQRS with Event Sourcing:

    • Independent Scaling: Scale read and write operations independently
    • Optimized Storage: Use different databases for different query patterns (SQL for reports, NoSQL for analytics, Elasticsearch for search)
    • Performance: Read models can be highly optimized without affecting write performance
    • Flexibility: Create multiple views of the same data for different use cases

    In our banking example below, the AccountAggregate represents the command side (write model), while the AccountBalanceProjection represents the query side (read model).

    Banking System Implementation

    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.

    1. Defining Commands and 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()); } }

    2. The Event Store

    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()); } }

    2. The Command Processor (Account Aggregate)

    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; } }

    3. Projections for Read Models (CQRS Query Side)

    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()); } }

    4. Banking Service - Orchestrating Commands and Events

    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.

    Banking System Event Sourcing Flow 1 Command CreateAccount DepositMoney WithdrawMoney 2 Aggregate AccountAggregate Validates & Processes 3 Events AccountCreated MoneyDeposited MoneyWithdrawn 4 Event Store Immutable Event Log Append Only 5 Projections AccountBalance Projection Read Models Processes Generates Stores Updates Example: Transfer $300 from ACC001 to ACC002 TransferMoney ("ACC001", "ACC002", 300) Command MoneyWithdrawn ("ACC001", 300) Event MoneyDeposited ("ACC002", 300) Event Event Store [Event1, Event2...] Immutable Log Account Balance ACC001: $650 ACC002: $800 State Reconstruction from Events AccountAggregate fromHistory("ACC001", events) → Replays all events → Current balance: $650 Event History 1. AccountCreated (1000) 2. MoneyDeposited (200) 3. MoneyWithdrawn (150) Current State Balance: $1050 Owner: John Doe AccountId: ACC001 Read Model Projection maintains optimized view for fast queries Loads Applies Updates

    This diagram shows:

    • Command Processing: Commands flow through the aggregate which validates and processes them
    • Event Generation: Valid commands generate immutable events
    • Event Storage: Events are stored in the append-only event store
    • Projection Updates: Projections listen to events and update read models
    • State Reconstruction: Current state can be rebuilt by replaying events
    • Example Flow: A concrete example of transferring money between accounts

    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:

    1. Commands are sent to the banking service
    2. Aggregates(Command Processor) process commands and generate events
    3. Events are stored in the event store
    4. Projections build optimized read models
    5. State can be rebuilt from events at any time
    6. Audit trail is maintained automatically

    Popular JVM Frameworks for Event Sourcing

    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.

    Pros

    • Complete Audit Trail: By storing every state change as an event, you get a complete, immutable history of your system. This is invaluable for auditing, debugging, and business intelligence.
    • Temporal Queries: You can reconstruct the state of an aggregate at any point in time, which is a powerful feature for historical analysis and troubleshooting.
    • Flexible Read Models: The separation of the write model (the Event Store) and read models (Projections) allows you to create multiple, optimized views of your data without affecting the write side.
    • Improved Performance and Scalability: By using an append-only log for writes and separate read models, you can optimize both for their specific use cases, leading to better performance and scalability.
    • Loose Coupling: Event Sourcing promotes a loosely coupled architecture, especially in microservices, where services can subscribe to event streams and react to changes without being directly coupled.

    Cons

    • Increased Complexity: Event Sourcing is more complex than traditional state management. It requires a different way of thinking and introduces new concepts that the team needs to learn.
    • Event Versioning: As your application evolves, your event schemas will likely change. Handling different versions of events can be challenging and requires a clear strategy.
    • Eventual Consistency: Since projections are updated asynchronously, there's a delay between when an event is written and when the read models are updated. This means you have to design your application to handle eventual consistency.
    • Tooling and Infrastructure: You'll need to invest in tooling and infrastructure to support Event Sourcing, such as an Event Store, a message bus, and a way to manage projections.
    • Querying the Event Store: The Event Store is not designed for ad-hoc queries. If you need to ask a question that your projections don't answer, you may need to create a new projection and replay all events, which can be time-consuming.

    Below are some guidelines to help you decide if Event Sourcing is right for your project.

    Use Event Sourcing When...

    • Your domain is complex and state transitions are important. If your application has rich business logic and you need to understand how an entity got to its current state, Event Sourcing is a natural fit.
    • You need a strong audit log for compliance or business intelligence. Financial systems, healthcare applications, and other domains with strict auditing requirements are excellent candidates.
    • You need to query historical data. If you have use cases that require you to "go back in time" and see the state of the system at a particular point, Event Sourcing makes this straightforward.

    Avoid Event Sourcing When...

    • Your application is a simple enough to be managed by a traditional CRUD service. For applications where you're just creating, reading, updating, and deleting records, Event Sourcing is likely overkill. Also, the cost of implementing event sourcing might not be worth the benefits for such simple applications.
    • You require strong, immediate consistency for your reads. If your application cannot tolerate the eventual consistency of projections, the CQRS aspect of Event Sourcing might not be a good fit.

    Conclusion

    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.

    Summarise

    Transform Your Learning

    Get instant AI-powered summaries of YouTube videos and websites. Save time while enhancing your learning experience.

    Instant video summaries
    Smart insights extraction
    Channel tracking