Mastering Event Sourcing in C#: A Step-by-Step Guide

Event sourcing is a powerful architectural pattern that allows software engineers to track every change in an application state as a series of events, creating a more auditable and scalable system. In the .NET world, C# offers robust support for implementing event sourcing, making it a valuable addition to any developer’s skillset. This article will walk you through what event sourcing is, why it’s worth considering, and how to implement it in C# using practical examples.

Let’s dive in!

What Is Event Sourcing?

Event sourcing is a pattern where changes to an application’s state are captured as a sequence of events. Instead of storing only the current state of the data, every event that affects the state is recorded. This approach allows you to:

  • Reconstruct the application state at any point in time.

  • Audit all changes and track user actions.

  • Enable temporal queries by replaying events to see the state at any historical point.

This pattern is particularly useful for applications requiring strict auditing, complex business workflows, or the ability to recover from failures gracefully.

Why Use Event Sourcing?

Event sourcing brings several advantages:

  • Historical Analysis: You can analyze the state at any point in time, which can be critical for debugging and auditing.

  • Traceability: Every event is recorded, making it easy to track what happened in the system.

  • Scalability: It’s easier to scale specific functions by processing only new events.

Getting Started: A Simple Event Sourcing Implementation in C#

Let’s break down the core components and implement a simple event-sourced application in C#.

1. Define Your Events

In event sourcing, every change to the system is recorded as an event. These events should be immutable and carry enough information to reconstruct the application state.

Let’s say we’re building a Bank Account system with operations to deposit and withdraw money. We’ll start by defining our events.

public abstract class BankAccountEvent
{
    public Guid AccountId { get; }
    public DateTime Timestamp { get; }

    protected BankAccountEvent(Guid accountId)
    {
        AccountId = accountId;
        Timestamp = DateTime.UtcNow;
    }
}

public class MoneyDeposited : BankAccountEvent
{
    public decimal Amount { get; }

    public MoneyDeposited(Guid accountId, decimal amount) : base(accountId)
    {
        Amount = amount;
    }
}

public class MoneyWithdrawn : BankAccountEvent
{
    public decimal Amount { get; }

    public MoneyWithdrawn(Guid accountId, decimal amount) : base(accountId)
    {
        Amount = amount;
    }
}

Here, BankAccountEvent is the base class for all events related to the bank account, with MoneyDeposited and MoneyWithdrawn representing specific actions.


2. Create the Event Store

The event store is responsible for persisting events. In a real application, this might be a database, but for simplicity, let’s use an in-memory list.

public class EventStore
{
    private readonly List<BankAccountEvent> _events = new List<BankAccountEvent>();

    public void Save(BankAccountEvent bankEvent)
    {
        _events.Add(bankEvent);
    }

    public List<BankAccountEvent> GetEvents(Guid accountId)
    {
        return _events.Where(e => e.AccountId == accountId).ToList();
    }
}

The Save method allows us to persist events, while GetEvents fetches events for a specific account, enabling us to rebuild the account state.


3. Implement the Aggregate

The aggregate represents the business logic and state. The state is derived from applying events, so this object will be initialized by replaying events.

public class BankAccount
{
    public Guid AccountId { get; private set; }
    public decimal Balance { get; private set; }

    public BankAccount(Guid accountId)
    {
        AccountId = accountId;
        Balance = 0;
    }

    public void Apply(BankAccountEvent bankEvent)
    {
        switch (bankEvent)
        {
            case MoneyDeposited deposit:
                Balance += deposit.Amount;
                break;
            case MoneyWithdrawn withdrawal:
                Balance -= withdrawal.Amount;
                break;
        }
    }
}

Here, the Apply method takes an event and applies it to the account’s state. Every time we fetch the account, we can replay the events to get its current state.


4. Building the Service Layer

Now, we’ll add a service layer to handle operations on the bank account, such as depositing and withdrawing money.

public class BankAccountService
{
    private readonly EventStore _eventStore;

    public BankAccountService(EventStore eventStore)
    {
        _eventStore = eventStore;
    }

    public void Deposit(Guid accountId, decimal amount)
    {
        var depositEvent = new MoneyDeposited(accountId, amount);
        _eventStore.Save(depositEvent);
    }

    public void Withdraw(Guid accountId, decimal amount)
    {
        var withdrawEvent = new MoneyWithdrawn(accountId, amount);
        _eventStore.Save(withdrawEvent);
    }

    public BankAccount GetAccount(Guid accountId)
    {
        var account = new BankAccount(accountId);
        var events = _eventStore.GetEvents(accountId);

        foreach (var bankEvent in events)
        {
            account.Apply(bankEvent);
        }

        return account;
    }
}

The BankAccountService manages deposits and withdrawals by creating events and storing them in the EventStore. The GetAccount method then uses the stored events to reconstruct the current account state.


5. Putting It All Together

Let’s test our event-sourced system.

class Program
{
    static void Main(string[] args)
    {
        var accountId = Guid.NewGuid();
        var eventStore = new EventStore();
        var accountService = new BankAccountService(eventStore);

        // Deposit and withdraw money
        accountService.Deposit(accountId, 100);
        accountService.Withdraw(accountId, 50);

        // Retrieve and display account state
        var account = accountService.GetAccount(accountId);
        Console.WriteLine($"Account ID: {account.AccountId}");
        Console.WriteLine($"Balance: {account.Balance}");
    }
}

This script deposits $100 and then withdraws $50. Finally, it retrieves the account to display the current balance, calculated by replaying the events.


Extending the Example: Auditing with Event Sourcing

A key benefit of event sourcing is auditing. By querying events for a given account, we can easily generate a transaction history:

public void PrintTransactionHistory(Guid accountId)
{
    var events = _eventStore.GetEvents(accountId);
    foreach (var bankEvent in events)
    {
        switch (bankEvent)
        {
            case MoneyDeposited deposit:
                Console.WriteLine($"Deposited: {deposit.Amount} at {deposit.Timestamp}");
                break;
            case MoneyWithdrawn withdrawal:
                Console.WriteLine($"Withdrew: {withdrawal.Amount} at {withdrawal.Timestamp}");
                break;
        }
    }
}

This simple method prints each event, offering a detailed transaction history that shows each operation and when it occurred.


Conclusion

Event sourcing can seem complex, but its benefits for scalability, auditing, and historical analysis make it an invaluable pattern. By capturing each change as an event, we gain flexibility to handle complex systems, trace back to past states, and provide valuable insights. This C# example shows the power of the pattern in a simple context, but it’s easily adaptable to larger applications. Give it a try, and see how event sourcing can transform your development approach!

0
Subscribe to my newsletter

Read articles from Onyinyechi Onyenaucheya directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Onyinyechi Onyenaucheya
Onyinyechi Onyenaucheya

I am a Software Engineer with experience in designing and developing scalable software solutions using modern technologies such as .NET Core, React.js, AWS, and Azure DevOps. I am currently a Systems Engineer at Creditsafe Group, United Kingdom, where I focus on building secure and efficient applications within cross-functional teams. My work involves cloud-based application development, performance optimization, and the implementation of best practices in software engineering. I have contributed to several projects, including the development of a fintech payment application and a real-time ticket tracking solution that improved user engagement within an organization. My professional interests include software architecture, cloud computing, and secure application development.