newsletter

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

Download source code
10 min read

Newsletter Sponsors

Copied

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!

šŸ‘‰ Take the survey

Copied

Patch Windows Server Without Restarting

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.

šŸ‘‰ Register for the free Summit

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 this post, we will explore:

  • What is Event Sourcing
  • Overview of the Shopping Cart Application
  • Setting Up Marten with PostgreSQL
  • Defining Domain Events
  • Building the Aggregate
  • Writing Commands: Appending Events to Streams
  • Building Read Models with Projections
  • The Query Side: Reading Projected Data
  • When to Use Event Sourcing
  • Summary

Let's dive in.

Copied

What is Event Sourcing

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:

  1. CartCreated - Customer started a new cart
  2. ItemAdded - Wireless Mouse, quantity 1
  3. ItemAdded - Mechanical Keyboard, quantity 1
  4. ItemRemoved - Wireless Mouse
  5. ItemAdded - Gaming Mouse, quantity 2
  6. 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.

Copied

Core Concepts

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.

Copied

Overview of the Shopping Cart Application

We will build a Shopping Cart API that demonstrates all the core Event Sourcing concepts.

Here is what our application supports:

Commands (write side):

  • Create a new cart
  • Add items to the cart
  • Remove items from the cart
  • Change item quantities
  • Checkout the cart
  • Confirm the order

Queries (read side):

  • Get cart details (from an inline projection)
  • Get product popularity rankings (from an async cross-stream projection)

Domain events:

  • CartCreated
  • ItemAdded
  • ItemRemoved
  • ItemQuantityChanged
  • CartCheckedOut
  • OrderConfirmed
Copied

Project Structure

The application follows Vertical Slice Architecture with three projects:

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.

Copied

Setting Up Marten with PostgreSQL

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:

bash
dotnet add package Marten

Then, add Marten into DI:

csharp
services.AddMarten(options => { options.Connection(connectionString); options.DatabaseSchemaName = "shopping_cart"; options.Projections.Add<CartSummaryProjection>(ProjectionLifecycle.Inline); options.Projections.Add<ProductPopularityProjection>(ProjectionLifecycle.Async); }) .AddAsyncDaemon(DaemonMode.Solo);

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.

Copied

Defining Domain Events

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:

csharp
public sealed record CartCreated(Guid CustomerId); public sealed record ItemAdded( Guid ProductId, string ProductName, decimal Price, int Quantity); public sealed record ItemRemoved(Guid ProductId); public sealed record ItemQuantityChanged(Guid ProductId, int NewQuantity); public sealed record CartCheckedOut; public sealed record OrderConfirmed(Guid OrderId);

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
Copied

Building the Aggregate

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.

Here is the complete ShoppingCart aggregate:

csharp
using ShoppingCart.Domain.Events; namespace ShoppingCart.Domain; public enum CartStatus { Active, CheckedOut, Confirmed } public sealed record CartItem(Guid ProductId, string ProductName, decimal Price, int Quantity); public sealed class ShoppingCart { public Guid Id { get; set; } public Guid CustomerId { get; private set; } public List<CartItem> Items { get; private set; } = []; public CartStatus Status { get; private set; } public Guid? OrderId { get; private set; } public void Apply(CartCreated e) { CustomerId = e.CustomerId; Status = CartStatus.Active; } public void Apply(ItemAdded e) { var existing = Items.FirstOrDefault(i => i.ProductId == e.ProductId); if (existing is not null) { Items.Remove(existing); Items.Add(existing with { Quantity = existing.Quantity + e.Quantity }); } else { Items.Add(new CartItem(e.ProductId, e.ProductName, e.Price, e.Quantity)); } } public void Apply(ItemRemoved e) { Items.RemoveAll(i => i.ProductId == e.ProductId); } public void Apply(ItemQuantityChanged e) { var existing = Items.FirstOrDefault(i => i.ProductId == e.ProductId); if (existing is not null) { Items.Remove(existing); Items.Add(existing with { Quantity = e.NewQuantity }); } } public void Apply(CartCheckedOut _) { Status = CartStatus.CheckedOut; } public void Apply(OrderConfirmed e) { Status = CartStatus.Confirmed; OrderId = e.OrderId; } }

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:

  1. CartCreated -> calls Apply(CartCreated e) -> sets CustomerId, Status = Active
  2. ItemAdded (Mouse) -> calls Apply(ItemAdded e) -> adds Mouse to Items
  3. ItemAdded (Keyboard) -> calls Apply(ItemAdded e) -> adds Keyboard to Items
  4. ItemAdded (Mouse again) -> calls Apply(ItemAdded e) -> increments Mouse quantity

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.

Copied

Writing Commands: Appending Events to Streams

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:

csharp
internal sealed class CreateCartHandler(IDocumentSession session) : ICreateCartHandler { public async Task<ErrorOr<CreateCartResponse>> HandleAsync( CreateCartRequest request, CancellationToken cancellationToken) { var cartId = Guid.NewGuid(); var @event = new CartCreated(request.CustomerId); session.Events.StartStream<Domain.ShoppingCart>(cartId, @event); await session.SaveChangesAsync(cancellationToken); return new CreateCartResponse(cartId); } }

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.

Here is the API endpoint using Carter:

csharp
public class CreateCartEndpoint : ICarterModule { public void AddRoutes(IEndpointRouteBuilder app) { app.MapPost("/api/carts", Handle); } private static async Task<IResult> Handle( [FromBody] CreateCartRequest request, IValidator<CreateCartRequest> validator, ICreateCartHandler handler, CancellationToken cancellationToken) { var validationResult = await validator.ValidateAsync(request, cancellationToken); if (!validationResult.IsValid) { return Results.ValidationProblem(validationResult.ToDictionary()); } var response = await handler.HandleAsync(request, cancellationToken); if (response.IsError) { return response.Errors.ToProblem(); } return Results.Created($"/api/carts/{response.Value.CartId}", response.Value); } }

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.

Copied

Adding Items to a Cart

The AddItem handler demonstrates the core Event Sourcing command pattern: load the aggregate, validate business rules, and append an event:

csharp
internal sealed class AddItemHandler(IDocumentSession session) : IAddItemHandler { public async Task<ErrorOr<CartResponse>> HandleAsync( Guid cartId, AddItemRequest request, CancellationToken cancellationToken) { var cart = await session.Events .AggregateStreamAsync<Domain.ShoppingCart>(cartId, token: cancellationToken); if (cart is null) { return Error.NotFound("Cart.NotFound", "Cart not found"); } if (cart.Status != CartStatus.Active) { return Error.Validation("Cart.NotActive", "Cannot add items to a non-active cart"); } var @event = new ItemAdded(request.ProductId, request.ProductName, request.Price, request.Quantity); session.Events.Append(cartId, @event); await session.SaveChangesAsync(cancellationToken); cart = await session.Events .AggregateStreamAsync<Domain.ShoppingCart>(cartId, token: cancellationToken); return cart!.MapToResponse(); } }

Let me break down the pattern:

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.

Copied

Removing Items and Changing Quantities

The RemoveItem handler follows the same pattern but adds item existence validation:

csharp
internal sealed class RemoveItemHandler(IDocumentSession session) : IRemoveItemHandler { public async Task<ErrorOr<CartResponse>> HandleAsync( Guid cartId, Guid productId, CancellationToken cancellationToken) { var cart = await session.Events .AggregateStreamAsync<Domain.ShoppingCart>(cartId, token: cancellationToken); if (cart is null) { return Error.NotFound("Cart.NotFound", "Cart not found"); } if (cart.Status != CartStatus.Active) { return Error.Validation("Cart.NotActive", "Cannot remove items from a non-active cart"); } if (cart.Items.All(i => i.ProductId != productId)) { return Error.NotFound("Cart.ItemNotFound", "Item not found in cart"); } var @event = new ItemRemoved(productId); session.Events.Append(cartId, @event); await session.SaveChangesAsync(cancellationToken); cart = await session.Events .AggregateStreamAsync<Domain.ShoppingCart>(cartId, token: cancellationToken); return cart!.MapToResponse(); } }

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.

Copied

Building Read Models with Projections

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.

Copied

Inline Projection: CartSummary

The CartSummary projection builds a denormalized view of a single cart. It updates inline, so it is always in sync with the event stream.

First, define the read model document:

csharp
public sealed class CartSummary { public Guid Id { get; set; } public Guid CustomerId { get; set; } public List<CartSummaryItem> Items { get; set; } = []; public decimal TotalPrice { get; set; } public int ItemCount { get; set; } public string Status { get; set; } = CartStatus.Active.ToString(); public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } } public sealed record CartSummaryItem( Guid ProductId, string ProductName, decimal Price, int Quantity);

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.

Now define the projection itself:

csharp
public sealed class CartSummaryProjection : SingleStreamProjection<CartSummary, Guid> { public CartSummary Create(IEvent<CartCreated> e) { return new CartSummary { CustomerId = e.Data.CustomerId, Status = CartStatus.Active.ToString(), CreatedAt = e.Timestamp, UpdatedAt = e.Timestamp }; } public void Apply(CartSummary summary, IEvent<ItemAdded> e) { var data = e.Data; var existing = summary.Items.FirstOrDefault(i => i.ProductId == data.ProductId); if (existing is not null) { summary.Items.Remove(existing); summary.Items.Add(existing with { Quantity = existing.Quantity + data.Quantity }); } else { summary.Items.Add(new CartSummaryItem(data.ProductId, data.ProductName, data.Price, data.Quantity)); } RecalculateTotals(summary, e.Timestamp); } public void Apply(CartSummary summary, IEvent<ItemRemoved> e) { summary.Items.RemoveAll(i => i.ProductId == e.Data.ProductId); RecalculateTotals(summary, e.Timestamp); } public void Apply(CartSummary summary, IEvent<ItemQuantityChanged> e) { var data = e.Data; var existing = summary.Items.FirstOrDefault(i => i.ProductId == data.ProductId); if (existing is not null) { summary.Items.Remove(existing); summary.Items.Add(existing with { Quantity = data.NewQuantity }); } RecalculateTotals(summary, e.Timestamp); } public void Apply(CartSummary summary, IEvent<CartCheckedOut> e) { summary.Status = CartStatus.CheckedOut.ToString(); summary.UpdatedAt = e.Timestamp; } public void Apply(CartSummary summary, IEvent<OrderConfirmed> e) { summary.Status = CartStatus.Confirmed.ToString(); summary.UpdatedAt = e.Timestamp; } private static void RecalculateTotals(CartSummary summary, DateTimeOffset timestamp) { summary.TotalPrice = summary.Items.Sum(i => i.Price * i.Quantity); summary.ItemCount = summary.Items.Sum(i => i.Quantity); summary.UpdatedAt = timestamp; } }

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.

Copied

Async Projection: ProductPopularity

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.

csharp
public sealed class ProductPopularity { public Guid Id { get; set; } public string ProductName { get; set; } = string.Empty; public int TimesAddedToCart { get; set; } public DateTimeOffset LastAddedAt { get; set; } } public sealed class ProductPopularityProjection : MultiStreamProjection<ProductPopularity, Guid> { public ProductPopularityProjection() { Identity<ItemAdded>(e => e.ProductId); } public ProductPopularity Create(IEvent<ItemAdded> e) { return new ProductPopularity { Id = e.Data.ProductId, ProductName = e.Data.ProductName, TimesAddedToCart = e.Data.Quantity, LastAddedAt = e.Timestamp }; } public void Apply(ProductPopularity view, IEvent<ItemAdded> e) { view.TimesAddedToCart += e.Data.Quantity; view.ProductName = e.Data.ProductName; view.LastAddedAt = e.Timestamp; } }

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.

Copied

The Query Side: Reading Projected 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.

Copied

Getting Cart Details

The GetCart handler reads from the CartSummary inline projection:

csharp
internal sealed class GetCartHandler(IQuerySession session) : IGetCartHandler { public async Task<ErrorOr<CartSummaryResponse>> HandleAsync( Guid cartId, CancellationToken cancellationToken) { var summary = await session.LoadAsync<CartSummary>(cartId, cancellationToken); if (summary is null) { return Error.NotFound("Cart.NotFound", "Cart not found"); } return new CartSummaryResponse(...); } }

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.

Copied

Getting Product Popularity

The GetProductPopularity handler queries the ProductPopularity async projection:

csharp
internal sealed class GetProductPopularityHandler(IQuerySession session) : IGetProductPopularityHandler { public async Task<ErrorOr<List<ProductPopularityResponse>>> HandleAsync( int top, CancellationToken cancellationToken) { var count = top > 0 ? top : 10; var results = await session.Query<ProductPopularity>() .OrderByDescending(x => x.TimesAddedToCart) .Take(count) .ToListAsync(cancellationToken); return results .Select(x => new ProductPopularityResponse(x.Id, x.ProductName, x.TimesAddedToCart, x.LastAddedAt)) .ToList(); } }

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.

Copied

When to Use Event Sourcing

Event Sourcing is a powerful pattern, but it is not the right choice for every application.

Copied

When Event Sourcing Shines:

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.

Copied

When to Stick with Traditional CRUD:

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.

Copied

The Pragmatic Approach:

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.

Copied

Summary

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
Download source code

Whenever you're ready, here's how I can help you:

The .NET Senior Playbook is built to:

  • Fast-track you from junior or mid-level to senior
  • Keep you growing as a senior
  • Help you beat any .NET interview

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.

The .NET Senior Playbook
View the Playbook

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.

Start your free run

Enjoyed this article? Share it with your network

Improve Your .NET and Architecture Skills

Join my community of 25,000+ developers and architects.

Each week you will get 1 practical tip with best practices and real-world examples.

Learn how to craft better software with source code available for my newsletter.

Join 25,000+ developers already reading
No spam. Unsubscribe any time.