I am a Lead Software Engineer at Innovecs, where it's common practice for our team to share professional insights and experiences. Recently, I delivered a lecture to my colleagues on Event Sourcing, and I realized that this introductory information could be valuable to a broader audience. This article is useful for those interested in the concept of Event Sourcing and who want to decide if it's a good fit for their projects while avoiding common pitfalls. So, let's dive in.

Event Sourcing is an approach where, instead of storing the current state of the system, all changes are saved as events, which become the main data source. The approach gained popularity around 2005, after Martin Fowler’s article on the topic.

The main idea is that instead of storing and updating the application’s state, you save the events that describe changes. Events act as the core source of information. This differs from the traditional approach, where the current state is saved and updated with each change. In Event Sourcing, each change is logged as a new event instead of modifying an existing record.

For example, in an app where users can edit their profiles, the traditional approach uses an "Update" command to modify the existing database record. With Event Sourcing, instead of "Update," we use "Insert" — adding a new entry to an event log that records the change.

Events include a user identifier (StreamID), the event name, version, and new data, such as a new username. This creates an "append-only" log, an immutable record where each change is a separate event.

This method preserves the full change history, simplifying auditing, recovery, and data analysis. Event Sourcing Pros and Cons

Benefits of Event Sourcing:

  1. Observability: You can track all changes, create an audit log, and restore data states at any point in time.
  2. System Decomposition: Supports asynchronous interactions between system components and microservices.
  3. Enhanced Fault Tolerance: Reduces data loss risk, especially when there are multiple Event Store replicas.
  4. Easy Data Migration: Data can be easily transferred between databases by replaying events.

Challenges:

  1. Complex Implementation and Maintenance: Event Sourcing isn’t always the best fit.
  2. Potential Overengineering: It’s not suitable for every project.

Before implementing Event Sourcing, it's essential to answer these questions:

  1. Can events be considered part of my app's domain? Determine if you plan to use events for asynchronous tasks, like sending emails, or for breaking down the system into microservices that interact through events.
  2. Can events help me develop and maintain my app? Consider whether event storage will support your development and maintenance needs.
  3. Do I need to know about every change in my app? Evaluate if you need to track every change or if there are storage limitations for tracking all operations.

With these questions in mind, you can decide if event sourcing is suitable for your application.

For example, in financial apps where account balances frequently change, events can efficiently reflect these adjustments. Each balance change links to events like deposits or withdrawals. This approach enables you to recreate the account's state at any given time by applying the sequence of events, each with a specific type and logic.

Here are some examples of the approach across different domains:

FinTech

Supply Chain

Healthcare

Event Sourcing Building Blocks

To build an Event Sourcing system, focus on these key components:

  1. Event Store — A storage solution for all events in the system, grouped into streams with unique IDs and ordered versions. For example, EventStoreDB is a popular choice. The Event Store organizes events into streams, each with an ID and a chronological list of events for a particular entity.

2. Aggregates — A Domain-Driven Design (DDD) concept that groups multiple objects into a whole, each with a unique ID. For a financial app, an aggregate could be an account that ties together balances and cards, while transactions might form a separate aggregate. The root of an aggregate is the access point for managing changes.

  1. Events — Each event has a unique ID, version, and type, and is immutable. For example, a transaction event can be represented in JSON with fields like version, name (e.g., “deposit”), amount, currency, and other details.

  1. Event Stream — An ordered sequence of events, each with a version in the stream. There are different ways to organize streams:

    • Single Stream per Aggregate: Each stream is tied to a specific aggregate, like a “Balance” stream for account ID 1 with events only for that balance.
    • Multiple Streams for One Aggregate: You can create separate streams for different periods or categories, such as monthly balance streams.
    • Global Event Stream: Contains all events for all aggregates, ordered by time.

  2. Projections — Also called read models, query models, or view models, projections organize events into user-friendly formats, like transaction histories or account statements. They can aggregate or group events to simplify data access.

  3. Snapshots — Optional state saves for aggregates, used to optimize state restoration. Snapshots are not required but can improve performance when replaying event histories.

Building Aggregate states

To build an aggregate state from events in a stream, follow these steps:

  1. Replaying State from Events

To recreate the account's state, define a class like AccountBoundState with fields such as Amount, Currency, and other properties. The class includes an Apply method that takes an event and adjusts the state based on the event type:

This approach allows you to restore the account's state at any given version by sequentially applying each event up to the desired version.

  1. Using Projections

Projections simplify data access by creating read-only models for efficient querying. Projections can be tailored for individual events or aggregated for groups of events. Examples include:

Optimizing with Snapshots

When the number of events grows very large (millions or more), replaying all events becomes inefficient. Snapshots help by saving the aggregate's state at specific versions, so you can start from the latest snapshot rather than from the beginning.

var snapshot = snapshots.LastOrDefault();
state.LoadFromSnapshot(snapshot);
foreach (var domainEvent in orderedDomainEvents.Where(x => x.Version > snapshot.Version))
{
    state.Apply(domainEvent);
}

Snapshot Creation Strategy

Decide how often to create snapshots based on system usage:

Overall, using snapshots significantly speeds up aggregate state recovery in large systems.

Main Challenges in Event Sourcing and Their Solutions

  1. Concurrency Updates

In distributed systems with multiple microservices and parallel access to data, concurrency updates and distributed transaction issues arise. The primary approaches to handling concurrency updates include:

In event sourcing, rather than a mutable state, there’s an event stream (append-only log). This avoids concurrency update issues since new events are only appended to the end of logs. If each new event needs to depend on the previous one, optimistic version control is used, naturally supported by many event sourcing implementations. If dependency between events is unimportant (e.g., adding items to an order in an online store), events can be added without version checking.

  1. Distributed Transactions

In distributed systems, it's crucial to ensure that changes across various data stores or services are consistent. Solutions include:

  1. Projection Synchronization

When a system uses projections for data reads, it’s essential to ensure their consistency and synchronization with the main event store. This can be achieved with:

These approaches allow for flexibility in meeting specific system requirements, maintaining consistency and high performance in distributed environments.

  1. Optimizing Read Process and State Computation

Event sourcing involves replaying the state from all events, which can be resource-intensive for large streams. Key optimization techniques include:

So, while Event Sourcing presents challenges such as handling distributed transactions and concurrency control, these can be effectively managed through established patterns and best practices. By thoughtfully implementing this concept, teams can build applications that are more resilient, maintainable, and scalable, making it a valuable asset in modern software development.