
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
Challenges:
Before implementing Event Sourcing, it's essential to answer these questions:
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
To build an Event Sourcing system, focus on these key components:
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.
Event Stream — An ordered sequence of events, each with a version in the stream. There are different ways to organize streams:
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.
Snapshots — Optional state saves for aggregates, used to optimize state restoration. Snapshots are not required but can improve performance when replaying event histories.
To build an aggregate state from events in a stream, follow these steps:
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:
Apply
method increases Amount
by the deposit amount.Apply
method decreases Amount
by the corresponding amount.
This approach allows you to restore the account's state at any given version by sequentially applying each event up to the desired version.
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);
}
balance-1-snapshot
.
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.
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.
In distributed systems, it's crucial to ensure that changes across various data stores or services are consistent. Solutions include:
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.
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.