Dynamic Consistency Boundary in Marten, Part 3: Less ceremony with Wolverine

Dynamic Consistency Boundary in Marten, Part 3: Less ceremony with Wolverine

In Part 2 we built the coupon redemption command using Marten’s DCB API by hand. The RedeemAsync method was about 35 lines, most of it boilerplate around FetchForWritingByTags, SaveChangesAsync, the try/catch for DcbConcurrencyException, and a retry loop. The HTTP layer on top added another lambda inside app.MapPost(...).

For one endpoint that is fine. For thirty endpoints the repetition starts to hurt. Wolverine is a command, message, and HTTP handler framework that is part of the same Critter Stack as Marten, and its WolverineFx.Http package lets a single static method serve as both the HTTP endpoint and the DCB handler.

This post uses Wolverine 6.x with WolverineFx.Marten 6.x and WolverineFx.Http 6.x.

The endpoint

The same business rule from Part 2, expressed as a Wolverine.HTTP endpoint:

public record RedeemCouponBody(Guid CustomerId, decimal OrderTotal);
public record RedeemResponse(string Status);

public static class RedeemCouponEndpoint
{
    public static EventTagQuery Load(string code, RedeemCouponBody body)
        => new EventTagQuery()
            .Or<CouponCode>(new CouponCode(code))
            .Or<CustomerId>(new CustomerId(body.CustomerId));

    [ProducesResponseType(typeof(RedeemResponse), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    [ProducesResponseType(StatusCodes.Status409Conflict)]
    [WolverinePost("/coupons/{code}/redeem")]
    public static IResult Post(
        string code,
        RedeemCouponBody body,
        [BoundaryModel] IEventBoundary<CouponRedemptionGuard> boundary,
        IDocumentSession session)
    {
        var guard = boundary.Aggregate;
        if (guard is null || guard.MaxTotalUses == 0)
            return Results.NotFound(new { error = $"Coupon {code} not defined" });

        var decision = guard.CanRedeem(body.CustomerId);
        if (!decision.Allowed)
            return Results.Conflict(new { error = decision.Reason });

        var redeemed = session.Events.BuildEvent(
            new CouponRedeemed(code, body.CustomerId, body.OrderTotal));
        redeemed.WithTag(new CouponCode(code), new CustomerId(body.CustomerId));
        boundary.AppendOne(redeemed);

        return Results.Ok(new RedeemResponse("accepted"));
    }

    public static void Configure(HandlerChain chain)
    {
        chain.OnException<ConcurrencyException>()
            .RetryWithCooldown(50.Milliseconds(), 100.Milliseconds(), 250.Milliseconds());
    }
}

Three methods, each doing one thing:

  • Load(...) builds the EventTagQuery that defines the consistency boundary for this command. Its parameters match the endpoint method’s parameters by name, so Wolverine can run Load first, fetch the matching events, and project them into CouponRedemptionGuard before the endpoint runs.
  • Post(...) is the endpoint itself. It takes the boundary wrapper via [BoundaryModel] IEventBoundary<CouponRedemptionGuard>, the projected guard exposes both the snapshot (boundary.Aggregate) and the writer (boundary.AppendOne). The injected IDocumentSession is needed for one specific reason: the event has to be built and tagged explicitly. Marten’s boundary.AppendOne(rawEvent) tries to infer tags by matching the event’s property types against registered tag types; our CouponRedeemed carries string and Guid, not the CouponCode and CustomerId wrappers, so inference fails. session.Events.BuildEvent(...).WithTag(...) followed by boundary.AppendOne(builtEvent) is the explicit form. (You could alternatively define the event with wrapper-typed properties; the explicit form is the choice that keeps the existing event schema unchanged.)
  • Returning IResult is a first-class Wolverine.HTTP pattern, and it’s the right choice here because the endpoint legitimately returns three different status codes (200, 404, 409). The one trade-off: Wolverine can’t infer the response body type from IResult, so OpenAPI/Swagger would show no schema. The [ProducesResponseType(...)] attributes (standard ASP.NET Core, honored by Wolverine.HTTP) restore that metadata. The alternative - returning a concrete type like RedeemResponse directly - gives OpenAPI for free but commits you to a single status code; with three outcomes to express, IResult plus the attributes is the cleaner fit.
  • Configure(...) tells Wolverine what to do when SaveChangesAsync throws a concurrency exception. Wolverine does not auto-retry on transient exceptions; without an explicit policy a single losing race would propagate as a 500. The RetryWithCooldown(50ms, 100ms, 250ms) policy gives the endpoint three attempts with growing pauses. Each retry re-runs Load, the fetch, and Post from scratch, so the second pass sees the world updated by whatever event caused the first conflict - and on Marten 9.4.0+ the DCB check serializes the appends, so that second pass observes the committed cap and rejects with a clean 409. The cooldown itself is just backoff now (it was load-bearing on 9.3.x, where the check was non-locking; Part 4 has that history). If every DCB endpoint in the service should share the policy, set it once on opts.Policies in Program.cs instead.

At application startup Wolverine generates the wrapper: call Load, run FetchForWritingByTags with the returned query, project into CouponRedemptionGuard, call Post, save changes. On a concurrency exception it applies the policy from Configure and re-runs the whole sequence up to the configured retry count.

NOTE

Why isn’t validation factored out into a sibling Validate(...) middleware method? Wolverine.HTTP supports that pattern, and it is the right shape when the check is input-shape validation - “is the request body well-formed”, “is this a valid email”, “is the date in the future”. Those checks don’t depend on the state of the world and they belong in middleware, before any aggregate is fetched.

The checks in this endpoint are different. “Does this coupon exist?” and “is this customer under the cap?” are read from the projected CouponRedemptionGuard - i.e. from the same boundary the endpoint is about to append into. They aren’t validation; they’re the business decision. DCB’s whole shape is “construct the decision model from a tag query, decide, write.” Splitting the read from the write across two methods would express two stages where there is really one - and would tempt the reader to think the boundary is read twice when it isn’t. Keeping Post as one method is the more honest expression of what the endpoint does. Save the Validate(...) slot for the input-shape work that genuinely doesn’t need the aggregate.

What changed

Comparing the two implementations end-to-end, including the HTTP layer:

ConcernPlain Marten + app.MapPostWolverine.HTTP
Total lines (handler + HTTP)~45~25
Open session, disposemanualgenerated
FetchForWritingByTagsmanualgenerated
Build and tag the eventBuildEvent + WithTagBuildEvent + WithTag + boundary.AppendOne(builtEvent) - same dance, scoped to the endpoint
SaveChangesAsyncmanualgenerated
try/catch DcbConcurrencyException + retry loopmanual, inline for loopdeclared via Configure(...) or global policy; loop generated
HTTP routingapp.MapPost("/...", ...) lambda[WolverinePost("/...")] attribute
Failure-path HTTP statusesmanual switch returning Results.Ok/NotFound/Conflictinline Results.NotFound/Conflict/Ok inside the endpoint - the state-dependent checks aren’t middleware-shaped
Happy-path response shapeanonymous object inside Results.Ok(...)typed RedeemResponse record (drives OpenAPI metadata)
The business decisionguard.CanRedeem(...)guard.CanRedeem(...)

The decision is the only line that survives. Everything else moves into framework code.

This is the same kind of trade made when adopting MediatR over hand-written dispatch or EF Core over hand-written ADO. Less explicit code in the endpoint, less surface area to bug, more framework behaviour to trust. The trade is usually right. It is worth being aware that you made it: when something fails at 3am the stack trace points at generated code rather than your RedeemAsync method, and you need to be comfortable reading what Wolverine emits.

Wiring it up

Add three Wolverine packages alongside WolverineFx.Marten:

<PackageReference Include="WolverineFx" Version="6.*" />
<PackageReference Include="WolverineFx.Marten" Version="6.*" />
<PackageReference Include="WolverineFx.Http" Version="6.*" />
<PackageReference Include="WolverineFx.RuntimeCompilation" Version="6.*" />

The last one is easy to miss. Wolverine 6 split the Roslyn runtime compiler out of core; without WolverineFx.RuntimeCompilation, the host throws “Wolverine is running in TypeLoadMode.Dynamic … but no IAssemblyGenerator (Roslyn) is registered” at startup. The package auto-registers when referenced. The production-grade alternative is dotnet run -- codegen write plus opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Static; for a sample, runtime compilation is fine.

The bootstrap is several lines beyond the Marten configuration from Part 2. Every line is load-bearing - Part 4 expands on which ones bite if missed:

builder.Services.AddMarten(opts =>
{
    opts.Connection(connStr);

    opts.Events.RegisterTagType<CouponCode>("coupon")
        .ForAggregate<CouponRedemptionGuard>();
    opts.Events.RegisterTagType<CustomerId>("customer")
        .ForAggregate<CouponRedemptionGuard>();

    opts.Events.AddEventType<CouponDefined>();
    opts.Events.AddEventType<CouponRedeemed>();
})
.IntegrateWithWolverine();

builder.Host.UseWolverine();
builder.Services.AddWolverineHttp();

var app = builder.Build();

app.MapWolverineEndpoints();
  • .ForAggregate<T>() chained on every RegisterTagType<>() is what links the tag to the boundary aggregate. Without it, Marten treats the type as a regular single-stream projection and the first FetchForWritingByTags<T>(...) fails to resolve a dispatcher. (Marten 9.3 and earlier also needed opts.Events.StreamIdentity = StreamIdentity.AsString here as a workaround for a [BoundaryAggregate] dispatcher bug; that is fixed in current Marten - the runtime now pins string for boundary aggregates regardless of StreamIdentity - so the line is no longer required.)
  • AddEventType<T>() for every event is required under IntegrateWithWolverine(). Plain DocumentStore.For(...) lazy-registers events on first BuildEvent; the Wolverine-integrated path does not, and the DCB query’s event-type filter silently returns nothing.
  • IntegrateWithWolverine tells Marten to participate in Wolverine’s outbox and transaction support.
  • UseWolverine starts the handler runtime.
  • AddWolverineHttp is required by WolverineFx.Http. Without it, MapWolverineEndpoints throws “Required usage of IServiceCollection.AddWolverineHttp()” at app startup.
  • MapWolverineEndpoints discovers every static method decorated with [WolverinePost], [WolverineGet], and the rest, and registers them as ASP.NET routes.

(If you’ve seen older DCB samples with .UseLightweightSessions() on this chain, that was a workaround: earlier builds opened a heavy session through OutboxedSessionFactory, whose identity map made FetchForWritingByTags<T> return null aggregates so every DCB endpoint quietly 404’d. Lightweight sessions are the default now, so the explicit call is redundant and the line is gone.)

There is no separate app.MapPost(...) call. The endpoint is the handler.

Testing the endpoint with Alba

Because the endpoint runs inside the generated DCB wrapper, the most useful test exercises it through HTTP rather than reaching into Wolverine’s internals. Alba is a small library from the Critter Stack that bootstraps an ASP.NET Core app in-process and lets you make HTTP requests against it without a real network listener.

Paired with Testcontainers, the test runs against a real Postgres with no external setup:

public class RedemptionHttpTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
        .WithImage("postgres:16")
        .WithDatabase("dcb_sample")
        .WithUsername("dcb").WithPassword("dcb")
        .Build();

    private IAlbaHost _host = null!;

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();

        // ConnectionStrings__Postgres is read by WebApplication.CreateBuilder
        // at construction time, before AddMarten(...) inside Program.cs reads
        // builder.Configuration.GetConnectionString("Postgres"). Going through
        // Alba's ConfigureAppConfiguration would run too late.
        Environment.SetEnvironmentVariable(
            "ConnectionStrings__Postgres", _postgres.GetConnectionString());

        _host = await AlbaHost.For<Program>(_ => { });
    }

    public async Task DisposeAsync()
    {
        await _host.DisposeAsync();
        Environment.SetEnvironmentVariable("ConnectionStrings__Postgres", null);
        await _postgres.DisposeAsync();
    }

    [Fact]
    public async Task Per_customer_cap_is_respected_under_concurrency()
    {
        await _host.Scenario(s =>
        {
            s.Post.Json(new DefineCoupon("SUMMER25", 1000, 2)).ToUrl("/coupons");
            s.StatusCodeShouldBeOk();
        });

        var customerId = Guid.NewGuid();
        var client = _host.Server.CreateClient();

        var tasks = Enumerable.Range(0, 50)
            .Select(_ => client.PostAsJsonAsync(
                "/coupons/SUMMER25/redeem",
                new { CustomerId = customerId, OrderTotal = 9.99m }))
            .ToArray();

        var responses = await Task.WhenAll(tasks);

        var ok = responses.Count(r => r.StatusCode == HttpStatusCode.OK);
        ok.ShouldBeLessThanOrEqualTo(2);
        ok.ShouldBeGreaterThan(0);
    }
}

Fifty parallel HTTP POSTs. The DCB invariant that survives is the cap holds exactly - never over. The split between 200 OK and everything else is decided by the DCB check: on Marten 9.4.0+ that check serializes concurrent same-tag appends on a row-level constraint, so exactly two land and the rest lose. The losers come back 409 Conflict (the retry policy converts the DcbConcurrencyException into a clean rejection), or 500 if a request exhausts its retry budget under contention - both mean “your redemption did not land”. The assertion is <= 2 (and > 0) mainly defensively; with the hard cap you’ll see exactly two 200s. On Marten 9.3.x this was instead a soft cap - the non-locking check could let a genuinely-simultaneous writer slip past, so the count could briefly sit at cap+1. Marten 9.4.0 closed that race (issue #4591); Part 4 covers the mechanism and the upgrade.

A small detail: for Program to be visible to AlbaHost.For<Program>, the top-level-statements file needs a public partial class Program; at the bottom. ASP.NET Core emits Program as internal by default, which Alba cannot reach.

Why the connection string goes through an environment variable instead of Alba’s ConfigureAppConfiguration: Program.cs reads builder.Configuration.GetConnectionString("Postgres") immediately inside AddMarten(opts => ...), which runs synchronously during service registration. Alba’s ConfigureAppConfiguration hook merges into the configuration pipeline later, by which point the hardcoded fallback in AddMarten has already won. WebApplication.CreateBuilder(args) reads environment variables up front, so ConnectionStrings__Postgres (double underscore, ASP.NET’s nested-key convention) is in place before AddMarten looks for it. Part 4 covers the trap in more detail.

When to reach for which

For one or two DCB commands, or while still learning the pattern, plain Marten with a hand-rolled app.MapPost is the easier read. The dance is visible and debuggable. For a service with many DCB endpoints, or a codebase already using Wolverine for other work, Wolverine.HTTP removes a real amount of repetition and keeps the HTTP route, the consistency boundary, and the business decision in one obvious place.

Both can coexist in the same Marten configuration. The companion repo ships both projects pointing at the same Postgres, with separate ports.

Next

Part 4 covers what changes when this leaves the laptop. Tag storage modes and their performance characteristics, the global-lock failure mode of over-broad tag queries, tag governance, schema evolution, and when DCB is the wrong tool.