Event Sourcing Basics in .NET

In a traditional application, you store the current state of an entity. When something changes, you overwrite the old state with the new. Event sourcing takes a fundamentally different approach: instead of storing state, you store the sequence of events that produced that state.

The Core Concept

An Order isn't a row in a table with columns for status, total, and customer. It's a stream of events:

  1. OrderCreated { CustomerId, CreatedAt }
  2. LineAdded { Product: "Widget", Quantity: 3, Price: 10.00 }
  3. LineAdded { Product: "Gadget", Quantity: 1, Price: 25.00 }
  4. OrderConfirmed { ConfirmedAt }

To get the current state, you replay these events from the beginning. The event stream is the source of truth — hence "event sourcing."

Defining Events

Events are immutable records of things that happened:

Example.cs
public interface IDomainEvent
{
    Guid Id { get; }
    DateTime OccurredAt { get; }
}

public record OrderCreated(
    Guid Id, Guid OrderId, Guid CustomerId, DateTime OccurredAt) : IDomainEvent;

public record OrderLineAdded(
    Guid Id, Guid OrderId, string Product,
    int Quantity, decimal UnitPrice, DateTime OccurredAt) : IDomainEvent;

public record OrderConfirmed(
    Guid Id, Guid OrderId, DateTime OccurredAt) : IDomainEvent;

public record OrderCancelled(
    Guid Id, Guid OrderId, string Reason, DateTime OccurredAt) : IDomainEvent;

Events are named in the past tense — they describe something that already happened.

The Aggregate

An event-sourced aggregate applies events to build its state:

Order.cs
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; } = [];
    public decimal Total => Lines.Sum(l => l.Quantity * l.UnitPrice);

    private readonly List<IDomainEvent> _pendingEvents = [];
    public IReadOnlyList<IDomainEvent> PendingEvents => _pendingEvents;

    // For rehydration
    public Order() { }

    // Public behaviour methods raise events
    public static Order Create(Guid customerId)
    {
        var order = new Order();
        order.Raise(new OrderCreated(
            Guid.NewGuid(), Guid.NewGuid(), customerId, DateTime.UtcNow));
        return order;
    }

    public void AddLine(string product, int quantity, decimal unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Cannot modify a confirmed order.");

        Raise(new OrderLineAdded(
            Guid.NewGuid(), Id, product, quantity, unitPrice, DateTime.UtcNow));
    }

    public void Confirm()
    {
        if (Lines.Count == 0)
            throw new InvalidOperationException("Cannot confirm an empty order.");

        Raise(new OrderConfirmed(Guid.NewGuid(), Id, DateTime.UtcNow));
    }

    // Apply methods update state
    public void Apply(OrderCreated e)
    {
        Id = e.OrderId;
        CustomerId = e.CustomerId;
        Status = OrderStatus.Draft;
    }

    public void Apply(OrderLineAdded e)
    {
        Lines.Add(new OrderLine(e.Product, e.Quantity, e.UnitPrice));
    }

    public void Apply(OrderConfirmed e)
    {
        Status = OrderStatus.Confirmed;
    }

    private void Raise(IDomainEvent @event)
    {
        _pendingEvents.Add(@event);
        ((dynamic)this).Apply((dynamic)@event);
    }
}

The Apply methods are pure state transitions. The public methods enforce business rules and then raise events.

Storing Events with Marten

Marten is a .NET library that uses PostgreSQL as both a document database and an event store:

Program.cs
builder.Services.AddMarten(options =>
{
    options.Connection(connectionString);
    options.Events.StreamIdentity = StreamIdentity.AsGuid;
});

Saving events:

PlaceOrderHandler.cs
public class PlaceOrderHandler
{
    private readonly IDocumentSession _session;

    public PlaceOrderHandler(IDocumentSession session) => _session = session;

    public async Task<Guid> Handle(PlaceOrderCommand command, CancellationToken ct)
    {
        var order = Order.Create(command.CustomerId);

        foreach (var line in command.Lines)
            order.AddLine(line.Product, line.Quantity, line.UnitPrice);

        _session.Events.StartStream<Order>(order.Id, order.PendingEvents.ToArray());
        await _session.SaveChangesAsync(ct);

        return order.Id;
    }
}

Loading an aggregate:

Example.cs
var order = await _session.Events.AggregateStreamAsync<Order>(orderId, token: ct);

Marten replays all events for the stream and calls the Apply methods to reconstruct the aggregate.

Projections

You rarely query event streams directly. Instead, you build projections — read-optimised views derived from events:

OrderSummaryProjection.cs
public class OrderSummaryProjection : SingleStreamProjection<OrderSummary>
{
    public void Apply(OrderCreated e, OrderSummary view)
    {
        view.Id = e.OrderId;
        view.CustomerId = e.CustomerId;
        view.Status = "Draft";
        view.CreatedAt = e.OccurredAt;
    }

    public void Apply(OrderLineAdded e, OrderSummary view)
    {
        view.LineCount++;
        view.Total += e.Quantity * e.UnitPrice;
    }

    public void Apply(OrderConfirmed e, OrderSummary view)
    {
        view.Status = "Confirmed";
    }
}

Projections can be rebuilt from scratch at any time by replaying the full event history.

When to Use Event Sourcing

Event sourcing provides a complete audit trail, enables temporal queries ("what did this order look like yesterday?"), and supports building multiple read models from the same events. It's a natural fit for financial systems, compliance-heavy domains, and systems where understanding the history of changes is as important as the current state.

However, it adds significant complexity. Simple queries become harder, eventual consistency must be managed, and event versioning requires careful thought. Don't adopt event sourcing unless you need the specific benefits it provides.