Skip to main content
SEMastery

Getting Started with Event Sourcing in .NET with Marten and PostgreSQL

Learn event sourcing in .NET using Marten and PostgreSQL. Store events, build aggregates and projections, and read state the easy, beginner-friendly way.

14 min readUpdated February 6, 2026

Getting Started with Event Sourcing in .NET with Marten and PostgreSQL

Think about your school report card. Your teacher does not erase last term's marks and write only the new ones. She keeps every term, one after the other. When the year ends, she adds them up to see your final result. The full story is kept, and the final number is built from that story.

That simple idea is the heart of event sourcing. Instead of saving only the latest value, you save every change that happened, in order. The "current value" is just the result of replaying those changes from start to finish.

In this guide you will learn event sourcing in .NET using Marten and PostgreSQL. We will build a small Shopping Cart, store its events, and rebuild its state. By the end you will understand events, streams, aggregates, projections, and how to read state safely.

A real-life everyday analogy

Imagine your mother keeps a small notebook for the family kitchen money.

  • She does not rub out the total every day.
  • She writes lines: "Added 500 rupees", "Bought vegetables 120", "Bought milk 40".
  • To know how much is left, she reads all the lines and adds them up.

The notebook lines are events. They never change once written. The total at the bottom is the current state, and it is calculated from the lines. If she ever doubts the total, she can recount from the top. That is exactly how an event-sourced system thinks.

The notebook idea: events are written in order, and the total is calculated from them.

State storage vs event sourcing

Most apps you have seen use state storage. They keep a row in a table, and every update overwrites the old values. You lose the history. You only know now, not how you got here.

Event sourcing keeps the history forever. Here is the difference side by side.

QuestionState storage (normal CRUD)Event sourcing
What is stored?Only the latest valuesEvery change as an event
Can you see history?No, old values are goneYes, the full timeline
How is "now" found?Read the row directlyReplay events to rebuild it
Easy audit trail?Hard, you add extra loggingBuilt in for free
Undo / time-travel?Very hardNatural and simple

Neither one is "better" for everything. State storage is great for simple data. Event sourcing wins when the history matters, like banking, orders, bookings, or anything where someone may later ask "who changed this and when?"

What is Marten?

Marten is a free, open-source .NET library. It turns a plain PostgreSQL database into two things at once:

  1. A document database (store and query .NET objects as JSON).
  2. A fully ACID event store for event sourcing.

This is good news for two reasons. First, you do not install any special database. PostgreSQL is everywhere and easy to run. Second, unlike some popular libraries (for example MediatR and MassTransit, which moved to commercial licenses), Marten stays free under the MIT license. You can use it in commercial projects without paying.

Where Marten sits

Your .NET App
Marten
PostgreSQL

Steps

1

Your .NET App

Sends commands and reads state

2

Marten

Stores events, builds aggregates

3

PostgreSQL

Saves events and JSON documents

Your .NET app talks to Marten, and Marten talks to PostgreSQL.

The five core words you need

Before code, learn five small words. Once these click, everything else is easy.

WordSimple meaningNotebook example
EventA fact that already happened"Spent 120 on vegetables"
StreamAll events for one thing, in orderOne page for one cart
AggregateThe current state built from eventsThe total at the bottom
CommandA request to do something"Please add this item"
ProjectionA view built by replaying eventsA nice summary report

An event is named in the past tense, because it already happened. A stream is the list of events for one specific cart (each cart has its own page). An aggregate is what you get when you fold all events together. A command asks the system to change something. A projection turns events into a useful read-friendly shape.

A stream is a chain of events for one cart, folded into the current aggregate.

Step 1: Set up the project

First, install the Marten NuGet package. We target .NET 10, which is the current LTS release.

dotnet new web -n ShopApp
cd ShopApp
dotnet add package Marten

You also need a running PostgreSQL. The fastest way is Docker:

docker run --name shop-pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres:17

Now register Marten in Program.cs. You give it a connection string, and Marten can create the tables it needs on its own during development.

using Marten;
 
var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddMarten(options =>
{
    var connectionString = builder.Configuration.GetConnectionString("Postgres")
        ?? "Host=localhost;Port=5432;Database=shop;Username=postgres;Password=secret";
 
    options.Connection(connectionString);
 
    // In development, let Marten create/update tables automatically.
    options.AutoCreateSchemaObjects = Weasel.Core.AutoCreate.All;
});
 
var app = builder.Build();
app.Run();

That is the whole setup. Marten will build the event tables for you the first time it runs.

Step 2: Define the events

Events are tiny, plain records. In C# 14 (shipped with .NET 10), record types are perfect for this. Each event holds only the facts of one change.

// Each event is an immutable fact about the cart.
public record CartCreated(Guid CartId, string Customer);
 
public record ItemAdded(string Product, decimal Price, int Quantity);
 
public record ItemRemoved(string Product);
 
public record CartCheckedOut(DateTimeOffset CheckedOutAt);

Notice the past-tense names: Created, Added, Removed, CheckedOut. They describe things that already happened. We never change an event after it is written. If the cart later changes again, we just add a new event to the end.

Step 3: Build the aggregate

The aggregate is the current state of one cart. It starts empty and "evolves" as each event is applied. Marten can do this for you if your class has Apply methods, one for each event type.

public class ShoppingCart
{
    public Guid Id { get; set; }
    public string Customer { get; set; } = "";
    public List<CartLine> Lines { get; set; } = new();
    public bool IsCheckedOut { get; set; }
 
    public decimal Total => Lines.Sum(l => l.Price * l.Quantity);
 
    // Create the first version of the cart from the first event.
    public void Apply(CartCreated e)
    {
        Id = e.CartId;
        Customer = e.Customer;
    }
 
    public void Apply(ItemAdded e) =>
        Lines.Add(new CartLine(e.Product, e.Price, e.Quantity));
 
    public void Apply(ItemRemoved e) =>
        Lines.RemoveAll(l => l.Product == e.Product);
 
    public void Apply(CartCheckedOut e) => IsCheckedOut = true;
}
 
public record CartLine(string Product, decimal Price, int Quantity);

Each Apply method takes one event and changes the cart a little. Replay them all in order and you get the final cart. This "fold the events into state" step is the core trick of event sourcing.

How an aggregate is rebuilt by applying events one by one.

Step 4: Save events (handle a command)

A command is a request like "add an item". The handler opens a session, appends one or more events to the cart's stream, and saves. The first time, we start a brand new stream.

public class CartHandlers(IDocumentStore store)
{
    public async Task<Guid> CreateCart(string customer)
    {
        await using var session = store.LightweightSession();
 
        var cartId = Guid.NewGuid();
        var created = new CartCreated(cartId, customer);
 
        // StartStream begins a new stream of events for this cart.
        session.Events.StartStream<ShoppingCart>(cartId, created);
 
        await session.SaveChangesAsync();
        return cartId;
    }
 
    public async Task AddItem(Guid cartId, string product, decimal price, int qty)
    {
        await using var session = store.LightweightSession();
 
        // Append a new event to the end of the existing stream.
        session.Events.Append(cartId, new ItemAdded(product, price, qty));
 
        await session.SaveChangesAsync();
    }
}

StartStream creates a fresh page for a new cart. Append adds a line to an existing page. When you call SaveChangesAsync, Marten writes the events to PostgreSQL inside one transaction. Either all events save, or none do. That is what "ACID" means: your data stays consistent.

Command to saved event

Command
Handler
Append event
SaveChanges
PostgreSQL

Steps

1

Command

AddItem request arrives

2

Handler

Validates the request

3

Append event

ItemAdded added to stream

4

SaveChanges

One safe transaction

5

PostgreSQL

Event stored forever

A command turns into one or more events stored in PostgreSQL.

Step 5: Read the current state

To read a cart, Marten replays its events into a fresh ShoppingCart for you. The simple way is AggregateStreamAsync. It fetches every event in the stream and applies them in order.

public async Task<ShoppingCart?> GetCart(Guid cartId)
{
    await using var session = store.LightweightSession();
 
    // Replay every event in this stream to rebuild the live state.
    var cart = await session.Events
        .AggregateStreamAsync<ShoppingCart>(cartId);
 
    return cart;
}

This is called a live aggregation, because Marten builds the answer on the spot from the raw events. It is always correct, but for very long streams it can be slow, because it reads many events each time.

The safer way for commands: FetchForWriting

When you change a cart, you usually want to read the current state, check a rule, then append a new event. Marten 7 and later give you a cleaner API for this: FetchForWriting. It loads the latest aggregate, hands you the stream, and lets you append in one neat step.

public async Task Checkout(Guid cartId)
{
    await using var session = store.LightweightSession();
 
    // Loads the up-to-date cart AND the stream, ready to append.
    var stream = await session.Events
        .FetchForWriting<ShoppingCart>(cartId);
 
    if (stream.Aggregate is null || stream.Aggregate.IsCheckedOut)
        return; // nothing to do
 
    stream.AppendOne(new CartCheckedOut(DateTimeOffset.UtcNow));
    await session.SaveChangesAsync();
}

FetchForWriting always gives you the most up-to-date view of the stream, no matter how your projections are configured. It also helps prevent two people from saving conflicting changes at the same time. The official docs recommend it as the default choice inside command handlers. Note: it works only for single-stream aggregates.

Step 6: Make reads fast with projections

Replaying events every time can get slow. A projection fixes this. Marten can keep a ready-made copy of the aggregate as a stored document and update it whenever a new event arrives. Reading then becomes a fast, single document load.

The easiest version is a snapshot: tell Marten to keep an inline, up-to-date copy of the cart. You add one line during setup.

builder.Services.AddMarten(options =>
{
    options.Connection(connectionString);
 
    // Keep a stored, up-to-date ShoppingCart document for every stream.
    options.Projections.Snapshot<ShoppingCart>(
        Marten.Events.Projections.SnapshotLifecycle.Inline);
});

With this in place, every time you save events, Marten also updates the stored ShoppingCart. Now you can read it like a normal document, which is very fast:

public async Task<ShoppingCart?> GetCartFast(Guid cartId)
{
    await using var session = store.QuerySession();
 
    // Loads the stored snapshot directly. No replay needed.
    return await session.LoadAsync<ShoppingCart>(cartId);
}

Here is how the two read paths compare.

Read styleHow it worksSpeedBest for
Live aggregateReplay all events nowSlower on long streamsRare reads, always-fresh needs
Inline snapshotRead a stored documentVery fastFrequent reads of current state

Inline vs async projections

Marten can update a projection in two timings. Inline updates the projection in the same transaction as the events, so it is instantly consistent. Async updates it slightly later in a background process, which is gentler on heavy systems but can be a tiny bit behind. Beginners should start with inline, because it is the simplest to reason about.

Inline projection: events and the read model are saved together.

A quick word on CQRS

Event sourcing pairs naturally with CQRS (Command Query Responsibility Segregation). The idea is simple: keep your write path and your read path separate.

  • The write side appends events (commands like AddItem).
  • The read side queries projections (queries like GetCart).

You do not have to use CQRS to use Marten, but they fit together well. Commands grow your event streams. Projections give you fast, friendly views to read from.

CQRS with Marten

Command
Events
Projection
Query

Steps

1

Command

Changes the system

2

Events

Stored in the stream

3

Projection

Built from events

4

Query

Reads the projection fast

Writes append events; reads come from projections.

When should you use event sourcing?

Event sourcing is powerful, but it is not free. It adds thinking effort. Use this simple guide.

Good fit:

  • Money, orders, bookings, and anything needing an audit trail.
  • Systems where someone asks "why is it like this?" and history matters.
  • Domains with rich behavior and many state changes over time.

Maybe skip it:

  • Simple CRUD screens where you only care about the latest value.
  • Tiny apps where the extra concepts are not worth it.
  • Pure reporting databases with no real domain logic.

A wise approach is to use event sourcing only for the few parts of your app where history truly matters, and keep normal CRUD for the rest. Marten lets you mix both in the same PostgreSQL database, which makes this easy.

Common beginner mistakes

A few friendly warnings to save you pain later:

  • Do not change old events. Events are facts. To fix something, append a new correcting event.
  • Keep events small and meaningful. Name them after real business facts, not technical steps.
  • Do not put logic in events. Put logic in the aggregate's Apply methods.
  • Use FetchForWriting in command handlers. It keeps your code safe against future projection changes.
  • Turn off auto-create in production. Use Marten's schema migration tools instead of AutoCreate.All.

Quick recap

  • Event sourcing stores every change as an event, like notebook lines, instead of overwriting state.
  • Marten turns plain PostgreSQL into both a document store and an ACID event store, and it is free and open source.
  • The five core words are event, stream, aggregate, command, projection.
  • You append events with StartStream and Append, then SaveChangesAsync writes them safely.
  • You rebuild state with AggregateStreamAsync (live) or read a stored snapshot for speed.
  • Inside command handlers, prefer FetchForWriting for safe, up-to-date reads before appending.
  • Projections keep fast read models; start with inline for instant consistency.
  • Event sourcing pairs well with CQRS: writes append events, reads use projections.
  • Use it where history and audit matter; keep simple CRUD for everything else.

References and further reading

Related Patterns