# Event Sourcing in .NET: Aggregates, Event Stores, and Projections

> Build an event-sourced aggregate, an append-only event store, and a replayable read model in C#. This learning implementation makes version checks and duplicate delivery explicit, then explains what durable storage and snapshots add.

Published: 2026-09-22. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/event-sourcing-dotnet-beginners-guide

**Event sourcing** stores an ordered history of events as the authoritative record of an aggregate.
In .NET, commands validate business rules, events update state, and an event store appends those events only if the loaded stream version still matches.
Projections build queryable views, while snapshots can reduce the cost of loading long histories.

## What Are We Building?

An order can move through `OrderCreated`, `LineItemAdded`, and `OrderConfirmed` events.
Loading it means replaying that history in order.
For the design tradeoffs, start with the [**introduction to event sourcing**](https://milanjovanovic.tech/blog/introduction-to-event-sourcing-for-net-developers).

The following example is a complete **in-memory learning implementation**, using only the .NET standard libraries.
It demonstrates aggregate replay, atomic expected-version checks, a repository, and a projection.
The event store loses its contents on restart; the final sections explain durable persistence and snapshots.

Create a console project and put the types below in `Ordering.cs`:

```bash
dotnet new console -n EventSourcingDemo --framework net10.0
cd EventSourcingDemo
```

![A command drives the aggregate to raise and apply events, the event store appends them, replay rebuilds aggregate state, and projections turn the stream into read models](https://milanjovanovic.tech/blogs/articles/event-sourcing-dotnet-beginners-guide/event-sourcing-flow.png)

## Define Events and the Aggregate

The example prices every order in USD.
In an HTTP application, load prices from a trusted catalog before calling the aggregate; do not accept a caller-supplied price as authoritative.

```csharp
public abstract record OrderEvent(Guid OrderId);
public sealed record OrderCreated(Guid OrderId, Guid CustomerId)
    : OrderEvent(OrderId);
public sealed record LineItemAdded(Guid OrderId, Guid ProductId,
    int Quantity, decimal UnitPrice) : OrderEvent(OrderId);
public sealed record OrderConfirmed(Guid OrderId, DateTimeOffset ConfirmedAt)
    : OrderEvent(OrderId);
public sealed record OrderCancelled(Guid OrderId, string Reason)
    : OrderEvent(OrderId);

public sealed record StoredEvent(int Version, OrderEvent Data);
public enum OrderStatus { Draft, Confirmed, Cancelled }

public sealed class Order
{
    private readonly List<OrderEvent> _pending = [];
    private int _lineCount;

    private Order() { }

    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public decimal TotalUsd { get; private set; }
    public int Version { get; private set; } = -1;
    public IReadOnlyList<OrderEvent> Pending => _pending.AsReadOnly();

    public static Order Create(Guid customerId)
    {
        if (customerId == Guid.Empty)
            throw new ArgumentException("A customer is required.");

        var order = new Order();
        order.Raise(new OrderCreated(Guid.NewGuid(), customerId));
        return order;
    }

    public void AddLineItem(Guid productId, int quantity, decimal unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Only drafts accept items.");
        if (productId == Guid.Empty || quantity <= 0 || unitPrice < 0)
            throw new ArgumentException("Invalid line item.");

        Raise(new LineItemAdded(Id, productId, quantity, unitPrice));
    }

    public void Confirm(DateTimeOffset now)
    {
        if (Status != OrderStatus.Draft || _lineCount == 0)
            throw new InvalidOperationException("A nonempty draft is required.");

        Raise(new OrderConfirmed(Id, now));
    }

    public void Cancel(string reason)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(reason);
        if (Status == OrderStatus.Cancelled)
            throw new InvalidOperationException("The order is already cancelled.");

        Raise(new OrderCancelled(Id, reason));
    }

    private void Raise(OrderEvent data)
    {
        Apply(data);
        _pending.Add(data);
    }

    private void Apply(OrderEvent data)
    {
        switch (data)
        {
            case OrderCreated created:
                Id = created.OrderId;
                CustomerId = created.CustomerId;
                Status = OrderStatus.Draft;
                break;
            case LineItemAdded item:
                TotalUsd += item.Quantity * item.UnitPrice;
                _lineCount++;
                break;
            case OrderConfirmed:
                Status = OrderStatus.Confirmed;
                break;
            case OrderCancelled:
                Status = OrderStatus.Cancelled;
                break;
            default:
                throw new InvalidOperationException("Unsupported order event.");
        }
    }

    public static Order Rehydrate(IReadOnlyList<StoredEvent> history)
    {
        if (history.Count == 0 || history[0].Data is not OrderCreated)
            throw new InvalidOperationException("The stream must start with creation.");

        var order = new Order();
        foreach (var stored in history)
        {
            if (stored.Version != order.Version + 1 ||
                (order.Id != Guid.Empty && stored.Data.OrderId != order.Id) ||
                (stored.Version > 0 && stored.Data is OrderCreated))
            {
                throw new InvalidOperationException("Invalid stream history.");
            }

            order.Apply(stored.Data);
            order.Version = stored.Version;
        }
        return order;
    }

    public void MarkCommitted()
    {
        Version += _pending.Count;
        _pending.Clear();
    }
}
```

`Version` tracks the last **committed** event: `-1` means no events, and the first event has version `0`.
Raising events changes working state without advancing that version.
The repository advances it only after a successful append, so saving the same aggregate again uses the correct expected version.

Replay calls `Apply` without raising new events or rerunning current business validation.
Structural checks still reject gaps, mixed streams, and unknown event types.
Keep timestamps, external lookups, and message delivery out of `Apply` so the same history always produces the same state.

## Append With an Expected Version

Add the store to `Ordering.cs`.
The lock makes the version check and append one atomic operation within this process:

```csharp
public sealed class StreamConflictException(Guid streamId)
    : Exception($"Stream {streamId} changed after it was loaded.");

public sealed class InMemoryEventStore
{
    private readonly object _gate = new();
    private readonly Dictionary<Guid, List<StoredEvent>> _streams = [];

    public IReadOnlyList<StoredEvent> Read(Guid id)
    {
        lock (_gate)
        {
            return _streams.TryGetValue(id, out var stream)
                ? stream.ToArray()
                : Array.Empty<StoredEvent>();
        }
    }

    public void Append(Guid id, int expectedVersion,
        IReadOnlyList<OrderEvent> events)
    {
        lock (_gate)
        {
            _streams.TryGetValue(id, out var stream);
            var actualVersion = (stream?.Count ?? 0) - 1;
            if (actualVersion != expectedVersion)
                throw new StreamConflictException(id);
            if (events.Any(data => data.OrderId != id))
                throw new ArgumentException("Events belong to another stream.");
            if (events.Count == 0)
                return;

            var next = events.Select((data, index) =>
                new StoredEvent(expectedVersion + index + 1, data)).ToArray();
            stream ??= [];
            stream.AddRange(next);
            _streams[id] = stream;
        }
    }
}

public sealed class OrderRepository(InMemoryEventStore store)
{
    public Order Load(Guid id)
    {
        var history = store.Read(id);
        if (history.Count == 0)
            throw new KeyNotFoundException($"Order {id} was not found.");

        return Order.Rehydrate(history);
    }

    public void Save(Order order)
    {
        store.Append(order.Id, order.Version, order.Pending);
        order.MarkCommitted();
    }
}
```

Two callers that loaded version `3` both attempt to append version `4`.
Only one can pass the version check.
After a conflict, discard the stale aggregate, reload, and reevaluate the command; a newly confirmed order may no longer accept the requested item.

Use each aggregate instance for one command at a time.
The store supports concurrent callers, but an aggregate is a mutable working model and is not thread-safe.

## Run the Example

Replace `Program.cs` with the following:

```csharp
var store = new InMemoryEventStore();
var repository = new OrderRepository(store);

var order = Order.Create(Guid.NewGuid());
order.AddLineItem(Guid.NewGuid(), 2, 25m);
repository.Save(order); // Created=0, LineItemAdded=1.

order.Confirm(DateTimeOffset.UtcNow);
repository.Save(order); // Confirmed=2, using expected version 1.

var loaded = repository.Load(order.Id);
Console.WriteLine($"{loaded.Status}: USD {loaded.TotalUsd}, v{loaded.Version}");
```

Run it with `dotnet run`.
The result is a confirmed order for USD 50 at version 2.
The second save is intentional: it demonstrates that accepting committed events advances the aggregate version.

## Build an Idempotent Projection

A projection turns stored events into a query-oriented view.
This pure function updates one order view, ignores an already-applied version, and rejects a gap:

```csharp
public sealed record OrderView(Guid Id, Guid CustomerId,
    OrderStatus Status, decimal TotalUsd, int Version);

public static class OrderProjection
{
    public static OrderView Apply(OrderView? view, StoredEvent stored)
    {
        if (view is not null && view.Id != stored.Data.OrderId)
            throw new InvalidOperationException("Projection stream mismatch.");
        if (view is not null && stored.Version <= view.Version)
            return view;
        if (stored.Version != (view?.Version ?? -1) + 1)
            throw new InvalidOperationException("A preceding event is missing.");

        return (view, stored.Data) switch
        {
            (null, OrderCreated e) => new(e.OrderId, e.CustomerId,
                OrderStatus.Draft, 0, stored.Version),
            (not null, LineItemAdded e) => view with
            {
                TotalUsd = view.TotalUsd + e.Quantity * e.UnitPrice,
                Version = stored.Version
            },
            (not null, OrderConfirmed) => view with
            {
                Status = OrderStatus.Confirmed, Version = stored.Version
            },
            (not null, OrderCancelled) => view with
            {
                Status = OrderStatus.Cancelled, Version = stored.Version
            },
            _ => throw new InvalidOperationException("Unexpected projection event.")
        };
    }
}
```

Apply it by iterating `store.Read(order.Id)` with an initially null view.
All four event types affect the read model; leaving out line items would incorrectly show every order with a zero total.

This function demonstrates transitions and duplicate handling, not a durable subscription worker.
In a database-backed projector, save the view and its consumed version in the same transaction, with a concurrency token or single-writer ownership.
Only acknowledge delivery after that commit.

The committed checkpoint prevents a crash between applying an event and recording progress from double-counting it.
For a global subscription, also persist the event store's global cursor; versions are only ordered within each stream.
These choices matter when [**CQRS**](https://milanjovanovic.tech/blog/cqrs-pattern-with-mediatr) read models run independently of writes.

## Persist Events and Add Snapshots Carefully

A durable store needs an atomic expected-version check and append across application instances.
A PostgreSQL implementation commonly uses a unique `(stream_id, version)` constraint, plus validation that the current stream version equals the caller's expected version.
Insert a batch in one transaction so a failed append cannot leave half a command's events committed.

Classify database errors precisely.
A violation of that particular stream-version constraint signals a conflict; authentication errors and unavailable connections are different problems.
EF Core documents that [**insert uniqueness violations use provider-specific exceptions**](https://learn.microsoft.com/en-us/ef/core/saving/concurrency), rather than `DbUpdateConcurrencyException`.

Use stable event names and schema versions in persisted envelopes, not CLR assembly-qualified names.
Preserve old event meanings, and add explicit upcasters or compatible handlers when schemas evolve.
A network failure after commit has an ambiguous outcome; command IDs and deduplication are needed to prevent reapplying a business operation on retry.

A snapshot caches the aggregate's state **and committed version** at a point in its stream.
For this example that state includes the order ID, customer ID, status, total, and line count.
Restore all of them, set `Version` to the snapshot version, and then replay only events with higher versions, checking for gaps.
Starting replay from `-1` after loading a snapshot produces incorrect concurrency checks.

Snapshots need their own format version and must not contain pending events.
If an old snapshot cannot be read, discard it and replay the complete event stream.
Add this optimization only after measuring loading latency; event count alone does not determine whether it is necessary.

For a production system, evaluate [**Marten**](https://martendb.io/) or a dedicated event database instead of treating this in-memory store as a persistence layer.
You still own event design, retention, compatibility, projection recovery, and operational monitoring.

## Summary

- **Commands validate rules** before raising events; replay rebuilds state deterministically.
- **Expected versions** detect concurrent changes, and successful saves advance the committed version.
- **Projections** handle every relevant event and commit state with their checkpoint.
- **Durable storage** adds cross-process atomicity, schema evolution, and retry deduplication.
- **Snapshots** cache full committed state and a version; they are optional and rebuildable.

Event sourcing is useful when durable business history and replay justify the added storage and operational complexity.
A conventional [**aggregate**](https://milanjovanovic.tech/blog/aggregate-root-ddd) with state-based persistence is often sufficient when only current state matters.

Thanks for reading.

And stay awesome!

---

## Frequently asked questions

### How do you implement event sourcing in .NET?

Use an aggregate that validates commands and applies events, an event store with atomic expected-version checks, and a repository that replays history. Projections build read models, while snapshots optionally reduce loading time.

### How does optimistic concurrency work in an event store?

The caller supplies the last committed version it loaded. The store atomically verifies that version and appends the new events. If another writer committed first, the caller must reload and reevaluate the command.

### What is a projection in event sourcing?

A projection consumes events and builds a query-oriented view. A durable projector commits its changes with a checkpoint, handles duplicates, and preserves ordering. It can be rebuilt when the required event history is retained.

### When do you need snapshots in event sourcing?

When measured loading latency justifies caching aggregate state. A snapshot stores the complete state and committed version, so loading only replays later events. It is an optimization rather than the source of truth.

### Should you build your own event store?

An in-memory store is useful for learning. Production systems need durable atomic appends, subscriptions, compatibility, and recovery. Evaluate an established event store and configure its concurrency and projection behavior explicitly.
