Event Sourcing in Practice: Marten and Wolverine Working Together

Event Sourcing in Practice: Marten and Wolverine Working Together
by Brad Jolicoeur
06/14/2026

Event sourcing sounds compelling until you try to wire it up. You end up with an event store, a projection engine, a message bus for reacting to those events, and somewhere a transactional boundary that has to hold all three together atomically. Each piece works fine in isolation. Getting them to cooperate is where the accidental complexity lives.

Marten and Wolverine solve this together in a way I haven't seen another .NET stack match. Marten gives you a production-grade event store on PostgreSQL. Wolverine gives you durable message handling and the transactional outbox. IntegrateWithWolverine() is a single call that binds them — events appended to Marten and messages published through Wolverine happen in the same database transaction. No two-phase commit. No eventual consistency tax.

This article is a practical walkthrough of how that combination works. I'll cover the event store setup, aggregate patterns, projections, and how Wolverine handlers react to Marten events — all grounded in a concrete example. If you're already familiar with Marten or Wolverine individually, this is about what happens when they share a transaction boundary.

I've written about both tools separately: Exploring MartenDb covers the document store fundamentals, and the Wolverine series starts with concurrency and builds up through scaling and AI agents. This article assumes you're comfortable with the basics and focuses on the integration.

The example code is available on GitHub. It targets .NET 10, Marten 9.x, and WolverineFx 6.x, and uses .NET Aspire to spin up PostgreSQL — dotnet run --project AppHost starts everything.

Why This Combination Exists

The classic event sourcing problem in distributed systems: you append events to your event store and publish domain events to your message bus. If the append succeeds but the publish fails, consumers never know something happened. If the publish succeeds but the event store write fails, consumers act on something that didn't happen. The standard fix — a transactional outbox — requires you to build and maintain that infrastructure.

Marten and Wolverine sidestep this entirely because they share PostgreSQL. When you call IntegrateWithWolverine(), Wolverine's durable outbox lives in the same Postgres database as Marten's event streams. A Wolverine handler that appends events and cascades messages does all of that in one transaction. Either everything commits or nothing does.

This isn't just architectural tidiness. It removes an entire category of production bug — the kind where your read models are stale because a projection event was lost, or a downstream service acted on a message for an order that doesn't exist in the event store.

The Domain Example

I'll use a straightforward order management domain: orders move through states (placed → validated → fulfilled → completed), and each state change is an event. Downstream services react to those events via Wolverine messages.

The domain events:

// Events — immutable records of what happened
public record OrderPlaced(Guid OrderId, Guid CustomerId, decimal Total, DateTimeOffset OccurredAt);
public record OrderValidated(Guid OrderId, DateTimeOffset OccurredAt);
public record OrderFulfilled(Guid OrderId, Guid CustomerId, string TrackingNumber, DateTimeOffset OccurredAt);
public record OrderCompleted(Guid OrderId, DateTimeOffset OccurredAt);
public record OrderCancelled(Guid OrderId, string Reason, DateTimeOffset OccurredAt);

Note that OrderFulfilled carries CustomerId — the event is a self-contained record of what happened, and downstream handlers shouldn't need to look up related data to act on it.

The aggregate that owns the state transitions:

public class Order
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public decimal Total { get; private set; }
    public OrderStatus Status { get; private set; }
    public string? TrackingNumber { get; private set; }

    // Marten calls these Apply methods when replaying the event stream
    public void Apply(OrderPlaced e)
    {
        Id = e.OrderId;
        CustomerId = e.CustomerId;
        Total = e.Total;
        Status = OrderStatus.Placed;
    }

    public void Apply(OrderValidated e) => Status = OrderStatus.Validated;

    public void Apply(OrderFulfilled e)
    {
        TrackingNumber = e.TrackingNumber;
        Status = OrderStatus.Fulfilled;
    }

    public void Apply(OrderCompleted e) => Status = OrderStatus.Completed;
    public void Apply(OrderCancelled e) => Status = OrderStatus.Cancelled;

    // Business methods enforce invariants and return events — they don't mutate state directly.
    // This keeps the aggregate testable without a database.
    public OrderValidated Validate()
    {
        if (Status != OrderStatus.Placed)
            throw new InvalidOperationException($"Cannot validate an order in {Status} status.");

        return new OrderValidated(Id, DateTimeOffset.UtcNow);
    }

    public OrderFulfilled Fulfill(string trackingNumber)
    {
        if (Status != OrderStatus.Validated)
            throw new InvalidOperationException($"Cannot fulfill an order in {Status} status.");

        return new OrderFulfilled(Id, CustomerId, trackingNumber, DateTimeOffset.UtcNow);
    }
}

public enum OrderStatus { Placed, Validated, Fulfilled, Completed, Cancelled }

Two things worth noting. First, Apply methods are the only way state changes — there's no setter called from outside the aggregate. Second, business methods (Validate, Fulfill) return events rather than mutating state directly. The caller appends those events to the stream; the aggregate replays them to rebuild state. This keeps the aggregate pure and testable without a database.

Setting Up the Integration

Two packages beyond the core Marten and Wolverine NuGets are worth calling out explicitly. WolverineFx.RuntimeCompilation is required — as of WolverineFx 6.x the Roslyn runtime compiler ships separately and must be referenced explicitly (it self-registers on startup). Without it, the app throws on startup. WolverineFx.Http adds the Wolverine HTTP endpoint pipeline, and WolverineFx.Marten provides the [AggregateHandler] attribute and IntegrateWithWolverine().

// Program.cs
using JasperFx;
using JasperFx.Events.Daemon;
using JasperFx.Events.Projections;
using Marten;
using Marten.Events.Projections;
using OrderManagement.Api.Infrastructure;
using OrderManagement.Api.Projections;
using Wolverine;
using Wolverine.Http;
using Wolverine.Marten;

var builder = WebApplication.CreateBuilder(args);

// Aspire service defaults: OpenTelemetry, health checks, service discovery.
// Remove this line if you're not using Aspire.
builder.AddServiceDefaults();

builder.Services.AddMarten(opts =>
{
    // When running via Aspire, the connection string is injected automatically.
    opts.Connection(builder.Configuration.GetConnectionString("order-management")
        ?? builder.Configuration.GetConnectionString("Postgres")!);

    // Development only — create or update schema automatically.
    // Use AutoCreate.None in production and run migrations via the CLI.
    opts.AutoCreateSchemaObjects = AutoCreate.CreateOrUpdate;

    // Inline projection — OrderSummary updates synchronously with the event append.
    opts.Projections.Add<OrderSummaryProjection>(ProjectionLifecycle.Inline);

    // Async projection — OrderTimeline runs in Wolverine's background daemon.
    opts.Projections.Add<OrderTimelineProjection>(ProjectionLifecycle.Async);

    // Performance: use identity map when loading aggregates for write
    opts.Projections.UseIdentityMapForAggregates = true;
})
// Lightweight sessions skip the identity map for document reads — better query perf
.UseLightweightSessions()
// Starts the async projection daemon
// Solo = single process (dev). Use HotCold for multi-node production.
.AddAsyncDaemon(DaemonMode.Solo)
// Wires Wolverine's durable outbox into Marten's PostgreSQL database
.IntegrateWithWolverine();

builder.Host.UseWolverine(opts =>
{
    // Automatically wraps handlers and endpoints that use IDocumentSession
    // with transactional middleware — calls SaveChangesAsync after the handler.
    // Without this, writes to IDocumentSession are silently dropped.
    opts.Policies.AutoApplyTransactions();

    opts.Discovery.IncludeAssembly(typeof(Program).Assembly);
});

builder.Services.AddWolverineHttp();

var app = builder.Build();

app.MapDefaultEndpoints(); // Aspire health check endpoints — remove if not using Aspire

// HttpExceptionMiddleware.OnException methods are code-generated into every
// endpoint's try/catch by Wolverine at startup — see the section below.
app.MapWolverineEndpoints(opts =>
    opts.AddMiddleware(typeof(HttpExceptionMiddleware)));

// RunJasperFxCommands enables the CLI (db-apply, describe, etc.)
// and falls through to app.RunAsync() when no CLI argument is provided.
return await app.RunJasperFxCommands(args);

A few things worth calling out. AutoApplyTransactions() is non-obvious but important — without it, any handler that injects IDocumentSession and writes to it will silently drop those writes because Wolverine opens the managed session but never calls SaveChangesAsync. The policy detects handlers that use Marten services and wraps them automatically.

RunJasperFxCommands enables the JasperFx CLI (dotnet run -- db-apply, dotnet run -- describe) for schema management and diagnostics. It falls through to the normal web host when no command is given.

The Aggregate Handler Pattern

Wolverine has a first-class pattern for aggregate command handling that removes significant boilerplate. Without it, every handler that modifies an aggregate looks like this:

// Without the pattern — manual load, mutate, append, save
public async Task Handle(ValidateOrder command, IDocumentSession session)
{
    var order = await session.Events.AggregateStreamAsync<Order>(command.OrderId);
    if (order is null) throw new InvalidOperationException($"Order {command.OrderId} not found.");

    var @event = order.Validate();
    session.Events.Append(command.OrderId, @event);
    await session.SaveChangesAsync();
}

This works, but you're writing the same load-append-save scaffolding in every handler. Wolverine's [AggregateHandler] attribute eliminates it:

using Wolverine.Marten;

public record ValidateOrder(Guid OrderId);

// Wolverine finds the Order stream using command.OrderId (convention: {AggregateName}Id),
// replays events to reconstruct the aggregate, calls Handle, appends the returned event,
// and calls SaveChangesAsync — all automatically.
[AggregateHandler]
public static class ValidateOrderHandler
{
    public static OrderValidated Handle(ValidateOrder command, Order order) =>
        order.Validate();
}

The naming convention is the key. Wolverine looks for a property on the command named {AggregateName}Id — so OrderId for the Order aggregate. It loads the event stream for that ID, applies all events to a new Order instance, and passes the reconstructed aggregate to your handler. Your method contains only the business rule.

Cascading Messages with Events

When a command needs to produce both a domain event and trigger downstream work, use IEventStream<T> with IMessageContext. The example's FulfillOrder handler appends the fulfillment event and publishes a notification message in the same transaction:

using JasperFx.Events;
using Wolverine;
using Wolverine.Marten;

[AggregateHandler]
public static class FulfillOrderHandler
{
    public static async Task Handle(
        FulfillOrder command,
        IEventStream<Order> stream,
        IMessageContext context)
    {
        var order = stream.Aggregate
            ?? throw new InvalidOperationException($"Order {command.OrderId} not found.");

        var fulfilled = order.Fulfill(command.TrackingNumber);
        stream.AppendOne(fulfilled);

        // This message goes through Wolverine's durable outbox —
        // not published until the event stream write commits.
        await context.PublishAsync(new SendFulfillmentNotification(
            order.Id,
            order.CustomerId,
            command.TrackingNumber));
    }
}

IEventStream<T> gives direct access to the aggregate and stream. stream.AppendOne queues the event; context.PublishAsync queues the downstream message. Wolverine commits both when the handler completes. If the notification handler fails later, Wolverine retries it — the OrderFulfilled event is already in the stream regardless.

The handler for the cascaded message is straightforward:

public static class FulfillmentNotificationHandler
{
    public static void Handle(SendFulfillmentNotification notification, ILogger logger)
    {
        // Replace with real notification logic — email, SMS, push notification, etc.
        logger.LogInformation(
            "Fulfillment notification for order {OrderId}: tracking {TrackingNumber}",
            notification.OrderId,
            notification.TrackingNumber);
    }
}

Projections: Inline vs. Async

Marten gives you two projection lifecycles that map to different consistency requirements.

In Marten 9.x, projection classes inherit from SingleStreamProjection<TDoc, TId> in the Marten.Events.Aggregation namespace (the second type param is the stream identity type, Guid for most cases). The ProjectionLifecycle enum is now in JasperFx.Events.Projections.

Inline projections update synchronously when events are appended. The read model is always consistent with the event stream at commit time — there's no lag. The trade-off is that the projection work happens inside the command's transaction, so slow projections slow down writes.

using Marten.Events.Aggregation;
using JasperFx.Events.Projections;

// Inline projection — OrderSummary is always consistent with the event stream
// Must be partial — JasperFx source generator emits the event dispatcher at compile time
public partial class OrderSummaryProjection : SingleStreamProjection<OrderSummary, Guid>
{
    public OrderSummary Create(OrderPlaced e) => new()
    {
        Id = e.OrderId,
        CustomerId = e.CustomerId,
        Total = e.Total,
        Status = "Placed",
        CreatedAt = e.OccurredAt,
        LastUpdatedAt = e.OccurredAt
    };

    public void Apply(OrderValidated e, OrderSummary summary)
    {
        summary.Status = "Validated";
        summary.LastUpdatedAt = e.OccurredAt;
    }

    public void Apply(OrderFulfilled e, OrderSummary summary)
    {
        summary.Status = "Fulfilled";
        summary.TrackingNumber = e.TrackingNumber;
        summary.LastUpdatedAt = e.OccurredAt;
    }

    public void Apply(OrderCompleted e, OrderSummary summary)
    {
        summary.Status = "Completed";
        summary.LastUpdatedAt = e.OccurredAt;
    }

    public void Apply(OrderCancelled e, OrderSummary summary)
    {
        summary.Status = "Cancelled";
        summary.LastUpdatedAt = e.OccurredAt;
    }
}

Async projections run via Marten's async daemon, which Wolverine drives in the background. The projection is eventually consistent — there's a small lag after the event is appended before the read model updates. The benefit is that projection work doesn't block writes, and the daemon has its own retry and observability story.

// Async projection — audit trail for each order, runs in background daemon
// Must be partial — JasperFx source generator emits the event dispatcher at compile time
public partial class OrderTimelineProjection : SingleStreamProjection<OrderTimeline, Guid>
{
    public OrderTimeline Create(OrderPlaced e) => new()
    {
        Id = e.OrderId,
        Entries =
        [
            new OrderTimelineEntry("OrderPlaced", $"Order placed — total: {e.Total:C}", e.OccurredAt)
        ]
    };

    public void Apply(OrderValidated e, OrderTimeline timeline) =>
        timeline.Entries.Add(new("OrderValidated", "Order validated", e.OccurredAt));

    public void Apply(OrderFulfilled e, OrderTimeline timeline) =>
        timeline.Entries.Add(new("OrderFulfilled", $"Fulfilled — tracking: {e.TrackingNumber}", e.OccurredAt));

    public void Apply(OrderCompleted e, OrderTimeline timeline) =>
        timeline.Entries.Add(new("OrderCompleted", "Order completed", e.OccurredAt));

    public void Apply(OrderCancelled e, OrderTimeline timeline) =>
        timeline.Entries.Add(new("OrderCancelled", $"Cancelled — reason: {e.Reason}", e.OccurredAt));
}

The choice between inline and async isn't always obvious. A useful heuristic: if a UI component queries the read model immediately after a command returns (optimistic UI, confirmation screens), use inline. If the read model is for audit trails, reports, or heavy aggregations that don't need to be immediately current, async is the better fit.

Querying the Read Model

Querying is straightforward Marten document store usage, exposed through Wolverine HTTP endpoints. One important constraint to know upfront: each Wolverine HTTP endpoint must be its own static class. Wolverine's code generator binds to the class type, not the method — multiple [WolverineGet] or [WolverinePost] methods in the same class will silently route all requests to the first method it finds, ignoring the rest.

using Marten;
using Wolverine.Http;

// One class per endpoint — this is required, not a style preference
public static class GetOrderEndpoint
{
    // Returns the inline OrderSummary — always consistent with the event stream
    [WolverineGet("/api/orders/{orderId}")]
    public static Task<OrderSummary?> Get(Guid orderId, IQuerySession session) =>
        session.LoadAsync<OrderSummary>(orderId);
}

public static class GetOrdersEndpoint
{
    [WolverineGet("/api/orders")]
    public static Task<IReadOnlyList<OrderSummary>> Get(IQuerySession session) =>
        session.Query<OrderSummary>().ToListAsync();
}

public static class GetOrderTimelineEndpoint
{
    // Returns the async timeline — may lag slightly behind the write side
    [WolverineGet("/api/orders/{orderId}/timeline")]
    public static Task<OrderTimeline?> Get(Guid orderId, IQuerySession session) =>
        session.LoadAsync<OrderTimeline>(orderId);
}

CQRS falls out naturally. Commands go through Wolverine handlers. Queries hit the Marten document store directly, reading from projections those commands built. The query side has no dependency on Wolverine — it's just Marten.

The HTTP Entry Point

For new order creation, Wolverine provides the IStartStream side effect — a clean way to start a new event stream from an HTTP endpoint without wiring up a separate command handler:

// Endpoints/PlaceOrderEndpoint.cs
using Wolverine.Http;
using Wolverine.Marten;

public static class PlaceOrderEndpoint
{
    [WolverinePost("/api/orders")]
    public static (CreationResponse<Guid>, IStartStream) Post(PlaceOrder command)
    {
        var orderId = Guid.NewGuid();
        var orderPlaced = new OrderPlaced(orderId, command.CustomerId, command.Items);

        // MartenOps.StartStream returns an IStartStream side effect.
        // Wolverine executes it within the transaction — no SaveChangesAsync needed.
        var startStream = MartenOps.StartStream<Order>(orderId, orderPlaced);

        return (new CreationResponse<Guid>($"/api/orders/{orderId}", orderId), startStream);
    }
}

The tuple return is Wolverine's side-effect pattern. CreationResponse<Guid> becomes the HTTP 201 response body. IStartStream is a Marten side effect that Wolverine executes within the current transaction. The method is a pure function — no services injected, no async, nothing to mock.

One critical rule when writing Wolverine HTTP endpoints: each endpoint must be its own static class. Wolverine's code generator binds to the class type when emitting handler code. If you put multiple [WolverinePost] methods in the same class, all generated handlers will silently route to the first method in the class — the other routes are effectively dead. One class, one endpoint.

For the state-transition endpoints, combine [WolverinePost] with [AggregateHandler]:

// Endpoints/ValidateOrderEndpoint.cs
public static class ValidateOrderEndpoint
{
    // [AggregateHandler] loads the Order aggregate from the event stream by orderId,
    // injects it as a parameter, and appends the returned event to the stream.
    [WolverinePost("/api/orders/{orderId}/validate")]
    [AggregateHandler]
    public static OrderValidated Handle(ValidateOrder command, Order order)
    {
        return order.Validate(); // Returns the event; Wolverine appends it
    }
}

// Endpoints/FulfillOrderEndpoint.cs
public static class FulfillOrderEndpoint
{
    [WolverinePost("/api/orders/{orderId}/fulfill")]
    [AggregateHandler]
    public static OrderFulfilled Handle(FulfillOrder command, Order order)
    {
        return order.Fulfill();
    }
}

[AggregateHandler] tells Wolverine to load the aggregate from Marten's event stream (replaying all events to reconstruct current state), inject it into the handler, and append any returned events back to the stream. The handler never calls SaveChangesAsync — that's handled by AutoApplyTransactions() in the Wolverine policy.

Note: If you're integrating Wolverine into an existing application that has established controller contracts, publishing through IMessageBus from a controller works fine. You're opting out of some of Wolverine's middleware pipeline benefits, but it's a valid incremental adoption path.

Exception Handling the Wolverine Way

Domain exceptions from aggregate methods (InvalidOperationException for invalid state transitions, ArgumentException for bad input) need to surface as meaningful HTTP responses rather than raw 500 stack traces. Wolverine has a built-in pattern for this using OnException naming conventions on a middleware class.

// Infrastructure/HttpExceptionMiddleware.cs
using Microsoft.AspNetCore.Mvc;
using Wolverine.Http;

public static class HttpExceptionMiddleware
{
    // Wolverine code-generates a typed catch block for each exception type
    // into every endpoint's handler at startup — zero runtime overhead.
    // Most-derived types are matched first.

    public static ProblemDetails OnException(InvalidOperationException ex) =>
        new()
        {
            Title = "Invalid Operation",
            Detail = ex.Message,
            Status = StatusCodes.Status422UnprocessableEntity
        };

    public static ProblemDetails OnException(ArgumentException ex) =>
        new()
        {
            Title = "Invalid Argument",
            Detail = ex.Message,
            Status = StatusCodes.Status400BadRequest
        };
}

Register it globally in MapWolverineEndpoints:

app.MapWolverineEndpoints(opts =>
    opts.AddMiddleware(typeof(HttpExceptionMiddleware)));

Wolverine inspects the OnException methods at startup and generates typed catch blocks into every endpoint's compiled handler class. By the time a request arrives, there's no runtime reflection — just generated try/catch code that returns ProblemDetails with application/problem+json content type. You get structured error responses without exposing any internals.

When This Pattern Makes Sense

Event sourcing adds real complexity. The projection machinery, the dual read/write models, the event schema evolution problem — these are not free. This combination earns its cost in specific scenarios:

Strong audit requirements. The event stream is a complete, immutable history of everything that happened to an aggregate. If you need to answer "what was the state of this order at 3pm on the 14th?" you replay the stream to that point. You can't do that with a mutable record.

Complex state machines with multiple consumers. When multiple downstream services need to react to the same state changes, publishing from Marten's event stream through Wolverine gives you a single durable source of truth. Add a consumer without modifying the command side.

CQRS with divergent read/write concerns. If your write model and your read model want to be shaped differently — aggregates organized around business rules, read models organized around UI or reporting needs — projections let each evolve independently.

Where it's probably overkill: Simple CRUD with no audit requirement, small domains with a single consumer, teams new to both patterns simultaneously. Event sourcing has a learning curve. Adding Wolverine on top of a new event sourcing implementation can be too much to absorb at once. Get comfortable with Marten's event store first, then layer in Wolverine's integration.

Conclusion

The Marten + Wolverine combination is the most cohesive event sourcing stack I've worked with in .NET. The IntegrateWithWolverine() call genuinely collapses what would otherwise be three separate infrastructure concerns — event storage, projection updates, and downstream messaging — into a single transaction boundary. That's not a convenience feature; it eliminates a class of production reliability problem.

The aggregate handler pattern keeps command handlers focused on business rules. Inline projections give you synchronous read model consistency where you need it. Async projections give you decoupled, durable background processing where you don't. And the IStartStream side-effect pattern makes creating new streams from HTTP endpoints a pure function.

A few things that tripped me up along the way and are worth knowing before you start: projection subclasses that use convention Apply/Create methods must be declared partial — the JasperFx source generator needs it to emit the event dispatcher at compile time. WolverineFx 6.x ships without the Roslyn runtime compiler; reference WolverineFx.RuntimeCompilation separately or the app will throw on startup. And each Wolverine HTTP endpoint must be its own static class — multiple [WolverinePost] methods in the same class silently break routing.

The tooling isn't magic. You still have to think carefully about where event sourcing adds value and where it adds complexity without payoff. But when the problem fits, this stack removes the infrastructure friction and lets you focus on the domain.

The example project on GitHub includes an order-management.http REST client file that walks through the full lifecycle — place, validate, fulfill, complete, cancel — with orderId automatically extracted from the place order response.


Brad Jolicoeur
About Brad Jolicoeur

Principal Architect with 20+ years building and transforming engineering organizations. Wharton Executive CTO Program graduate. Writing about architecture, distributed systems, production AI, and engineering leadership.

Get in touch →   More articles →

You May Also Like


Fast Code, Stuck Value

feature-dollar-days.png
Brad Jolicoeur - 05/26/2026
Read

Coding Was Never the Bottleneck

abolish-local-optima.png
Brad Jolicoeur - 05/25/2026
Read

Your AI Coding Agents Aren't Slow. Your Process Is.

agent-process-constraint.png
Brad Jolicoeur - 05/24/2026
Read