Skip to content

Event Sourcing Guide

This guide demonstrates how to implement event-sourced aggregates in the RIVORA Framework.

Overview

Event Sourcing persists domain events instead of current state. The current state of an aggregate is reconstructed by replaying its events. This provides a complete audit trail and enables temporal queries.

Step 1: Define Domain Events

Events are immutable records that describe something that happened:

csharp
public record AccountOpened(
    Guid AccountId,
    Guid CustomerId,
    string AccountType,
    decimal InitialDeposit,
    DateTime OpenedAt) : IDomainEvent;

public record MoneyDeposited(
    Guid AccountId,
    decimal Amount,
    string Description,
    DateTime DepositedAt) : IDomainEvent;

public record MoneyWithdrawn(
    Guid AccountId,
    decimal Amount,
    string Description,
    DateTime WithdrawnAt) : IDomainEvent;

public record AccountClosed(
    Guid AccountId,
    string Reason,
    DateTime ClosedAt) : IDomainEvent;

TIP

Name events in past tense -- they describe facts that already happened.

Step 2: Create the Aggregate

The aggregate raises events to change state and applies them to update internal properties:

csharp
public class BankAccount : AggregateRoot
{
    public Guid CustomerId { get; private set; }
    public string AccountType { get; private set; } = string.Empty;
    public decimal Balance { get; private set; }
    public AccountStatus Status { get; private set; }

    // Public constructor -- creates a new account
    public BankAccount(Guid customerId, string accountType, decimal initialDeposit)
    {
        if (initialDeposit < 0)
            throw new ArgumentException("Initial deposit cannot be negative.");

        RaiseEvent(new AccountOpened(
            Guid.NewGuid(), customerId, accountType, initialDeposit, DateTime.UtcNow));
    }

    // Private constructor -- used for rehydration from events
    private BankAccount() { }

    public void Deposit(decimal amount, string description)
    {
        if (Status != AccountStatus.Active)
            throw new InvalidOperationException("Cannot deposit to a closed account.");
        if (amount <= 0)
            throw new ArgumentException("Deposit amount must be positive.");

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

    public void Withdraw(decimal amount, string description)
    {
        if (Status != AccountStatus.Active)
            throw new InvalidOperationException("Cannot withdraw from a closed account.");
        if (amount <= 0)
            throw new ArgumentException("Withdrawal amount must be positive.");
        if (amount > Balance)
            throw new InvalidOperationException("Insufficient funds.");

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

    public void Close(string reason)
    {
        if (Status == AccountStatus.Closed)
            throw new InvalidOperationException("Account is already closed.");
        if (Balance != 0)
            throw new InvalidOperationException("Account balance must be zero before closing.");

        RaiseEvent(new AccountClosed(Id, reason, DateTime.UtcNow));
    }

    // Event handlers -- update internal state
    private void Apply(AccountOpened e)
    {
        Id = e.AccountId;
        CustomerId = e.CustomerId;
        AccountType = e.AccountType;
        Balance = e.InitialDeposit;
        Status = AccountStatus.Active;
    }

    private void Apply(MoneyDeposited e)
    {
        Balance += e.Amount;
    }

    private void Apply(MoneyWithdrawn e)
    {
        Balance -= e.Amount;
    }

    private void Apply(AccountClosed e)
    {
        Status = AccountStatus.Closed;
    }
}

public enum AccountStatus { Active, Closed }

Step 3: Use the Event Store

csharp
public class BankAccountService
{
    private readonly IEventStore _eventStore;

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

    public async Task<BankAccount> OpenAccountAsync(
        Guid customerId, string accountType, decimal initialDeposit, CancellationToken ct)
    {
        var account = new BankAccount(customerId, accountType, initialDeposit);

        await _eventStore.SaveEventsAsync(
            account.Id,
            account.UncommittedEvents,
            expectedVersion: 0,
            ct);

        account.ClearUncommittedEvents();
        return account;
    }

    public async Task<BankAccount> GetAccountAsync(Guid accountId, CancellationToken ct)
    {
        var events = await _eventStore.GetEventsAsync(accountId, ct);

        if (!events.Any())
            throw new KeyNotFoundException($"Account {accountId} not found.");

        var account = new BankAccount();
        account.LoadFromHistory(events);
        return account;
    }

    public async Task DepositAsync(
        Guid accountId, decimal amount, string description, CancellationToken ct)
    {
        var account = await GetAccountAsync(accountId, ct);
        account.Deposit(amount, description);

        await _eventStore.SaveEventsAsync(
            accountId,
            account.UncommittedEvents,
            expectedVersion: account.Version,
            ct);

        account.ClearUncommittedEvents();
    }

    public async Task WithdrawAsync(
        Guid accountId, decimal amount, string description, CancellationToken ct)
    {
        var account = await GetAccountAsync(accountId, ct);
        account.Withdraw(amount, description);

        await _eventStore.SaveEventsAsync(
            accountId,
            account.UncommittedEvents,
            expectedVersion: account.Version,
            ct);

        account.ClearUncommittedEvents();
    }
}

Step 4: Register Services

csharp
// In Program.cs
builder.Services.AddRvrEventSourcing(options =>
{
    options.UseInMemoryStore();  // Development
    // options.UseSqlServerStore(connectionString);  // Production
});

Building Read Models (Projections)

Event sourcing separates writes (events) from reads (projections). Build read models by subscribing to events:

csharp
public class AccountBalanceProjection
{
    private readonly IProjectionStore _store;

    public async Task HandleAsync(MoneyDeposited @event)
    {
        var balance = await _store.GetAsync<AccountBalanceView>(@event.AccountId);
        balance.Balance += @event.Amount;
        balance.LastTransactionAt = @event.DepositedAt;
        await _store.SaveAsync(balance);
    }

    public async Task HandleAsync(MoneyWithdrawn @event)
    {
        var balance = await _store.GetAsync<AccountBalanceView>(@event.AccountId);
        balance.Balance -= @event.Amount;
        balance.LastTransactionAt = @event.WithdrawnAt;
        await _store.SaveAsync(balance);
    }
}

// Read model -- optimized for queries
public class AccountBalanceView
{
    public Guid AccountId { get; set; }
    public decimal Balance { get; set; }
    public DateTime LastTransactionAt { get; set; }
}

Temporal Queries

Replay events up to a specific point in time to see historical state:

csharp
public async Task<BankAccount> GetAccountAtAsync(Guid accountId, DateTime pointInTime, CancellationToken ct)
{
    var allEvents = await _eventStore.GetEventsAsync(accountId, ct);

    var eventsUpToDate = allEvents
        .Where(e => e.Timestamp <= pointInTime)
        .ToList();

    var account = new BankAccount();
    account.LoadFromHistory(eventsUpToDate);
    return account;

    // account.Balance reflects the balance at `pointInTime`
}

Released under the MIT License.