Event Sourcing in C#: Storing State as Event Streams

Event sourcing is an architectural pattern where changes to application state are stored as an immutable sequence of events rather than updating records directly in a database. This article explores the core concepts of event sourcing, examines how it differs from traditional CRUD persistence, and demonstrates how to implement an aggregate root, capture state changes, and reconstruct state from event streams using modern C#.

Understanding Event Sourcing

In a traditional relational model (CRUD), if a user changes their address, the application executes an UPDATE statement that overwrites the existing row. The previous state is lost unless explicit audit logging is implemented.

Event sourcing approaches persistence differently. Instead of storing the current state, the application persists the full history of domain events that led to that state. Each event represents a business fact that occurred in the past (e.g., UserRegistered, AddressChanged, OrderPlaced). Because events represent history, they are append-only and immutable; once written, an event is never updated or deleted.

The current state of any entity is derived by replaying its historical stream of events in chronological order from the beginning.

Key Concepts

  • Domain Event: An immutable object representing something that happened in the domain.
  • Event Stream: The ordered sequence of events associated with a specific entity instance (aggregate).
  • Aggregate Root: The domain object responsible for validating commands, producing new events, and applying those events to update its internal state.
  • Rehydration: The process of instantiating an aggregate and replaying its event stream to rebuild its current state.

Implementing Event Sourcing in C

Modern C# features, such as record types, make defining and handling events concise and robust.

1. Define Domain Events

Events should be lightweight, immutable data structures representing business facts:

public interface IEvent
{
    Guid AggregateId { get; }
    DateTime OccurredOn { get; }
}

public record BankAccountCreated(Guid AccountId, string Owner, decimal InitialDeposit, DateTime OccurredOn) : IEvent
{
    public Guid AggregateId => AccountId;
}

public record MoneyDeposited(Guid AccountId, decimal Amount, DateTime OccurredOn) : IEvent
{
    public Guid AggregateId => AccountId;
}

public record MoneyWithdrawn(Guid AccountId, decimal Amount, DateTime OccurredOn) : IEvent
{
    public Guid AggregateId => AccountId;
}

2. Create the Aggregate Root

The aggregate enforces business invariants, stages uncommitted events, and updates its internal state via event handlers:

public class BankAccount
{
    private readonly List<IEvent> _uncommittedEvents = new();

    public Guid Id { get; private set; }
    public string Owner { get; private set; } = string.Empty;
    public decimal Balance { get; private set; }
    public int Version { get; private set; }

    public IReadOnlyCollection<IEvent> UncommittedEvents => _uncommittedEvents.AsReadOnly();

    public BankAccount() { }

    // Command: Open Account
    public static BankAccount Open(Guid id, string owner, decimal initialDeposit)
    {
        if (initialDeposit < 0)
            throw new ArgumentException("Initial deposit cannot be negative.");

        var account = new BankAccount();
        account.RaiseEvent(new BankAccountCreated(id, owner, initialDeposit, DateTime.UtcNow));
        return account;
    }

    // Command: Deposit
    public void Deposit(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Deposit amount must be positive.");

        RaiseEvent(new MoneyDeposited(Id, amount, DateTime.UtcNow));
    }

    // Command: Withdraw
    public void Withdraw(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Withdrawal amount must be positive.");

        if (Balance < amount)
            throw new InvalidOperationException("Insufficient funds.");

        RaiseEvent(new MoneyWithdrawn(Id, amount, DateTime.UtcNow));
    }

    // Record and apply event
    private void RaiseEvent(IEvent @event)
    {
        Apply(@event);
        _uncommittedEvents.Add(@event);
    }

    // Mutate internal state
    private void Apply(IEvent @event)
    {
        switch (@event)
        {
            case BankAccountCreated e:
                Id = e.AccountId;
                Owner = e.Owner;
                Balance = e.InitialDeposit;
                break;
            case MoneyDeposited e:
                Balance += e.Amount;
                break;
            case MoneyWithdrawn e:
                Balance -= e.Amount;
                break;
        }

        Version++;
    }

    // Replay historical events
    public void LoadFromHistory(IEnumerable<IEvent> history)
    {
        foreach (var @event in history)
        {
            Apply(@event);
        }
    }

    public void ClearUncommittedEvents() => _uncommittedEvents.Clear();
}

3. Storing and Appending to the Stream

An event store writes new events to an append-only store and fetches existing streams by aggregate identifier:

public interface IEventStore
{
    Task AppendEventsAsync(Guid aggregateId, IEnumerable<IEvent> events, int expectedVersion);
    Task<IReadOnlyList<IEvent>> GetStreamAsync(Guid aggregateId);
}

public class InMemoryEventStore : IEventStore
{
    private readonly Dictionary<Guid, List<IEvent>> _streams = new();

    public Task AppendEventsAsync(Guid aggregateId, IEnumerable<IEvent> events, int expectedVersion)
    {
        if (!_streams.TryGetValue(aggregateId, out var stream))
        {
            stream = new List<IEvent>();
            _streams[aggregateId] = stream;
        }

        if (stream.Count != expectedVersion)
        {
            throw new InvalidOperationException("Concurrency conflict detected.");
        }

        stream.AddRange(events);
        return Task.CompletedTask;
    }

    public Task<IReadOnlyList<IEvent>> GetStreamAsync(Guid aggregateId)
    {
        if (_streams.TryGetValue(aggregateId, out var stream))
        {
            return Task.FromResult<IReadOnlyList<IEvent>>(stream.AsReadOnly());
        }

        return Task.FromResult<IReadOnlyList<IEvent>>(Array.Empty<IEvent>());
    }
}

4. Executing Commands and Rehydrating State

The complete workflow involves fetching past events, rebuilding aggregate state, executing logic, and appending new events back to the stream:

public class BankAccountService
{
    private readonly IEventStore _eventStore;

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

    public async Task ProcessDepositAsync(Guid accountId, decimal amount)
    {
        // 1. Fetch historical event stream
        var history = await _eventStore.GetStreamAsync(accountId);
        if (!history.Any())
            throw new KeyNotFoundException("Account does not exist.");

        // 2. Rehydrate aggregate to current state
        var account = new BankAccount();
        account.LoadFromHistory(history);
        var originalVersion = account.Version;

        // 3. Execute domain logic
        account.Deposit(amount);

        // 4. Persist uncommitted events
        await _eventStore.AppendEventsAsync(accountId, account.UncommittedEvents, originalVersion);
        account.ClearUncommittedEvents();
    }
}

Advantages and Trade-offs

Event sourcing provides a 100% accurate audit trail by default, enables time-travel debugging (reconstructing the exact state at any timestamp), and eliminates object-relational mapping impedance mismatches.

However, it introduces architectural complexity. Querying data across streams requires read-model projections (often implemented using CQRS), and long-lived streams may necessitate snapshots to prevent slow aggregate rehydration over time.