Skip to main content
SEMastery

Event Sourcing for .NET Developers: A Simple Introduction

Learn event sourcing in .NET from scratch. Store every change as an event instead of just the current state, with a real-life bank-passbook analogy, diagrams, code, aggregates, projections, and when to use it.

11 min readUpdated September 27, 2025

A bank passbook, not just a balance

Imagine two ways a bank could track your account.

In the first way, the bank stores only one number: your current balance, say ₹5,000. Every time you deposit or withdraw, it simply overwrites that number. Fast and simple — but if there is ever a dispute, the bank cannot tell you how you reached ₹5,000. The history is gone.

In the second way, the bank keeps a passbook: a list of every single transaction. "+₹10,000 salary", "−₹3,000 rent", "−₹2,000 shopping". The current balance is not stored at all — it is calculated by adding up every line. Now the bank can show you exactly how you got to today's balance, replay your history, and even answer questions like "what was my balance last Tuesday?"

That second way is event sourcing. Instead of storing only the current state, you store every change as an event, forever. The current state is whatever you get by replaying all the events. This gives you a perfect history, powerful auditing, and the ability to answer questions you did not even think of when you first built the system.

Let us learn how it works in .NET, step by step.

CRUD vs event sourcing

Normal apps use CRUD storage: Create, Read, Update, Delete. When something changes, you overwrite the old value. The history is lost the moment you update.

Figure 1: CRUD overwrites and loses history. Event sourcing appends events and keeps the full story; the state is replayed from them.

The difference is profound. In CRUD, the database row is the truth, and it changes. In event sourcing, the events are the truth, they never change, and the current state is just a calculation on top of them.

The core building blocks

Event sourcing has three main ideas: events, aggregates, and projections. Let us meet each one.

The Three Building Blocks of Event Sourcing

Events
Aggregate
Event Stream
Projections

Steps

1

Events

Immutable facts: 'MoneyDeposited', 'OrderShipped'

2

Aggregate

Replays its events to know its state and enforce rules

3

Event Stream

The ordered list of all events for one aggregate

4

Projections

Read models built from events for fast queries

Events are the facts that happened. An aggregate replays its events to get state and decide new ones. Projections build fast read views from events.

Events: the facts that happened

An event is a record of something that already happened, written in the past tense. It is immutable — once stored, it never changes.

public record AccountOpened(Guid AccountId, string Owner);
public record MoneyDeposited(Guid AccountId, decimal Amount);
public record MoneyWithdrawn(Guid AccountId, decimal Amount);

Notice the names: MoneyDeposited, not DepositMoney. Events describe history, not commands. They are facts, and facts do not change.

Aggregates: replaying events to get state

An aggregate is a unit of related data with its own rules — like a bank account. Its current state is built by replaying its events one by one:

public class BankAccount
{
    public Guid Id { get; private set; }
    public decimal Balance { get; private set; }
 
    // Rebuild state by applying each event in order
    public void Apply(MoneyDeposited e) => Balance += e.Amount;
    public void Apply(MoneyWithdrawn e) => Balance -= e.Amount;
 
    public static BankAccount Load(IEnumerable<object> events)
    {
        var account = new BankAccount();
        foreach (var e in events)
            account.ApplyEvent(e); // dispatch to the right Apply method
        return account;
    }
}

To make a change, the aggregate checks its rules and then produces a new event rather than mutating a database row:

public MoneyWithdrawn Withdraw(decimal amount)
{
    if (amount > Balance)
        throw new InvalidOperationException("Insufficient funds");
 
    return new MoneyWithdrawn(Id, amount); // a new fact to append
}

Event streams: the history per aggregate

Every aggregate has an event stream — the ordered list of all its events. Loading the aggregate means reading its stream and replaying it.

Figure 2: An aggregate's event stream. Replaying the events in order rebuilds the current state.

Projections: making reads fast

Replaying events is great, but what if an aggregate has thousands of events? Replaying them all every time you want to show a balance would be slow. This is where projections come in.

A projection is a read model built from events — a ready-to-read view, shaped for a specific question, kept up to date as new events arrive. Instead of replaying everything on each read, you read the projection directly.

Figure 3: Writes append events. Projections listen to those events and update read models, so queries are fast.

This naturally pairs with CQRS (Command Query Responsibility Segregation): the write side appends events, and the read side queries projections. Each side is optimised for its job.

Aggregate (write side)Projection (read side)
PurposeEnforce rules, produce eventsAnswer queries fast
Built fromIts own event streamOne or many event streams
Has business logic?YesNo — just shaping data
Optimised forCorrect writesFast reads

Using a library: Marten

You rarely build an event store by hand. In .NET, Marten (backed by PostgreSQL) is a popular choice. It stores events, replays streams, and can keep projections automatically up to date. A simplified flow looks like this:

// Append events to a stream
session.Events.StartStream<BankAccount>(accountId,
    new AccountOpened(accountId, "Asha"),
    new MoneyDeposited(accountId, 10000));
await session.SaveChangesAsync();
 
// Load the aggregate by replaying its stream
var account = await session.Events.AggregateStreamAsync<BankAccount>(accountId);
Console.WriteLine(account!.Balance); // 10000

Marten can also maintain a stored projection so you can load the latest state directly like a normal document, instead of replaying every time — giving you both history and speed.

How a command becomes an event

It helps to see the full write flow — what happens when a user asks to do something, like withdraw money:

Handling a Command in Event Sourcing

Command arrives
Load event stream
Replay to state
Check rules
Append new event

Steps

1

Command

A request like 'Withdraw ₹3000' comes in

2

Load stream

Read all past events for this aggregate

3

Replay

Rebuild the current state by applying each event

4

Check rules

Is there enough balance? Enforce business rules

5

Append

Save a new MoneyWithdrawn event — never overwrite

Load the stream, replay to get state, check the rules, append a new event. The state is never overwritten — only events are added.

Read the steps carefully. The aggregate never updates a row. It loads its history, replays it to know where it stands, checks whether the new action is allowed, and then appends a single new fact. The past is never edited — only extended. This append-only nature is what gives event sourcing its perfect history and audit trail.

The benefits of event sourcing

  • Complete history. You never lose data. Every change is preserved as an event, forever.
  • Perfect audit trail. You can answer "who changed what, and when" precisely — often a legal requirement in finance and healthcare.
  • Time travel. You can rebuild the state as it was at any past moment by replaying events up to that time.
  • New read models anytime. Need a new report? Build a new projection from the existing events — even for questions you never anticipated.
  • Natural fit for events. It pairs beautifully with event-driven systems and the outbox pattern.

The honest trade-offs

Event sourcing is powerful, but it is not free:

  • More complexity. It is harder to learn and build than simple CRUD. Replaying, versioning events, and projections all add moving parts.
  • Event versioning. Over years, your event shapes change. You must handle old event formats carefully, because you can never delete the history.
  • Eventual consistency. Projections often update slightly after the write, so reads can be a touch behind. Your design must accept this.
  • Not for everything. Simple data that does not need history is better off as plain CRUD.
CRUDEvent Sourcing
StoresCurrent state onlyEvery change as events
HistoryLost on updateKept forever
Audit / time travelHardBuilt in
ComplexityLowHigher
Best forSimple dataRich, auditable domains

Snapshots: a speed trick for long streams

What happens when an aggregate has been alive for years and has 100,000 events? Replaying all of them every time you load it would be slow. The common fix is a snapshot.

A snapshot saves the aggregate's state at a certain point — say, after event number 100,000. Next time you load the aggregate, you start from the snapshot and replay only the few events that happened after it, instead of all 100,000.

Without snapshot:  replay events 1 ... 100,000   (slow)
With snapshot:     load snapshot @100,000
                   replay events 100,001 ... 100,010   (fast)

The events remain the full source of truth — the snapshot is just a performance shortcut you can rebuild or throw away at any time. Libraries like Marten support snapshots and stored projections so you rarely have to manage this by hand, but it is good to know the idea exists for very long-lived aggregates.

ℹ️

You do not need snapshots until streams get long. Start without them, measure your load times, and add snapshots only for the aggregates that genuinely have thousands of events. Premature snapshotting is extra complexity you may never need.

When to use event sourcing

Reach for event sourcing when history and auditability really matter, or when the sequence of changes is itself valuable: banking and payments, order lifecycles, inventory movements, insurance claims, and anything regulated. In these domains, knowing exactly how you reached the current state is as important as the state itself.

Avoid it for simple CRUD apps — a settings screen, a basic catalogue, a contact list. There, the extra complexity buys you nothing, and plain storage is the right tool. A common, wise approach is to use event sourcing only for the few aggregates that truly need it, and CRUD for the rest.

Quick recap

  • Event sourcing stores every change as an event, like a bank passbook, instead of overwriting the current state.
  • The events are the source of truth; the current state is rebuilt by replaying them.
  • An aggregate replays its event stream to know its state and produce new events; projections build fast read models from events.
  • It pairs naturally with CQRS and gives you full history, audit trails, and time travel — at the cost of more complexity and eventual consistency.
  • Use it where history matters (finance, orders, inventory); stick to CRUD for simple data.
  • Libraries like Marten handle the event store and projections for you in .NET.

Keep the full passbook of everything that happened, and you can always replay your way to today's balance — and answer tomorrow's questions about yesterday.

References and further reading

Related Patterns