Part 2: Event Sourcing in Action
Recap
In Part 1 of this series, I explored the foundational principles and historical context of Event Sourcing, highlighting its evolution and relevance in modern software systems.
In this second part, I will build on these foundational concepts and dive deeper into a practical implementation of a simple account management system. Using both a conventional CRUD approach as well as an Event Sourcing approach. I will expand on the pitfalls of the traditional CRUD implementation and how an Event Sourced solution solves many of these problems.
Key Concepts and Terminology
Before diving into the code, it is crucial to understand the following common terminology in Event Sourcing:
Event: A single unit of data that indicates an action occurred directly, as a result of an external command, or internally from an automatically generated command i.e. Account Created, Funds Deposited, Funds Withdrawn etc.
Command: An intent to alter the state of the system. A successful command should result in appending one or more events to the event store i.e. Create Account, Deposit Funds, Withdraw Funds etc.
Event Store: An optimized database management tool for storing events within a system.
Aggregate: A stateful domain model that can be altered by applying one or more events from the event store. The application of events for an aggregate represents the sum of incremental changes for a domain model, up to and including the final event applied.
Code Samples
In my GitHub repository, I present a straightforward Account management system that allows you to open, close, deposit, and withdraw money from an account. This solution, written in C#, demonstrates principles that are easily transferable to most programming languages. I encourage you to explore the code, provide feedback, and contribute to its development. Your engagement and contributions are highly valued!
You can clone the repo, debug the respective project, and step through each line in the Program.cs
file. Alternatively, you can follow the explanation I outline below.
In the first example (BasicCrudExample
), I have designed the system using traditional CRUD operations. In the second (BasicEventSourcingExample
), I have designed the system using simple Event Sourcing techniques.
The database I used as a traditional RDBMS and an Event Store is a simple in-memory DbContext (InMemoryDbContext
) from Microsoft.EntityFrameworkCore, for simplicity's sake. In both examples below, the InMemoryDbContext
code contains general database configuration and setup. The majority has been omitted for brevity.
Basic Crud Example
Setup
For the BasicCrudExample
, the database contains a table with Account
entries:
public interface IAuditable
{
public DateTime LastModifiedDate { get; set; }
public DateTime CreatedDate { get; set; }
}
public record Account : IAuditable
{
public Guid Id { get; set; }
public required decimal Balance { get; set; }
public DateTime LastModifiedDate { get; set; }
public DateTime CreatedDate { get; set; }
}
Stored directly as entities by EF Core:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Account>(
account =>
{
account.HasIndex(a => a.Id)
.IsUnique();
account.Property(a => a.Id)
.ValueGeneratedOnAdd();
}
);
}
Context Overview
To interact with the database in a more "user-centric" way, I created an AccountContext
to control state updates. The methods on the AccountContext
interact directly with entities stored in the RDBMS via the _dbContext
, as is expected with CRUD operations:
public Account OpenAccount()
{
var account = new Account
{
Balance = 0
};
_dbContext.Add(account);
_dbContext.SaveChanges();
return account;
}
public Account? GetAccountById(Guid id)
{
return _dbContext.Accounts.SingleOrDefault(a => a.Id == id);
}
public Account Deposit(Guid accountId, decimal amount)
{
if (amount <= 0)
{
throw new ArgumentException("Amount must be positive.");
}
var account = GetAccountById(accountId) ??
throw new Exception($"Account with Id '{accountId}' does not exist");
account.Balance += amount;
_dbContext.SaveChanges();
return account;
}
public Account Withdraw(Guid accountId, decimal amount)
{
if (amount <= 0)
{
throw new ArgumentException("Amount must be positive.");
}
var account = GetAccountById(accountId) ??
throw new Exception($"Account with Id '{accountId}' does not exist");
var newBalance = account.Balance - amount;
if (newBalance < 0)
{
throw new Exception("Not enough funds for Withdrawal");
}
account.Balance = newBalance;
_dbContext.SaveChanges();
return account;
}
Practical Workflow
Let's begin by opening an Account
:
Let's deposit some money into the Account
:
Let's make our first withdrawal:
Let's make another withdrawal:
Assuming we have left the Account
for some time and a series of financial transactions have occurred. Let's view the current state of the Account
:
This approach raises the following questions:
What was the initial deposited amount?
Has the
Account
ever gone into overdraft?What if we need new business logic before any actions are performed on the
Account
?How do we prevent conflicting transactions? i.e. two withdrawals at the same time
It should be immediately apparent that this approach is just not good enough. A superior solution must be used to maintain Account
auditability, scalability, flexibility, and concurrency.
Basic Event Sourcing Example
Setup
For the BasicEventSourcingExample
, the database stores entities of type BaseEvent
:
public interface IAccountEvent
{
public uint Id { get; set; }
public Guid AccountId { get; init; }
public uint Version { get; }
public DateTime EventDate { get; }
}
public record BaseEvent(Guid AccountId, uint Version) : IAccountEvent
{
public uint Id { get; set; }
public DateTime EventDate { get; set; }
}
Several event types are derived from BaseEvent
:
public record OpenAccountEvent(Guid AccountId, uint Version) : BaseEvent(AccountId, Version);
public record ActivateAccountEvent(Guid AccountId, uint Version) : BaseEvent(AccountId, Version);
public record DeactivateAccountEvent(Guid AccountId, uint Version) : BaseEvent(AccountId, Version);
public record DepositEvent(Guid AccountId, uint Version) : BaseEvent(AccountId, Version)
{
public required decimal Amount { get; set; }
}
public record WithdrawalEvent(Guid AccountId, uint Version) : BaseEvent(AccountId, Version)
{
public required decimal Amount { get; set; }
}
These derived events are stored by EF Core using a table-per-hierarchy and discriminator configuration:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<BaseEvent>(
baseEvent =>
{
baseEvent
.HasDiscriminator<string>("EventType")
.HasValue<DepositEvent>("DepositEvent")
.HasValue<OpenAccountEvent>("OpenAccountEvent")
.HasValue<WithdrawalEvent>("WithdrawalEvent")
.HasValue<ActivateAccountEvent>("ActivateAccountEvent")
.HasValue<DeactivateAccountEvent>("DeactivateAccountEvent");
baseEvent.HasIndex(a => a.Id)
.IsUnique();
}
);
}
Context Overview
Adding some changes to the context and domain layer of the project, we can implement a seamless experience for the user while addressing all of the pain points of the BasicCrudExample
.
The AccountContext
no longer interacts directly with entities stored in the database via the _dbContext
. Methods on the AccountContext
send commands to an AccountAggregate
:
public AccountViewModel OpenAccount()
{
var accountAggregate = new AccountAggregate(_dbContext);
var newAccountId = Guid.NewGuid();
accountAggregate.HandleOpenAccountCommand(new OpenAccountCommand(newAccountId));
accountAggregate.HandleActivateAccountCommand(new ActivateAccountCommand());
return accountAggregate.GetAccountView();
}
public AccountViewModel ActivateAccount(Guid accountId)
{
var accountAggregate = new AccountAggregate(_dbContext, accountId);
accountAggregate.HandleActivateAccountCommand(new ActivateAccountCommand());
return accountAggregate.GetAccountView();
}
public AccountViewModel DeactivateAccount(Guid accountId)
{
var accountAggregate = new AccountAggregate(_dbContext, accountId);
accountAggregate.HandleDeactivateAccountCommand(new DeactivateAccountCommand());
return accountAggregate.GetAccountView();
}
public AccountViewModel Deposit(Guid accountId, decimal amount)
{
var accountAggregate = new AccountAggregate(_dbContext, accountId);
accountAggregate.HandleDepositCommand(new DepositCommand(amount));
return accountAggregate.GetAccountView();
}
public AccountViewModel Withdraw(Guid accountId, decimal amount)
{
var accountAggregate = new AccountAggregate(_dbContext, accountId);
accountAggregate.HandleWithdrawalCommand(new WithdrawalCommand(amount));
return accountAggregate.GetAccountView();
}
public AccountViewModel GetAccountById(Guid accountId)
{
return new AccountAggregate(_dbContext, accountId).GetAccountView();
}
public List<BaseEvent> GetAllEvents()
{
return _dbContext.BaseEvents.ToList();
}
Commands are handled within the AccountAggregate
:
public void HandleOpenAccountCommand(OpenAccountCommand openAccountCommand)
{
if (Version != 0)
{
throw new ArgumentException("Account has already been opened.");
}
var openAccountEvent = new OpenAccountEvent(openAccountCommand.AccountId, NextVersion());
PersistAndApplyEvent(openAccountEvent);
}
public void HandleActivateAccountCommand(ActivateAccountCommand _)
{
if (Active)
{
throw new ArgumentException("Account is already activated.");
}
var activateAccountEvent = new ActivateAccountEvent(AccountId, NextVersion());
PersistAndApplyEvent(activateAccountEvent);
}
public void HandleDeactivateAccountCommand(DeactivateAccountCommand _)
{
if (!Active)
{
throw new ArgumentException("Account is already deactivated.");
}
var deactivateAccountEvent = new DeactivateAccountEvent(AccountId, NextVersion());
PersistAndApplyEvent(deactivateAccountEvent);
}
public void HandleDepositCommand(DepositCommand depositCommand)
{
if (!Active)
{
throw new ArgumentException("Account is not active.");
}
var depositAmount = depositCommand.Amount;
if (depositAmount <= 0)
{
throw new ArgumentException("Amount must be positive.");
}
var depositEvent = new DepositEvent(AccountId, NextVersion())
{
Amount = depositAmount
};
PersistAndApplyEvent(depositEvent);
}
public void HandleWithdrawalCommand(WithdrawalCommand withdrawalCommand)
{
if (!Active)
{
throw new ArgumentException("Account is not active.");
}
var withdrawalAmount = withdrawalCommand.Amount;
if (withdrawalAmount <= 0)
{
throw new ArgumentException("Amount must be positive.");
}
var newBalance = Balance - withdrawalAmount;
if (newBalance < 0)
{
throw new Exception("Not enough funds for Withdrawal");
}
var withdrawalEvent = new WithdrawalEvent(AccountId, NextVersion())
{
Amount = withdrawalAmount
};
PersistAndApplyEvent(withdrawalEvent);
}
Events created from successful command handling are persisted to the Event Store via the _dbContext
. These events are also applied to the current state directly after persisting them, which maintains consistency:
private void Apply(OpenAccountEvent openAccountEvent)
{
AccountId = openAccountEvent.AccountId;
CreatedDate = openAccountEvent.EventDate;
UpdateAuditProperties(openAccountEvent);
}
private void Apply(ActivateAccountEvent activateAccountEvent)
{
Active = true;
UpdateAuditProperties(activateAccountEvent);
}
private void Apply(DeactivateAccountEvent deactivateAccountEvent)
{
Active = false;
UpdateAuditProperties(deactivateAccountEvent);
}
private void Apply(DepositEvent depositEvent)
{
Balance += depositEvent.Amount;
UpdateAuditProperties(depositEvent);
}
private void Apply(WithdrawalEvent withdrawalEvent)
{
Balance -= withdrawalEvent.Amount;
UpdateAuditProperties(withdrawalEvent);
}
private void UpdateAuditProperties(IAccountEvent accountEvent)
{
LastModifiedDate = accountEvent.EventDate;
Version = accountEvent.Version;
}
private void PersistAndApplyEvent(BaseEvent baseEvent)
{
PersistEvent(baseEvent);
ApplyEvent(baseEvent);
}
private void PersistEvent(BaseEvent baseEvent)
{
_dbContext.Add(baseEvent);
_dbContext.SaveChanges();
}
private void ApplyEvent(BaseEvent baseEvent)
{
switch (baseEvent)
{
case OpenAccountEvent @event:
Apply(@event);
break;
case DepositEvent @event:
Apply(@event);
break;
case WithdrawalEvent @event:
Apply(@event);
break;
case ActivateAccountEvent @event:
Apply(@event);
break;
case DeactivateAccountEvent @event:
Apply(@event);
break;
}
}
private void ApplyEvents(IEnumerable<BaseEvent> baseEvents)
{
foreach (var baseEvent in baseEvents)
{
ApplyEvent(baseEvent);
}
}
Additionally, events are applied when rebuilding the AccountAggregate
to re-hydrate the state of the Aggregate:
public AccountAggregate(InMemoryDbContext dbContext, Guid accountId)
{
_dbContext = dbContext;
var baseEvents = _dbContext.BaseEvents
.Where(e => e.AccountId == accountId)
.OrderBy(a => a.Version)
.ToList();
if (baseEvents.Count < 0)
{
throw new Exception($"Account with Id '{accountId}' does not exist");
}
ApplyEvents(baseEvents);
}
Practical Workflow
Following the same workflow as the BasicCrudExample
, let's begin by opening an Account
:
Let's deposit some money into the Account
:
Let's make our first withdrawal:
Let's make another withdrawal:
Let's view the current state of the Account
:
Isn't that exactly what the BasicCrudExample
can return? Yes, but with Event Sourcing we can also return a full audit log of every event that occurred against the AccountAggregate
:
Answering the questions that the BasicCrudExample
couldn't:
Do you notice anything that's missing? - No
What was the initial deposited amount? - 2000
Has the
Account
ever gone into overdraft? - NoWhat if we need new business logic before any actions are performed on the
Account
? - We can simply extend the rules within each handle method on theAccountAggregate
following SOLID principles.How do we prevent conflicting transactions? i.e. two withdrawals at the same time? - Event versioning allows developers to ensure strong consistency. By marking the version as a unique column on the database, if two transactions attempt to add an event with a version of 10 to the event store at the same time, the first one to complete will be saved successfully, the second will attempt to save a
BaseEvent
with a version that already exists in the event store (10) and the database will reject this transaction.
Conclusion
Event Sourcing is a powerful architectural pattern that records state changes as a series of immutable events, offering benefits like auditability, reproducibility, scalability, and flexibility. By comparing traditional CRUD operations with Event Sourcing through practical examples, we see how Event Sourcing provides a superior solution for maintaining data integrity and consistency.
Join the Journey
In this series, I aim to take you through the journey of learning and implementing the Event Sourcing architectural pattern, sharing the insights and knowledge I’ve gained so far.
Each post will highlight key concepts, practical implementations, and real-world applications, providing a comprehensive understanding of Event Sourcing. As I continue to learn and explore this fascinating architectural pattern, I’ll document my discoveries and experiences, adding value by showing how to overcome challenges and apply best practices.
Whether you’re a beginner or looking to deepen your understanding, this series will guide you through the essentials and beyond, making the learning process engaging and informative.
Subscribe to my newsletter
Read articles from Brett Fleischer directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by