Event Sourcing in .NET Core
← All articles

Event Sourcing in .NET Core

This article is part of the Comprehensive Guide to Microservices Architecture in .NET Core, Cloud and Azure series.

Benefits of Event Sourcing

Complete Audit Trail: Every change to the system is captured as an immutable event, providing a comprehensive history of what happened, when, and why.

Time Travel Capabilities: You can reconstruct the state of any entity at any point in time by replaying events up to that moment, invaluable for debugging and compliance.

Event Replay for Debugging: Reproduce issues by replaying the exact sequence of events that caused a problem, making debugging significantly easier.

Multiple Read Models: Generate different read models (projections) from the same event stream, optimizing queries for different use cases without duplicating write logic.

Implementation with Marten

Marten is a .NET library that provides event sourcing and document database capabilities on top of PostgreSQL. Below is a complete implementation of event sourcing for an order management system.

Installing Marten

dotnet add package Marten

Defining the Aggregate Root

The aggregate root applies events to build its state:

public class Order
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public List<OrderLine> Lines { get; private set; } = new();
    public decimal TotalAmount { get; private set; }
    
    // Event application methods
    public void Apply(OrderCreated @event)
    {
        Id = @event.OrderId;
        CustomerId = @event.CustomerId;
        Status = OrderStatus.Created;
    }
    
    public void Apply(OrderLineAdded @event)
    {
        Lines.Add(new OrderLine
        {
            ProductId = @event.ProductId,
            Quantity = @event.Quantity,
            Price = @event.Price
        });
        TotalAmount += @event.Quantity * @event.Price;
    }
    
    public void Apply(OrderConfirmed @event)
    {
        Status = OrderStatus.Confirmed;
    }
    
    public void Apply(OrderCancelled @event)
    {
        Status = OrderStatus.Cancelled;
    }
}

Defining Events

Events are immutable records that represent state changes:

public record OrderCreated(Guid OrderId, Guid CustomerId, DateTime CreatedAt);
public record OrderLineAdded(Guid ProductId, int Quantity, decimal Price);
public record OrderConfirmed(DateTime ConfirmedAt);
public record OrderCancelled(string Reason, DateTime CancelledAt);

Configuring Marten

Set up Marten with event sourcing and projection support:

builder.Services.AddMarten(options =>
{
    options.Connection(builder.Configuration.GetConnectionString("Marten"));
    
    // Register event types
    options.Events.AddEventType<OrderCreated>();
    options.Events.AddEventType<OrderLineAdded>();
    options.Events.AddEventType<OrderConfirmed>();
    options.Events.AddEventType<OrderCancelled>();
    
    // Configure projections for read models
    options.Projections.Add<OrderProjection>(ProjectionLifecycle.Inline);
});

Writing Events with Command Handlers

Command handlers append events to the event stream:

public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly IDocumentSession _session;
    
    public CreateOrderHandler(IDocumentSession session)
    {
        _session = session;
    }
    
    public async Task<Guid> Handle(
        CreateOrderCommand request, 
        CancellationToken cancellationToken)
    {
        var orderId = Guid.NewGuid();
        
        // Start a new event stream for the order
        _session.Events.StartStream<Order>(
            orderId,
            new OrderCreated(orderId, request.CustomerId, DateTime.UtcNow)
        );
        
        // Append additional events to the stream
        foreach (var item in request.Items)
        {
            _session.Events.Append(
                orderId,
                new OrderLineAdded(item.ProductId, item.Quantity, item.Price)
            );
        }
        
        await _session.SaveChangesAsync(cancellationToken);
        return orderId;
    }
}

Reading State with Query Handlers

Query handlers rebuild aggregates by replaying events:

public class GetOrderHandler : IRequestHandler<GetOrderQuery, Order>
{
    private readonly IDocumentSession _session;
    
    public GetOrderHandler(IDocumentSession session)
    {
        _session = session;
    }
    
    public async Task<Order> Handle(
        GetOrderQuery request,
        CancellationToken cancellationToken)
    {
        // Marten automatically rebuilds the aggregate from its event stream
        var order = await _session.Events
            .AggregateStreamAsync<Order>(request.OrderId, token: cancellationToken);
            
        return order;
    }
}

Creating Projections for Read Models

Projections transform events into optimized read models:

public class OrderProjection : EventProjection
{
    // Create initial read model from first event
    public OrderReadModel Create(OrderCreated @event)
    {
        return new OrderReadModel
        {
            Id = @event.OrderId,
            CustomerId = @event.CustomerId,
            Status = "Created",
            CreatedAt = @event.CreatedAt
        };
    }
    
    // Update read model as new events occur
    public void Apply(OrderLineAdded @event, OrderReadModel model)
    {
        model.ItemCount++;
        model.TotalAmount += @event.Quantity * @event.Price;
    }
    
    public void Apply(OrderConfirmed @event, OrderReadModel model)
    {
        model.Status = "Confirmed";
        model.ConfirmedAt = @event.ConfirmedAt;
    }
}

Time Travel Queries

One of the most powerful features of event sourcing is the ability to query historical state:

public async Task<Order> GetOrderAtPointInTime(Guid orderId, DateTime timestamp)
{
    // Replay events only up to the specified timestamp
    return await _session.Events
        .AggregateStreamAsync<Order>(
            orderId,
            timestamp: timestamp);
}

Key Considerations

When to Use Event Sourcing:

  • Systems requiring complete audit trails (financial, healthcare, compliance)
  • Applications needing temporal queries and historical analysis
  • Domains with complex business logic where understanding the "why" matters
  • Systems that benefit from multiple read models

Challenges:

  • Increased complexity compared to traditional CRUD
  • Event schema evolution requires careful planning
  • Higher storage requirements due to event retention
  • Learning curve for developers unfamiliar with the pattern

Best Practices:

  • Keep events immutable and focused on business facts
  • Version your events to handle schema changes
  • Use projections for query optimization
  • Implement snapshots for aggregates with long event histories
  • Consider eventual consistency implications