Share your thoughts on .NET dev tooling and get a chance to win a reward
JetBrains is conducting a survey to better understand how .NET dev tools impact developer productivity, and your perspective could really help the company improve its tooling. It takes about 10 minutes to complete and focuses on your real-world experience with the tools.
As a thank-you for your time, you'll have the chance to win a small prize. Take the survey and make your voice count!
Still scheduling 2am maintenance windows just to patch a server? Hotpatching in Windows Server 2025 lets you apply security updates without a reboot ā and Microsoft engineering is walking through how it works live at the free Windows Server Summit (May 11ā13).
Three days, direct from the engineering teams, with a Day 3 feedback session where you can shape the next version of Windows Server. Virtual, recorded, free.
Most applications store data by saving the current state to a database.
When a customer adds an item to a shopping cart, you update a row in the database.
The previous state is lost.
You only see the cart's current state, with all history lost.
This works for many applications. But what happens when you need to know the full history of changes?
What if you need to rebuild past states, debug complex business flows, or generate analytics from historical data?
This is where the Event Sourcing pattern is useful.
Event Sourcing stores every change as an immutable event.
Instead of overwriting data, you append new events to a stream.
The current state is derived by replaying these events.
In this post, I will show you how to build an Event Sourcing application in .NET using the Marten library and PostgreSQL.
We will build a Shopping Cart that demonstrates all the core Event Sourcing concepts: events, streams, aggregates, commands, and projections.
In traditional CRUD applications, you store the current state of an entity in a database row.
When a customer adds a product to a shopping cart, you insert or update a row in the CartItems table.
When they remove an item, you delete that row.
The database always shows the latest state.
This approach is simple and works well for many use cases. But it has a fundamental limitation: you lose history.
If a customer added 5 items, removed 2, changed quantities 3 times, and then checked out, all you see in the database is the final state.
You cannot answer questions like "What did the cart look like 10 minutes ago?" or "Which items were removed before checkout?"
Event Sourcing takes a different approach. Instead of storing the current state, you store the sequence of events that led to the current state.
For a shopping cart, these events might look like:
CartCreated - Customer started a new cart
ItemAdded - Wireless Mouse, quantity 1
ItemAdded - Mechanical Keyboard, quantity 1
ItemRemoved - Wireless Mouse
ItemAdded - Gaming Mouse, quantity 2
CartCheckedOut - Customer completed the purchase
The current state of the cart is derived by replaying all these events from the beginning. This sequence of events is called an event stream.
Event Sourcing introduces several core concepts that work together:
Events are immutable facts that describe something that happened in the past.
An event is never deleted or modified.
Once ItemAdded is recorded, it stays in the stream forever.
Events use past tense names: CartCreated, ItemAdded, OrderConfirmed.
Streams are ordered sequences of events that belong to the same entity.
A shopping cart has its own stream. Each stream has a unique identifier, typically a GUID.
All events for cart abc-123 are stored together in correct order as they appear in time.
Aggregates are domain objects that derive their current state by replaying events from a stream.
The ShoppingCart aggregate starts empty and applies each event one by one.
After replaying all events, the aggregate reflects the current state.
Aggregates also enforce business rules before new events are appended.
Projections are read models built from events.
While aggregates give you the state of a single stream, projections can combine data from multiple streams to create optimized views for querying.
For example, a "Product Popularity" projection might track how many times each product was added across all carts.
Event Sourcing is not a replacement for CRUD.
It is a different approach that shines in specific scenarios.
We will explore when to use it at the end of this post.
Now let's build a real application to see these concepts in action.
ShoppingCart.Domain contains the pure domain model: event records and the aggregate.
ShoppingCart.Features contains the vertical slices: each feature has its own folder with an endpoint, handler, and optional validator. It also contains the projections. This project uses Marten, Carter, ErrorOr, and FluentValidation.
ShoppingCart.WebApi is the host project that wires everything together: Marten configuration, dependency injection, and the ASP.NET Core pipeline.
This structure is similar to what I use in my Modular Monolith projects, but simplified for a single module.
Marten is a .NET library that turns PostgreSQL into a full-featured document database and event store.
Under the hood, it uses PostgreSQL's native JSON support to store events and documents.
Marten provides everything you need for Event Sourcing out of the box:
Event storage with streams
Aggregate hydration by replaying events
Inline and async projections
Optimistic concurrency
Schema auto-creation
To get started with Marten, install the following NuGet package:
Here, we configure a connection string to our PostgreSQL database and a database schema for storing Marten tables required for event sourcing.
And we register two projections:
Inline projections update synchronously within the same transaction as appending events.
This means the read model is immediately consistent after saving.
Async projections update in the background using a daemon process.
This means the read model is eventually consistent, but does not slow down the write side.
.AddAsyncDaemon(DaemonMode.Solo) starts the background daemon that processes async projections.
Solo mode means only one instance runs the daemon. For multi-instance deployments, use HotCold mode.
When you run the application, Marten automatically creates all the necessary tables in the shopping_cart schema. No migrations needed.
Events are the foundation of Event Sourcing. Each event represents a fact that happened in the domain.
In C#, events are best represented as records.
Records are immutable by default, which aligns with the principle that events should never be modified after creation.
Here are all the events for our Shopping Cart domain:
CartCreated is the first event in every cart stream. It records which customer owns this cart.
ItemAdded captures all the details needed to reconstruct the item: product ID, name, price, and quantity.
Notice that we store the product name and price when the product is added.
This is important because product prices can change later, but the event should reflect the price at the moment the customer added the item.
ItemRemoved only needs the product ID. The aggregate already knows the full item details from the previous ItemAdded event.
ItemQuantityChanged records the new quantity for a specific product.
CartCheckedOut has no properties. The fact that it happened is the information.
OrderConfirmed links the cart to an order by storing the order ID.
Key rules for designing events:
Use past tense names: ItemAdded, not AddItem
Include all data needed to reconstruct state
Keep events focused on what happened, not what should happen next
Events are immutable. Never modify a published event
The aggregate is the central piece of Event Sourcing.
It derives its current state by replaying events and enforces business rules before new events are appended.
Marten uses a convention-based approach.
When it replays events from a stream, it looks for Apply methods that match each event type and calls them in order.
For example, when Marten replays a stream with these events:
CartCreated -> calls Apply(CartCreated e) -> sets CustomerId, Status = Active
After replaying all 4 events, the aggregate has 2 items: Mouse (quantity 2) and Keyboard (quantity 1).
Important design decisions in our aggregate:
The Apply(ItemAdded) method handles duplicate products by summing quantities.
If a customer adds the same product twice, the quantities are combined into a single line item.
This is a business decision encoded directly in the aggregate, in one place, and not scattered across other classes.
The CartItem is a record. To update a quantity, we remove the old item and add a new one with the updated quantity using the with expression.
All setters are private set except for Id, which Marten needs to set when loading the aggregate.
This ensures the state can only change through Apply methods.
Apply methods should be pure state transitions. They should not throw exceptions, make network calls, or perform any side effects.
Business rule validation happens in the handlers, before appending events.
Commands are the write side of our application. Each command validates business rules and appends events to a stream.
In Vertical Slice Architecture, use small manual handlers for each endpoint in the Application Layer.
Each feature has its own folder containing an endpoint, a handler, and an optional validator.
The CreateCart handler creates a new event stream with the CartCreated event:
Here we use the StartStream method instead of Append.
StartStream creates a new stream and will throw an exception if a stream with this ID already exists.
This provides natural idempotency protection.
The IDocumentSession is Marten's unit of work.
It tracks all changes and persists them when you call SaveChangesAsync.
Since we registered the CartSummaryProjection as inline, the projection document is also created within the same transaction.
This endpoint pattern is the same across all commands: validate the request, call the handler, and return the appropriate HTTP response.
You can read more about best practices for building REST APIs in my other post.
Step 1: Load the aggregate.AggregateStreamAsync reads all events for the given stream ID and replays them through the Apply methods to build the current state.
If the stream does not exist, it returns null.
Step 2: Validate business rules. We check that the cart exists and is in the Active status.
You cannot add items to a cart that is already checked out or confirmed. These validations occur before the event is appended.
Step 3: Append the event. We create the ItemAdded event and append it to the stream using session.Events.Append. This does not persist the event yet.
Step 4: Save changes.SaveChangesAsync persists the event to PostgreSQL and updates any inline projections within the same transaction.
Step 5: Re-aggregate for the response. We reload the aggregate to get the fresh state that includes the newly appended event. This is a simple approach for returning the updated state.
You can skip this step if you don't need to return the updated state in your Web API response.
The RemoveItem handler follows the same pattern but adds item existence validation:
csharp
1internalsealedclassRemoveItemHandler(IDocumentSession session): IRemoveItemHandler
2{3publicasyncTask<ErrorOr<CartResponse>>HandleAsync(4Guid cartId,5Guid productId,6CancellationToken cancellationToken)7{8var cart =await session.Events
9.AggregateStreamAsync<Domain.ShoppingCart>(cartId,token: cancellationToken);1011if(cart isnull)12{13return Error.NotFound("Cart.NotFound","Cart not found");14}1516if(cart.Status != CartStatus.Active)17{18return Error.Validation("Cart.NotActive","Cannot remove items from a non-active cart");19}2021if(cart.Items.All(i => i.ProductId != productId))22{23return Error.NotFound("Cart.ItemNotFound","Item not found in cart");24}2526var @event=newItemRemoved(productId);27 session.Events.Append(cartId, @event);28await session.SaveChangesAsync(cancellationToken);2930 cart =await session.Events
31.AggregateStreamAsync<Domain.ShoppingCart>(cartId,token: cancellationToken);3233return cart!.MapToResponse();34}35}
The ChangeItemQuantity handler is similar.
It validates that the cart is active, the item exists, and the new quantity is valid (validation is handled by FluentValidation in the endpoint).
Other events are implemented similarly.
The Checkout handler enforces two business rules: the cart must be active and not empty.
The ConfirmOrder handler validates that the cart is in the CheckedOut status before confirming:
This status transition pattern (Active -> CheckedOut -> Confirmed) is enforced by business rules in the handlers.
The aggregate's Apply methods simply apply the state change. The handlers decide whether the state change is allowed.
A quick reminder: IDocumentSession is the unit of work. You don't need to create a repository to wrap it.
I use the same approach with EF Core's DbContext.
If you are interested why I don't use repositories - check out this article.
Projections are one of the most powerful concepts in Event Sourcing.
They transform events into read-optimized views that are easy to query.
Instead of querying the event stream and replaying events every time you need to read data, you build projections that automatically keep denormalized read models up to date.
Marten supports two types of projections:
Inline projections update within the same transaction as appending events. They are immediately consistent. Use them when you need the read model to be available right after saving.
Async projections update in the background using a daemon process. They are eventually consistent. Use them for cross-stream projections or analytics that do not need real-time accuracy.
This document stores pre-calculated values like TotalPrice and ItemCount.
In a traditional CRUD application, you would calculate these on every read. With projections, you calculate them once when the event is processed.
SingleStreamProjection<CartSummary, Guid> tells Marten this projection processes events from a single stream and produces one CartSummary document per stream.
The Create method is called when the first event in the stream is processed. It creates the initial document.
Each Apply method handles a specific event type.
Notice that the method signature is Apply(CartSummary summary, IEvent<EventType> e).
The IEvent<T> wrapper provides access to event metadata, such as Timestamp, which we use for the CreatedAt and UpdatedAt fields.
The RecalculateTotals helper recalculates TotalPrice and ItemCount after any item change. This keeps the read model always consistent.
The ProductPopularity projection is a cross-stream projection.
It aggregates data from ItemAdded events across all cart streams to track which products are most popular.
MultiStreamProjection<ProductPopularity, Guid> tells Marten this projection can consume events from multiple streams and group them by a custom key.
The constructor call Identity<ItemAdded>(e => e.ProductId) tells Marten how to route events: all ItemAdded events with the same ProductId are routed to the same ProductPopularity document.
The Create method is called the first time a product is added to any cart.
The Apply method is called for subsequent events with the same ProductId.
It increments the counter and updates the product name (in case it changed).
This projection runs asynchronously via the daemon.
There is a slight delay between appending an ItemAdded event and the ProductPopularity document being updated.
This eventual consistency is acceptable for analytics-type data.
The query side reads from projections rather than building the latest state from event streams.
This separation is a key benefit of Event Sourcing: the write model (events + aggregates) and read model (projections) are optimized independently.
Notice two important differences from the command handlers:
IQuerySession instead of IDocumentSession. For read-only operations, Marten provides IQuerySession, which is a lightweight, read-only session.
We read directly from the projection document rather than the event stream.
This is a simple key-value lookup. No event replaying needed.
Because CartSummary is an inline projection, the document is always up to date.
When you add an item and immediately call GetCart, you see the updated state.
Here, we use session.Query<ProductPopularity>() to project the data with LINQ.
Marten translates this to a SQL query against the ProductPopularity table in PostgreSQL.
We sort by TimesAddedToCart descending and take the top N results.
Audit and compliance requirements. If you need a complete history of every change, Event Sourcing gives you this for free.
Financial systems, healthcare applications, and regulated industries benefit from the built-in audit trail.
Complex business workflows. When your domain has rich state transitions with many business rules, Event Sourcing makes these transitions explicit.
Each state change is a named event that tells a story.
Event-driven architectures. If your system already uses events for communication between services, Event Sourcing aligns naturally.
The events you store can be published to other services for further processing.
Temporal queries. When you need to answer questions like "What did this look like at a specific point in time?", Event Sourcing lets you replay events up to any point and get the state at that moment.
Multiple read models. When different consumers need different views of the same data, projections let you build optimized read models without changing the write side.
Simple CRUD applications. If your application is mainly about creating, reading, updating, and deleting records with minimal business logic, Event Sourcing adds unnecessary complexity.
Small teams with limited experience. Event Sourcing introduces new concepts (streams, aggregates, projections) that take time to learn. If your team is small and unfamiliar with the pattern, the learning curve can slow you down.
Applications where history does not matter. If you never need to know past states and a simple "last updated" timestamp is enough, Event Sourcing is overkill.
High-frequency updates to the same entity. If a single entity receives thousands of updates per second, the event stream grows quickly. Replaying thousands of events to build the current state can become slow without snapshotting.
You do not have to apply Event Sourcing to your entire application.
Many successful systems use Event Sourcing for specific bounded contexts where it provides clear value, while the rest of the application uses traditional CRUD.
For example, you might use Event Sourcing for order processing and payment workflows, but stick with CRUD for user profile management and application settings.
Start with CRUD.
When you identify a specific need for full history, complex state transitions, or event-driven processing, consider introducing Event Sourcing for that part of your system.
Event Sourcing stores every change as an immutable event instead of overwriting the current state.
The current state is derived by replaying events from a stream.
Remember these Event Sourcing core concepts:
Events are immutable records that describe what happened: CartCreated, ItemAdded, CartCheckedOut.
Streams are ordered sequences of events for a single entity, identified by a GUID.
Aggregates rebuild their state by replaying events through Apply methods and enforce business rules before new events are appended.
Projections build read-optimized views from events. Inline projections are immediately consistent. Async projections are eventually consistent but can aggregate data across multiple streams.
Marten makes Event Sourcing in .NET straightforward. It handles event storage, aggregate hydration, and projections out of the box, all backed by PostgreSQL.
Hope you find this newsletter useful. See you next time.
You can download source code for this newsletter for free
Covers everything: C#, ASP.NET Core, EF Core, system design ā answer each question first, reveal the solution, and a test after every chapter proves it stuck. Finish, and you earn a verifiable certificate for your LinkedIn.
Not sure where you stand? Take the free .NET Interview Run:
Find out your real level ā Junior to Senior+
A realistic mock .NET interview ā across 13 areas of C#, .NET, ASP.NET Core and System Design
No credit card required. When you finish, you get a personalized report: your level, your strongest and weakest areas, and where to focus next ā the perfect way to benchmark yourself before diving into the Playbook.