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.
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.
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.
| Question | State storage (normal CRUD) | Event sourcing |
|---|---|---|
| What is stored? | Only the latest values | Every change as an event |
| Can you see history? | No, old values are gone | Yes, the full timeline |
| How is "now" found? | Read the row directly | Replay events to rebuild it |
| Easy audit trail? | Hard, you add extra logging | Built in for free |
| Undo / time-travel? | Very hard | Natural 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:
- A document database (store and query .NET objects as JSON).
- 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
Steps
Your .NET App
Sends commands and reads state
Marten
Stores events, builds aggregates
PostgreSQL
Saves events and JSON documents
The five core words you need
Before code, learn five small words. Once these click, everything else is easy.
| Word | Simple meaning | Notebook example |
|---|---|---|
| Event | A fact that already happened | "Spent 120 on vegetables" |
| Stream | All events for one thing, in order | One page for one cart |
| Aggregate | The current state built from events | The total at the bottom |
| Command | A request to do something | "Please add this item" |
| Projection | A view built by replaying events | A 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.
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 MartenYou also need a running PostgreSQL. The fastest way is Docker:
docker run --name shop-pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres:17Now 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.
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
Steps
Command
AddItem request arrives
Handler
Validates the request
Append event
ItemAdded added to stream
SaveChanges
One safe transaction
PostgreSQL
Event stored forever
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 style | How it works | Speed | Best for |
|---|---|---|---|
| Live aggregate | Replay all events now | Slower on long streams | Rare reads, always-fresh needs |
| Inline snapshot | Read a stored document | Very fast | Frequent 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.
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
Steps
Command
Changes the system
Events
Stored in the stream
Projection
Built from events
Query
Reads the projection fast
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
Applymethods. - Use
FetchForWritingin 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
StartStreamandAppend, thenSaveChangesAsyncwrites them safely. - You rebuild state with
AggregateStreamAsync(live) or read a stored snapshot for speed. - Inside command handlers, prefer
FetchForWritingfor 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
- Marten as Event Store (official docs)
- Understanding Event Sourcing with Marten
- Reading Aggregates and FetchForWriting
- Single Stream Projections and Snapshots
- Marten on GitHub (JasperFx/marten)
- Event Sourcing and CQRS with Marten (CODE Magazine)
Related Patterns
Fast Document Database in .NET with Marten
Learn how Marten turns PostgreSQL into a fast document database for .NET. Save C# objects as JSON, query with LINQ, and keep full ACID safety.
Refactoring a Modular Monolith Without MediatR in .NET
Learn to remove MediatR from a .NET modular monolith using plain handlers and a tiny dispatcher, with CQRS, pipeline behaviors, and clear module boundaries.
CQRS Pattern in .NET: The Way It Should Have Been From the Start
A friendly, hands-on guide to the CQRS pattern in .NET 10. Learn commands, queries, handlers, diagrams, and when to actually use it.
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.
Vertical Slice Architecture: How to Structure Your Slices in .NET
Learn vertical slice architecture in .NET with a simple tiffin-box analogy, feature folders, CQRS, code examples, diagrams, and clear structuring rules.
How I Implemented Full-Text Search on My Website with EF Core
A simple, beginner-friendly guide to adding fast full-text search to your .NET website using EF Core with SQL Server and PostgreSQL.