Event sourcing in .NET means persisting every change to an entity as an immutable event and deriving current state by replaying those events, instead of overwriting rows in place. It is aimed at architects and senior developers building systems where history, auditability or temporal queries are first-class requirements. This guide explains event stores, streams and aggregates, projections and read models, snapshots, event versioning and upcasting, idempotency, practical implementations with Marten on PostgreSQL and with KurrentDB (formerly EventStoreDB), GDPR strategies such as crypto-shredding, and the pitfalls that make event-sourced systems painful to live with.

What Is Event Sourcing?#

In a conventional CRUD system, the database stores current state, and the story of how it got there is lost unless you add audit tables. In an event-sourced system, the story is the data. Each business fact, such as AccountOpened, MoneyDeposited or MoneyWithdrawn, is appended to an ordered stream, and nothing is ever updated or deleted. Current state is a left fold over the stream: start empty, apply each event in order.

This inversion has concrete benefits. You get a complete audit trail for free, because every change carries its intent, time and metadata. You can answer temporal questions ("what was the balance on March 31?") by replaying up to a point. You can build new read models from old events long after they happened. And writes to different streams never contend with each other, while conflicts on the same stream surface explicitly through version checks.

The costs are equally concrete: eventual consistency between writes and read models, schema evolution for data you can never rewrite, extra infrastructure for projections, and a steeper learning curve. Microsoft's Azure Architecture Center recommends applying event sourcing selectively, for example to a payment ledger or order-processing pipeline, while the rest of the system stays CRUD. Event sourcing pairs naturally with CQRS, because the event stream is an excellent write model and a poor query model.

How Event Sourcing Works#

Each aggregate instance, in the Domain-Driven Design sense, owns one stream, identified by something like account-7f3c.... Handling a command follows a fixed cycle:

  1. Load the stream and rebuild the aggregate by applying its events.
  2. Ask the aggregate to decide: validate the command against current state and return new events, or reject it.
  3. Append the new events with an expected version. If another writer appended first, the store rejects the write and the command is retried or reported as a conflict.
  4. Projections observe new events and update read models, either in the same transaction or asynchronously.

The expected-version check is the heart of consistency. It gives each stream serializable semantics without locks, which is why aggregate boundaries, and therefore stream boundaries, matter so much.

AspectState-based (CRUD)Event-sourced
Source of truthCurrent row valuesAppend-only event streams
HistoryLost unless audited separatelyComplete by construction
WritesUpdate in placeAppend with expected version
ReadsQuery the same tablesQuery projections built from events
Schema changesMigrate the tableVersion events, upcast old payloads
Deleting dataDELETENeeds a strategy: crypto-shredding, masking or external PII
Consistency of readsImmediateImmediate for inline projections, eventual for async ones

Getting Started: A Minimal Event-Sourced Aggregate in C#

Before introducing a library, it helps to see the mechanics in plain C#. Events are immutable records. The aggregate is also an immutable record: Create handles the first event, Apply methods evolve state, and decision methods such as Withdraw validate a command and return the resulting event without mutating anything.

C#
namespace Bank.Domain;

public sealed record AccountOpened(
    Guid AccountId, string Owner, decimal OverdraftLimit, DateTimeOffset OpenedAt);
public sealed record MoneyDeposited(
    Guid AccountId, decimal Amount, string Reference, DateTimeOffset At);
public sealed record MoneyWithdrawn(
    Guid AccountId, decimal Amount, string Reference, DateTimeOffset At);
public sealed record AccountClosed(Guid AccountId, string Reason, DateTimeOffset ClosedAt);

public sealed record BankAccount(
    Guid Id, string Owner, decimal Balance, decimal OverdraftLimit, bool IsClosed)
{
    // Evolve: pure functions from (state, event) to new state.
    public static BankAccount Create(AccountOpened e) =>
        new(e.AccountId, e.Owner, 0m, e.OverdraftLimit, IsClosed: false);

    public BankAccount Apply(MoneyDeposited e) => this with { Balance = Balance + e.Amount };
    public BankAccount Apply(MoneyWithdrawn e) => this with { Balance = Balance - e.Amount };
    public BankAccount Apply(AccountClosed e) => this with { IsClosed = true };

    // Decide: validate a command against current state and return new events.
    public MoneyWithdrawn Withdraw(decimal amount, string reference, DateTimeOffset now)
    {
        if (IsClosed)
            throw new InvalidOperationException("The account is closed.");
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
        if (Balance - amount < -OverdraftLimit)
            throw new InvalidOperationException("The overdraft limit would be exceeded.");

        return new MoneyWithdrawn(Id, amount, reference, now);
    }

    // Rehydrate: a left fold over the stream.
    public static BankAccount Rehydrate(IReadOnlyList<object> history) =>
        history.Skip(1).Aggregate(
            Create((AccountOpened)history[0]),
            (state, e) => e switch
            {
                MoneyDeposited d => state.Apply(d),
                MoneyWithdrawn w => state.Apply(w),
                AccountClosed c => state.Apply(c),
                _ => state // Tolerate event types this version does not know.
            });
}

Separating decisions from state changes pays off in tests, which read as given-when-then specifications of business rules:

C#
public class BankAccountTests
{
    private static readonly Guid Id = Guid.NewGuid();
    private static readonly DateTimeOffset Now = DateTimeOffset.UtcNow;

    [Fact]
    public void Withdrawal_beyond_the_overdraft_limit_is_rejected()
    {
        // Given
        var account = BankAccount.Rehydrate([
            new AccountOpened(Id, "Ada", OverdraftLimit: 100m, Now),
            new MoneyDeposited(Id, 50m, "salary", Now)]);

        // When / Then
        Assert.Throws<InvalidOperationException>(() => account.Withdraw(200m, "rent", Now));
    }
}

Event Stores: What They Must Guarantee#

An event store is any storage that provides a few non-negotiable guarantees: append-only writes, strict ordering within a stream, an atomic append of several events with an expected-version check, efficient reads of one stream, and a global ordering or position so that subscribers and projections can process "everything after checkpoint X". You can build one on a relational table with a unique constraint on stream ID and version, but the subscription, projection and operational tooling is where most of the effort goes. In .NET, two mature options dominate: Marten, which turns PostgreSQL into an event store, and KurrentDB, a database built specifically for events.

CriterionMartenKurrentDB
StoragePostgreSQL tables managed by the libraryPurpose-built event database server
LicenseMITServer under the source-available Kurrent License v1; .NET client Apache 2.0
DeploymentA library in your app plus PostgreSQLA separate server or cluster
ProjectionsInline, async (daemon) and live, written in C#Server-side projections plus client subscriptions
Documents and events in one transactionYesNo, events only
.NET supportCurrent releases target .NET 9 and .NET 10KurrentDB.Client targets .NET 8, 9 and 10 and .NET Framework 4.8
Best fitTeams already running PostgreSQLEvent-centric platforms with many subscribers

Event Sourcing with Marten on PostgreSQL#

Marten, from the JasperFx team, is both a document database and an event store on top of PostgreSQL. It creates its schema automatically in development, stores events as JSON, and provides projections, snapshots and an asynchronous projection daemon. Because the latest Marten releases target .NET 9 and .NET 10, applications still on .NET 8 need an older major version, one more reason to move to .NET 10 LTS before .NET 8 reaches end of support on November 10, 2026.

C#
builder.Services.AddMarten(options =>
{
    options.Connection(builder.Configuration.GetConnectionString("bank")!);

    // Keep the latest BankAccount state as a document, updated in the same transaction.
    options.Projections.Snapshot<BankAccount>(SnapshotLifecycle.Inline);

    // A read model updated asynchronously by the projection daemon.
    options.Projections.Add<AccountActivityProjection>(ProjectionLifecycle.Async);
})
.UseLightweightSessions()
.AddAsyncDaemon(DaemonMode.HotCold); // one active daemon per projection across nodes

For command handlers, the Marten documentation strongly recommends FetchForWriting. It loads the aggregate (from the inline snapshot when one exists) and remembers the stream version, so SaveChangesAsync throws a ConcurrencyException if another process appended in the meantime. Marten 9 also changed the default append mode, which makes FetchForWriting the safe default over hand-rolled Append calls with explicit versions.

C#
app.MapPost("/accounts", async (OpenAccountRequest request, IDocumentSession session,
    TimeProvider clock, CancellationToken ct) =>
{
    var opened = new AccountOpened(
        Guid.CreateVersion7(), request.Owner, request.OverdraftLimit, clock.GetUtcNow());

    session.Events.StartStream<BankAccount>(opened.AccountId, opened);
    await session.SaveChangesAsync(ct);
    return Results.Created($"/accounts/{opened.AccountId}", new { opened.AccountId });
});

app.MapPost("/accounts/{id:guid}/withdrawals", async (Guid id, WithdrawRequest request,
    IDocumentSession session, TimeProvider clock, CancellationToken ct) =>
{
    var stream = await session.Events.FetchForWriting<BankAccount>(id, ct);
    if (stream.Aggregate is null)
        return Results.NotFound();

    var withdrawn = stream.Aggregate.Withdraw(request.Amount, request.Reference,
        clock.GetUtcNow());
    stream.AppendOne(withdrawn);

    await session.SaveChangesAsync(ct); // ConcurrencyException on a conflicting append
    return Results.Ok(new { Balance = stream.Aggregate.Apply(withdrawn).Balance });
});

Event Sourcing with KurrentDB (Formerly EventStoreDB)#

Event Store, the company and its product, rebranded as Kurrent, and EventStoreDB is now called KurrentDB. The .NET client is the KurrentDB.Client NuGet package, which talks to the server over gRPC. Streams are named strings, events carry a type name and a byte payload, and appends take an expected state: StreamState.NoStream for a new stream, StreamState.Any to skip the check, or a specific revision for optimistic concurrency.

C#
public sealed class AccountEventStore(KurrentDBClient client)
{
    private static readonly Dictionary<string, Type> EventTypes = new()
    {
        [nameof(AccountOpened)] = typeof(AccountOpened),
        [nameof(MoneyDeposited)] = typeof(MoneyDeposited),
        [nameof(MoneyWithdrawn)] = typeof(MoneyWithdrawn),
        [nameof(AccountClosed)] = typeof(AccountClosed)
    };

    private static string StreamName(Guid id) => $"account-{id}";

    public async Task<(BankAccount? Account, ulong? Revision)> LoadAsync(
        Guid id, CancellationToken ct)
    {
        var read = client.ReadStreamAsync(Direction.Forwards, StreamName(id),
            StreamPosition.Start, cancellationToken: ct);
        if (await read.ReadState == ReadState.StreamNotFound)
            return (null, null);

        var history = new List<object>();
        ulong revision = 0;
        await foreach (var resolved in read)
        {
            var type = EventTypes[resolved.Event.EventType];
            history.Add(JsonSerializer.Deserialize(resolved.Event.Data.Span, type)!);
            revision = resolved.Event.EventNumber.ToUInt64();
        }

        return (BankAccount.Rehydrate(history), revision);
    }

    public Task AppendAsync(Guid id, ulong? expectedRevision, object @event, CancellationToken ct)
    {
        var data = new EventData(Uuid.NewUuid(), @event.GetType().Name,
            JsonSerializer.SerializeToUtf8Bytes(@event, @event.GetType()));

        // NoStream for a new account, otherwise the revision we loaded. Either way the append
        // is rejected with a wrong-expected-version error if another writer got there first.
        return expectedRevision is { } revision
            ? client.AppendToStreamAsync(StreamName(id), revision, [data], cancellationToken: ct)
            : client.AppendToStreamAsync(StreamName(id), StreamState.NoStream, [data],
                cancellationToken: ct);
    }
}

// Program.cs
builder.Services.AddSingleton(new KurrentDBClient(
    KurrentDBClientSettings.Create(builder.Configuration.GetConnectionString("kurrentdb")!)));

KurrentDB also offers catch-up subscriptions (from the start of a stream or from a saved checkpoint), persistent subscriptions with server-side consumer groups, and server-side projections. Review the server's license terms before adopting it: the Kurrent License v1 is source-available rather than OSI open source, and it restricts offering the software as a managed service.

Projections and Read Models#

A projection transforms events into a read model shaped for a query. Inline projections run in the same transaction as the append, so reads are immediately consistent but writes get slower. Async projections run in a background process that tails the global event log; writes stay fast, but reads lag slightly behind. Live aggregation builds state on demand from the stream and stores nothing, which suits rarely read or short streams.

C#
public sealed class AccountActivity
{
    public Guid Id { get; set; }
    public string Owner { get; set; } = "";
    public decimal Balance { get; set; }
    public int TransactionCount { get; set; }
    public DateTimeOffset LastActivityAt { get; set; }
}

public partial class AccountActivityProjection : SingleStreamProjection<AccountActivity, Guid>
{
    public AccountActivity Create(AccountOpened e) =>
        new() { Id = e.AccountId, Owner = e.Owner, LastActivityAt = e.OpenedAt };

    public void Apply(MoneyDeposited e, AccountActivity view)
    {
        view.Balance += e.Amount;
        view.TransactionCount++;
        view.LastActivityAt = e.At;
    }

    public void Apply(MoneyWithdrawn e, AccountActivity view)
    {
        view.Balance -= e.Amount;
        view.TransactionCount++;
        view.LastActivityAt = e.At;
    }
}

Marten's async daemon processes events in order with at-least-once semantics, using a "high water mark" so it never skips events from transactions that are still committing. In HotCold mode it uses leader election so each projection runs on exactly one node. Rebuilding a projection is a first-class operation: after fixing a bug or adding a field, replay the events into a fresh read model with dotnet run -- projections --rebuild or RebuildProjectionAsync, ideally into a new table so the old one keeps serving traffic until the rebuild catches up.

Snapshots#

Replaying a stream of a few hundred events is fast, so do not add snapshots by reflex. They pay off for long-lived streams, such as an account with years of transactions, or for aggregates loaded on every request. A snapshot is a serialized copy of the aggregate at a known version; loading reads the snapshot and then only the events after it. In Marten, Snapshot<T>(SnapshotLifecycle.Inline) keeps the latest state as a document in the same transaction as the events, and FetchForWriting uses it automatically.

A better first move is often to shorten streams. Many domains have natural period boundaries, such as a monthly statement or a shift, so the stream can close with a summary event and a new stream starts from the carried-forward balance.

Event Versioning and Upcasting#

Stored events are immutable, but the code that reads them is not. The Azure Architecture Center lists the standard strategies: tolerant deserialization for additive changes, explicit version identifiers, upcasting old payloads into the current shape as they are read, and, only as a last resort, rewriting stored events in place.

Additive changes rarely need anything: a new optional property simply deserializes as its default. Renames, type changes and splits need an upcaster. Marten supports upcasting from old CLR types, through upcaster classes, or directly from raw JSON, which lets you delete the old event type from the codebase:

C#
using static Marten.Services.Json.Transformations.SystemTextJson.JsonTransformations;

builder.Services.AddMarten(options =>
{
    options.Connection(builder.Configuration.GetConnectionString("bank")!);

    // Early releases stored "cash_deposited" events with an amount in cents. The old CLR
    // type is gone; this transformation reads the raw JSON and produces today's event.
    options.Events.Upcast<MoneyDeposited>(
        "cash_deposited",
        Upcast(json =>
        {
            var old = json.RootElement;
            return new MoneyDeposited(
                old.GetProperty("AccountId").GetGuid(),
                old.GetProperty("AmountInCents").GetInt64() / 100m,
                Reference: "(not recorded)",
                At: old.GetProperty("At").GetDateTimeOffset());
        }));
});

Upcasting code runs every time an old event is deserialized, so keep it pure and fast; Marten's documentation specifically warns that asynchronous upcasters doing I/O cause severe slowdowns. Never change the meaning of an existing event type. If the business concept changed, introduce a new event.

Idempotency and Concurrency#

Two different problems hide behind the word idempotency. On the write side, a client may retry a command after a timeout without knowing whether the first attempt succeeded. The expected-version check prevents lost updates. KurrentDB also deduplicates appends that reuse the same event ID, so deriving event IDs deterministically from a command ID makes simple retries safe.

On the read side, subscriptions and message brokers deliver at least once, so every projection and every downstream consumer must tolerate duplicates. Store the last processed position or event ID alongside the read model and update both in one transaction, or write handlers that assign absolute values instead of incrementing counters. Treat "exactly once" as a property you build, not one the infrastructure grants.

GDPR and the Right to Be Forgotten#

An append-only log collides with the right to erasure. The Azure Architecture Center describes two main approaches. The first is to keep personal data out of events entirely: store it in a normal table keyed by a subject ID and reference that ID from events. The second is crypto-shredding: encrypt personal fields with a per-person key held outside the event store, and delete the key when erasure is required. The events remain intact, but the personal data is unrecoverable.

C#
public sealed record EncryptedValue(Guid SubjectId, byte[] Nonce, byte[] Cipher, byte[] Tag);

public sealed class PersonalDataProtector(ISubjectKeyStore keys)
{
    public async Task<EncryptedValue> EncryptAsync(
        Guid subjectId, string plaintext, CancellationToken ct)
    {
        var key = await keys.GetOrCreateAsync(subjectId, ct); // 32-byte key per person
        var nonce = new byte[AesGcm.NonceByteSizes.MaxSize];
        RandomNumberGenerator.Fill(nonce);
        var input = Encoding.UTF8.GetBytes(plaintext);
        var cipher = new byte[input.Length];
        var tag = new byte[AesGcm.TagByteSizes.MaxSize];

        using var aes = new AesGcm(key, tag.Length);
        aes.Encrypt(nonce, input, cipher, tag, associatedData: subjectId.ToByteArray());
        return new EncryptedValue(subjectId, nonce, cipher, tag);
    }

    public async Task<string?> DecryptAsync(EncryptedValue value, CancellationToken ct)
    {
        var key = await keys.FindAsync(value.SubjectId, ct);
        if (key is null)
            return null; // Key deleted: this person has been forgotten.

        var plaintext = new byte[value.Cipher.Length];
        using var aes = new AesGcm(key, value.Tag.Length);
        aes.Decrypt(value.Nonce, value.Cipher, value.Tag, plaintext,
            associatedData: value.SubjectId.ToByteArray());
        return Encoding.UTF8.GetString(plaintext);
    }
}

Three details decide whether crypto-shredding holds up under audit. Key deletion must also reach backups of the key store. Projections that copied decrypted values must be purged or rebuilt. And legal review should confirm that encrypted data with a destroyed key satisfies your regulator. Marten offers a pragmatic alternative: masking rules that overwrite protected fields in stored events, which trades strict immutability for simplicity. For key management options, see the cryptography and data protection guide.

Best Practices#

  • Name events as business facts in the past tense. MoneyWithdrawn carries intent; AccountUpdated carries nothing.
  • Keep streams short. Model lifecycles with natural boundaries instead of relying on snapshots.
  • Always append with an expected version. Use FetchForWriting in Marten or an explicit revision in KurrentDB.
  • Separate internal events from integration events. Publishing your stored events couples every consumer to your schema.
  • Plan versioning on day one. Tolerant readers first, upcasters for structural changes, new event types for new meanings.
  • Design projections to be rebuilt. Keep them deterministic, and rebuild into new tables before switching traffic.
  • Keep personal data out of events, or encrypt it with per-person keys.

Common Pitfalls#

Event sourcing everything. Reference data, settings and simple CRUD entities gain nothing from an event log and lose simple queries.

CRUD events. CustomerChanged with a full snapshot of fields is state storage with extra steps. If you cannot name the business reason, the event is not ready.

Querying the event store for lists. "All accounts overdrawn this week" is a projection, not a stream scan.

Assuming exactly-once delivery. Non-idempotent projections double-count after the first redelivery or rebuild.

Unbounded streams. A stream that grows for a decade makes every load slower and every migration harder.

Editing stored events to fix bugs. Append a compensating event instead; the history of the mistake is part of the audit trail.

When to Use Event Sourcing#

SituationRecommendation
Ledgers, payments, bookings, inventory movementsStrong fit: history is the business
Regulatory audit trail or temporal queries requiredStrong fit
Complex workflows that other services react toGood fit, combined with CQRS and messaging
Mostly CRUD with occasional audit needsUse state storage plus an audit table or change data capture
Prototype or short-lived systemAvoid: the overhead is not justified
Team new to event-driven design with tight deadlinesStart with CQRS on state storage first

Frequently Asked Questions#

What is the difference between event sourcing and event-driven architecture?#

Event sourcing is a persistence strategy: events are the system of record for one service's aggregates. Event-driven architecture is an integration style: services communicate by publishing events. You can use either without the other, and event-sourced services usually publish separate integration events rather than their internal stream.

Should I use Marten or KurrentDB?#

Choose Marten if you already run PostgreSQL and want events, documents and projections in one transactional store with a library-only footprint. Choose KurrentDB if events are central to a larger platform with many subscribers, and you are comfortable operating a dedicated database under its license terms.

How do I query data in an event-sourced system?#

Through projections. Build read models shaped for each query, inline when you need immediate consistency and asynchronously when you need throughput, and query them like any other table or document.

When do I need snapshots?#

Only when measured load times of long streams become a problem. Most streams stay short enough to replay quickly, and shortening streams with period boundaries is usually a better fix than snapshots.

How do I delete personal data from an event store?#

Avoid storing it in events, or encrypt it with a per-person key and delete the key when erasure is requested, which is known as crypto-shredding. Then rebuild or purge any projection that held decrypted copies.

Summary#

  • Event sourcing stores business facts as immutable, ordered events and derives state by replaying them.
  • Appends with an expected version give each stream optimistic concurrency without locks.
  • Projections turn events into query models; choose inline for consistency and async for throughput, and design them to be rebuilt and idempotent.
  • Marten brings event sourcing to PostgreSQL; KurrentDB, formerly EventStoreDB, is a dedicated event database.
  • Plan versioning with upcasters, and handle GDPR with external PII storage or crypto-shredding.
  • Apply event sourcing selectively where history is the business, not across a whole system.

Further Reading#