Retrofitting existing xUnit.Net tests to Bobcat

Marten’s async daemon is the subsystem that runs projections and subscriptions in the background. It’s also been historically been the single hardest thing in Marten to test. Every test has to publish events, stand up a daemon (sometimes two, in hot/cold mode), wait for shards to catch up, and then check what the projections wrote. The DaemonTests project has also been for years one of our worst sources of flaky tests and I never trusted it to run in CI until earlier this year.

So when Bobcat needed a real test suite to prove itself against before 1.0, DaemonTests was one of the obvious guinea pigs (another was Wolverine’s leadership compliance tests that I’ll write about next). If projected specifications and the Supervisor could make that suite readable and reliable, they could hopefully do it anywhere.

This post is about how that went: the mechanics of how the tests were decorated, what the specifications look like in preview and in results, where we got real bugs out of it, and how the suite runs in CI today under the Supervisor’s retry budget.

I’ve already introduced Bobcat and the Bobcat Supervisor in earlier posts, so now let’s move on to a “what it looks like on a real codebase” follow up. All of the code is public in the Marten repository, and I’ll link to the actual files as I go.

Fair warning: a lot of the Bobcat work on DaemonTests was done by AI agents working from Bobcat issues, with me reviewing the pull requests. I’m going to quote the agents’ own write ups in a couple of places because, honestly, they documented the trade offs better than I would have.

No need to rewrite tests

Bobcat was initially going to be an alternative Gherkin tool with possibly a more code centric model where specifications could be “projected” from the raw test code — with a 3rd mode using a fluent interface kind of approach where you embedded Lambda functions inside of Given/When/Then() methods. It quickly became clear that the “projected” model was going to be the most usable, so we simplified Bobcat by dropping the early Gherkin model. And the less said about the Fluent Interface + nested Lambda approach, that better. I despise tools like that. Even the ones that I might have written myself years ago.

The constraint that shaped all of Bobcat’s “projected specification” model is that a big, old test suite has to be able to adopt it one class at a time, with no mandatory base class, no signature changes, and no rewrite. DaemonTests is exactly that kind of suite. It’s got hundreds of tests, a lot of them written years apart by different people, and nobody was going to sign up to rewrite them in a new DSL.

The first pull request back in September rendered two of the test classes as specifications. Eighteen tests started reporting themselves as Given/When/Then steps, and not one test body was edited. All of the steps came from decorating five helper methods on the shared DaemonContext base class and marking the two classes with one attribute.

The second pull request in October moved that work to Bobcat 1.0, routed the projection waits through Bobcat’s catch-up diagnostics, added a handful of brand new specifications written with the Critter Stack vocabulary, and rendered three more classes. Today the suite references Bobcat 1.0.0 and WolverineFx.Bobcat 6.55.0, and bobcat preview lists 30 specifications across six features:

Feature: Building an aggregate projection
Feature: Catching up projections and subscriptions
Feature: Rebuilding an inline projection
Feature: Resuming a failed rebuild
Feature: Skipping events that fail to apply
Feature: The async daemon keeps read models current

That’s a small fraction of the 393 tests in the project, but you have to start somewhere!

Mechanics, part one: decorate the helpers you already call

Almost every daemon test calls the same handful of helper methods on DaemonContext: start the daemon, publish the events, wait for a shard to reach a sequence number, rebuild a projection, compare every expected aggregate against what the projection actually wrote. Those helpers are the vocabulary of the suite. So that’s where the Bobcat attributes went:

[When("the projection daemon is running")]
internal async Task<IProjectionDaemon> StartDaemon()
{
var daemon = theStore.Tenancy.Default.Database.As<MartenDatabase>()
.StartProjectionDaemon(theStore);
await daemon.StartAllAsync();
_daemon = daemon;
return daemon;
}
[Given("the events are published")]
internal async Task PublishSingleThreaded()
{
await PublishSingleThreaded<Trip>();
}
[Given("the events are published on {threads} threads")]
internal Task PublishMultiThreaded(int threads)
{
// ...
}
[Then("the {shardName} shard has reached sequence {sequence}")]
internal async Task<ShardState> WaitForShard(IProjectionDaemon daemon, string shardName, long sequence,
Type readModel, TimeSpan timeout = default)
{
// ...
}
[When("the {projectionName} projection is rebuilt")]
internal Task RebuildProjection(IProjectionDaemon daemon, string projectionName)
{
return daemon.RebuildProjectionAsync(projectionName, CancellationToken.None);
}
[Then("every expected aggregate matches the actual")]
internal async Task CheckAllExpectedAggregatesAgainstActuals()
{
// ...
}

[Given], [When], [Then], [Check] and the keywordless [Step] are all the same StepAttribute with a different keyword. The {placeholder} names in the sentence bind to the method’s parameters by name, and they’re filled in from the argument at each call site. A Type argument renders as the type’s short name, so WaitForCatchUp(typeof(Trip)) reads as “the Trip read model has caught up”. The docs on step helpers cover the binding rules, and the placeholder rules are worth reading because a Guid or a record renders differently than you might expect.

Then the test class gets one attribute:

[BobcatFeature("Building an aggregate projection")]
public class build_aggregate_projection: DaemonContext
{
// 17 existing [Fact] tests, untouched
}

And a test like this one, which has not changed, now renders as a five step specification:

[Fact]
public async Task rebuild_the_projection()
{
NumberOfStreams = 10;
Logger.LogDebug("The expected number of events is {NumberOfEvents}", NumberOfEvents);
StoreOptions(x => x.Projections.Add(new TripProjectionWithCustomName(), ProjectionLifecycle.Async));
var agent = await StartDaemon();
await PublishSingleThreaded();
await WaitForShard(agent, "TripCustomName:All", NumberOfEvents, typeof(Trip), 30.Seconds());
Logger.LogDebug("About to rebuild TripCustomName:All");
await RebuildProjection(agent, "TripCustomName");
Logger.LogDebug("Done rebuilding TripCustomName:All");
await CheckAllExpectedAggregatesAgainstActuals();
}

And the results look like this in the console (we use Spectre.Console under the covers, so there’s color coded results and just take my word for it that it’s pretty!):

  rebuild the projection OK
  ────────────────────────────────
    ✓ When  the projection daemon is running (120ms)
    ✓ Given the events are published (67ms)
    ✓ Then  the TripCustomName:All shard has reached sequence 231 (248ms)
    ✓ When  the TripCustomName projection is rebuilt (25ms)
    ✓ Then  every expected aggregate matches the actual (21ms)

Yes, the When comes before the Given in that one, because that’s the order the test actually does things in. Bobcat renders what ran. It doesn’t editorialize. To be honest, I’ve never liked the Given/When/Then nomenclature anyway. To each their own.

The trick that makes this work with zero changes to the test bodies is that Bobcat.Generators is a source generator that emits C# interceptors for every call to a decorated helper. There’s no reflection or stack walking at runtime. Each call site is known at build time, which is also what lets bobcat preview show the specification before anything has run.

An interceptor has to be an extension method, and an extension method can’t see a protected member. So every decorated helper on DaemonContext had to be widened from protected to internal. That’s a C# language rule and not a Bobcat design choice, and Bobcat reports a protected helper as a BOBCAT039 diagnostic so you find out at build time rather than wondering why a step is missing. In Marten’s case that was eight helpers, and InternalsVisibleTo already existed for a sibling project, so nothing new was exposed. The Bobcat docs call this out because it’s the one thing you’ll actually have to touch.

Mechanics, part two: marker comments

Decorated helpers give you the work a test does. They don’t give you the narrative, and a lot of daemon tests have a middle section that’s just plain Marten code with no helper to hang a step on. For those, Bobcat reads marker comments: any // comment that opens with Given, When, Then, And or But as a whole word is a declared step. Every other comment is left alone, which is the property that makes this usable on a real suite that’s already full of explanatory comments like // Gotta do this to have it mixed up.

The clearest example in DaemonTests is when_skipping_events_in_daemon, which specifies what happens when a projection throws on some events and the daemon is configured to skip them:

[BobcatFeature("Skipping events that fail to apply")]
public class when_skipping_events_in_daemon : DaemonContext
{
// ...
[Given("the names are published to a running daemon, the bad ones failing to apply")]
internal async Task<IProjectionDaemon> PublishTheEvents()
{
var daemon = await StartDaemon();
var events = theNames.Select(name => new NameEvent {Name = name})
.OfType<object>()
.ToArray();
theSession.Events.StartStream(Guid.NewGuid(), events);
await theSession.SaveChangesAsync();
await daemon.Tracker.WaitForHighWaterMark(theNames.Length);
await WaitForShard(daemon, "CollateNames:All", theNames.Length, typeof(NamesByLetter));
await WaitForShard(daemon, "NamedDocuments:All", theNames.Length, typeof(NamedDocument), 5.Minutes());
return daemon;
}
[Fact]
public async Task see_the_dead_letter_events()
{
var daemon = await PublishTheEvents();
// When the daemon stops, draining the dead letter events it queued up
await daemon.StopAllAsync();
// Then each skipped event is a dead letter for the projection that skipped it
var skipped = await theSession.Query<DeadLetterEvent>().ToListAsync(TestContext.Current.CancellationToken);
var namedDocumentsSkipped = skipped.Where(x => x.ProjectionName == "NamedDocuments" && x.ShardName == "All")
.Select(x => x.EventSequence).OrderBy(x => x).ToList();
namedDocumentsSkipped.ShouldBe([4L, 5, 6, 7, 11, 14]);
var collateNamesSkipped = skipped.Where(x => x.ProjectionName == "CollateNames" && x.ShardName == "All")
.Select(x => x.EventSequence).OrderBy(x => x).ToList();
collateNamesSkipped.ShouldBe([4L, 5, 6, 7]);
}
}

There are three different Bobcat mechanisms in that one test, and they compose:

  1. PublishTheEvents() is a [Given] helper that lives on the test class itself rather than on DaemonContext. It calls StartDaemon() and WaitForShard(), which are decorated too, so those render nested underneath the Given with their own timings.
  2. The two // comments are marker comments. They declare what the test is doing in the stretch of code that calls no helper.
  3. The two ShouldBe() calls are projected assertions, which is the next section.

Here’s what that renders as from bobcat run:

  see the dead letter events OK
  ────────────────────────────────────
    ✓ Given the names are published to a running daemon, the bad ones failing to apply (306ms)
      ✓ When  the projection daemon is running (15ms)
      ✓ Then  the CollateNames:All shard has reached sequence 15 (44ms)
      ✓ And   the NamedDocuments:All shard has reached sequence 15
    · When  the daemon stops, draining the dead letter events it queued up — declared
    ✓ Then  each skipped event is a dead letter for the projection that skipped it (4ms)
      ✓ And   namedDocumentsSkipped should be [4L, 5, 6, 7, 11, 14] (4ms)
      ✓ And   collateNamesSkipped should be [4L, 5, 6, 7]

Notice the When the daemon stops line is marked declared with no tick and no timing. Nothing decorated ran inside that comment’s region, so Bobcat has nothing to report about it other than that the test claims to do it. The documentation is pretty strict about keeping “declared” and “recorded” separate, and I think that’s right. A step that borrowed a verdict from its neighbors would be exactly the kind of inference that makes test reports lie to you.

Marker comments are also how we narrated the longest, most involved daemon test in the suite, rebuild_mode_start_resumes_after_a_failure, which proves that a failed rebuild can be restarted from its committed progress without folding any event twice. That one is pure Marten and Postgres code with no shared helpers, and the comments carry the whole specification:

// Given a hundred streams of one event each
// When a Rebuild-mode run fails on event 60
// Then the committed progress and the failure are recorded by extended progression tracking
// And every event at or below the committed mark is in a document already
// When the shard is stopped, the fault cleared, and the Rebuild-mode run started again
// Then every event is folded into its document exactly once
// And the failure is cleared
// And no event below the first run's committed mark was folded again by the second run
// And the restarted run keeps following the store past where the rebuild started

Some of those comments, like the “Given a hundred streams” line, aren’t “meaningful” because they map to code. They’re meaningful because they’re the sentence a maintainer needs in order to understand what the next ten lines are for. Bobcat just happens to also render them.

One gotcha I tripped over while grabbing screenshots for this post: a marker comment wraps onto the // lines directly below it, up to a blank line or a line of code (dogfooding for the win I guess). That’s handy for long sentences, but it also means an explanatory comment sitting right under a marker becomes part of the step’s sentence. A couple of the steps in that rebuild test render with a trailing explanation glued on for that reason. Leave a blank line between the marker and the explanation and it stays a comment.

Mechanics, part three: your Shouldly assertions are steps too

We’ll add support for other assertion libraries later if there’s any demand, but I prefer Shouldly and that’s what the Critter Stack code already uses, so here we are.

DaemonTests has one line in its csproj that does a surprising amount of work:

<!-- A statement-level Shouldly assertion inside a [BobcatFeature] class renders as its own
Then step, and a run of consecutive assertions is all evaluated before the test fails,
so a red daemon spec shows every disagreement rather than the first. Classes without
[BobcatFeature] are untouched. -->
<BobcatProjectAssertions>true</BobcatProjectAssertions>

With that on, any statement-level Shouldly call inside a [BobcatFeature] class becomes a Then step with a sentence built from the C# expression as written, a verdict, a duration, and an expected/actual cell where the comparison allows it. jNames.Names.OrderBy(x => x).ShouldBe([...]) renders as “jNames.Names.OrderBy(x => x) should be [“Jack”, “Jane”, “Jeremy”, “Jill”]” with no attribute and no helper. Nothing moves.

The part that matters more than the rendering is that a run of consecutive assertions is all evaluated before the test fails. A plain Shouldly test throws on the first ShouldBe and never reaches the rest. With projected assertions, every assertion in the run executes, and the test fails once at the end with all of the wrongs in the failure. For daemon tests that check three or four things about shard state after a long setup, that’s the difference between one slow red/green cycle and four of them. The Shouldly page in the Bobcat docs is the whole story, including the closed set of comparisons that get a cell and the ones (ShouldBeTrue, ShouldBeOfType) that render as a step with no cell because any cell would be making something up.

Two things to know from using this on a real suite:

  • An assertion inside a loop is a step per iteration, because each one really ran. The “the shards should still be running” spec renders shard.Status should be AgentStatus.Running once per shard. If you don’t want that, assert once over the whole collection outside the loop, and the docs say the same. The rebuild-resume test does exactly that, and says why in a comment:
// One assertion over the whole set rather than one per event: with BobcatProjectAssertions on, a
// per-event ShouldBe would render as sixty steps
var foldedAgain = committedSequences
.Select(e => (Index: ((RestartableIncrement)e.Data).Index, e.Sequence))
.Where(x => RestartableTallyProjection.Applications[x.Index] != 1)
.Select(x => $"event {x.Index} at sequence {x.Sequence}")
.ToList();
foldedAgain.ShouldBeEmpty();
  • Only a statement is projected. x.ShouldNotBeNull().Name.ShouldBe("a") consumes the first assertion’s result, so Bobcat leaves a fluent chain alone rather than risk reporting a NullReferenceException in place of the real failure.

There’s also a comment in when_skipping_events_in_daemon explaining why each subject is pulled into a local first: on Bobcat 1.0.0-alpha.1 a projected assertion whose subject spanned lines broke the build because the generator didn’t escape the newlines in the subject string. That’s the kind of thing that dogfooding on a real suite is for, and it’s why the comments in these files tend to say why and not just what.

All the Bobcat-specific bits in one place

For the whole DaemonTests project, this is the entire Bobcat surface area:

Bobcat, Bobcat.Generators, Bobcat.XunitThe core package, the interceptor generator, and the xUnit v3 runner adapter that records every [BobcatFeature] class
WolverineFx.BobcatCritterStackSpec, CritterStackScenario and the EventStores helpers, which moved out of Bobcat core in 1.0.0-rc.2. Despite the name, it works against a Marten-only application
[BobcatFeature("...")] on a classNames the feature and opts the class in. The {Feature}/{Scenario} identity is the feature title plus the method name with underscores read as spaces (spec identities)
[Given], [When], [Then] on helper methodsThe step helpers, with {placeholder} bound to parameters by name. [BobcatStep(..., Keyword = "Given")] is the older spelling and still works
// Given, // When, // Then, // And commentsDeclared steps, read at compile time
<BobcatProjectAssertions>true</BobcatProjectAssertions>Statement-level Shouldly calls become Then steps

That’s it. No base class was imposed, and catch_up_timeout_diagnostics deliberately isn’t a [BobcatFeature] because those tests expect the error a recorded specification would report, which is a nice illustration that opting in is per class.

The one dependency cost worth being honest about: Bobcat.Xunit 1.0 is built on xunit.v3 4.x over Microsoft.Testing.Platform 2, and the two xunit lines can’t share a test project. Marten’s other 23 test projects are still on xunit.v3 3.x, so DaemonTests takes 4.x through a VersionOverride and the rest of the repo moves on its own schedule. The compatibility page spells out what pairs with what.

Waiting on the daemon

The most useful change in the second PR wasn’t a rendering change at all. Daemon tests spend most of their lives waiting for a projection shard to catch up, and when a wait times out, Marten’s own exception used to say roughly “the shards haven’t started within the timeout.” That tells you nothing about which shard, where it stood, or whether the daemon even ran.

DaemonContext now has two waits that route through Bobcat’s catch-up diagnosis:

/// <summary>
/// Wait for every async projection in <c>theStore</c> to catch up before reading
/// <paramref name="readModel"/>. On a timeout this throws Bobcat's
/// <see cref="ProjectionCatchUpTimeoutException"/>, which names each shard that was behind and
/// where it stood (<c>'Trip:All' at 412 of 1000</c>), rather than the bare TimeoutException
/// Marten's own wait throws. A stalled daemon test then says which shard stalled.
/// </summary>
[Given("the {readModel} read model has caught up")]
internal async Task WaitForCatchUp(Type readModel, TimeSpan timeout = default)
{
if (timeout == default) timeout = 30.Seconds();
try
{
await EventStores.WaitForNonStaleProjectionsAsync(theStore, timeout,
token: TestContext.Current.CancellationToken);
}
catch (TimeoutException e) when (e is not ProjectionCatchUpTimeoutException)
{
throw await catchUpTimedOut($"the {readModel.Name} read model has caught up", readModel, timeout, e);
}
}

We have pre-canned helpers for this in our Wolverine.Bobcat package that’s meant to be our specifications library for event sourced applications with the Critter Stack.

And when one of those waits times out, this is the message you get, from a test in catch_up_timeout_diagnostics that forces the timeout on purpose by never starting the daemon:

Waiting for 'the TripCustomName:All shard has reached sequence 43' timed out after 2s, before reading the Trip.
Behind: 'TripCustomName:All' at 0 of 43. This is the environment (a daemon behind, stopped or not running), not
evidence about the Trip itself. The store said: TimeoutException: Shard TripCustomName:All did not reach
sequence number 43 in the time allowed

“This is the environment, not evidence about the Trip itself” is the sentence I care about. When an AI agent, or a tired human, reads that failure, the next move is to look at the daemon and not at the projection code.

And it paid for itself before the PR was even reviewed. From the agent’s write up:

The first two full gate runs failed the new specs with Behind: 'ShipmentSummary:All' at 0 of 4, which meant the hosted daemon had never run its shard. The cause was that the fixture cleaned event data after the Solo daemon started. That restarted the sequence underneath a shard that had already loaded its old progress, so the shard sat “ahead” of every new event. Isolated runs won that race and full runs lost it.

That’s a real test fixture bug of exactly the kind that produces a “flaky” test that nobody can reproduce on their own machine. The fix was a one liner (clean the store before StartAsync()), and the comment in ShipmentsHost now explains the race for the next person. The diagnosis is what made it findable.

New specifications with the Critter Stack vocabulary

Everything above is about retrofitting existing tests. For new daemon specifications, we used CritterStackSpec, the store-only base class from WolverineFx.Bobcat, against an application that hosts its own daemon with AddAsyncDaemon(DaemonMode.Solo). This is from async_daemon_specifications:

[BobcatFeature("The async daemon keeps read models current")]
public class async_daemon_specifications: CritterStackSpec, IClassFixture<ShipmentsHost>
{
public async_daemon_specifications(ShipmentsHost app): base(app.Host)
{
ProjectionTimeout = 30.Seconds();
}
[Fact]
public async Task an_async_projection_catches_up_with_an_appended_event()
{
var id = Guid.NewGuid();
await GivenEvents<ShipmentSummary>(id, new ShipmentPacked(id, 3));
await WhenActing("the shipment is shipped", services => append(services, id, new ShipmentShipped(id)));
ThenEvents(new ShipmentShipped(id));
await ThenReadModel<ShipmentSummary>(id, Specify<ShipmentSummary>()
.With(x => x.Items, 3)
.With(x => x.Shipped, true));
}
[Fact]
public async Task an_async_projection_deletes_the_read_model_its_stream_cancels()
{
var id = Guid.NewGuid();
await GivenEvents<ShipmentSummary>(id, new ShipmentPacked(id, 2));
await WhenActing("the shipment is cancelled", services => append(services, id, new ShipmentCancelled(id)));
ThenEvents(new ShipmentCancelled(id));
await ThenNoReadModel<ShipmentSummary>(id);
}
}

Nothing in there waits by hand. ThenReadModel opens with Bobcat’s own catch-up step, which is the same diagnosis as above if it times out. And this is where the “data centric” side of Bobcat shows up, because the events and the read model render as grids:

The third spec in that file, a rebuild through the running daemon, had to go on a host of its own, and the class comment says why: JasperFx’s rebuild stops the shard’s continuous agent, rebuilds with dedicated agents, and doesn’t start the continuous agent again, so any spec that ran after the rebuild on a shared host timed out at “‘ShipmentSummary:All’ at 2 of 4”. Whether a hosted daemon should resume the shard after a rebuild is a question for Marten rather than for the specification, and I’d rather have that written down in the test than silently worked around.

What it looks like: preview, run, and the UI

Because every step is known at build time, you can see what the suite specifies without running it. bobcat preview prints the whole thing to the console with the templates unfilled and the helper each step binds to:

  rebuild the projection
  ────────────────────────────────
      ○ When  the projection daemon is running
        ↳ DaemonContext.StartDaemon
      ○ Given the events are published
        ↳ DaemonContext.PublishSingleThreaded
      ○ Then  the {shardName} shard has reached sequence {sequence}
        ↳ DaemonContext.WaitForShard
      ○ When  the {projectionName} projection is rebuilt
        ↳ DaemonContext.RebuildProjection
      ○ Then  every expected aggregate matches the actual
        ↳ DaemonContext.CheckAllExpectedAggregatesAgainstActuals

The same preview is available in the Bobcat UI once you’ve got a resident runner attached for the suite. This is bobcat ui plus bobcat resident src/DaemonTests/bin/Debug/net10.0/DaemonTests, and every feature and specification gets its own run button:

I like this view a lot more than I expected to. Seventeen tests about building an aggregate projection, readable in one screen by somebody who has never opened the C#.

Running a couple of features with bobcat run src/DaemonTests --feature "The async daemon keeps read models current" "Skipping events that fail to apply" gives you the run page, with the passing specs folded to one line each:

And when something’s wrong, the failed scenario is opened on its specification with the disagreement right there. I broke one expectation on purpose for this screenshot, so the aggregate is expected to contain a “Joe” that was never published:

The whole run also lands in a JSON results file with a published schema, and bobcat run --html writes a self contained results page you can attach to a CI artifact. Those are what an agent reads when it’s working on a red build, and the bobcat tool page covers the rest of the commands.

While grabbing these screenshots I noticed the UI’s scenario page renders the marker-comment steps ahead of the recorded helper steps and flattens the nesting, where the console gets the order right. That’s bobcat#810, and it’s a reminder that 1.0 means the model is stable, not that I’m done finding things.

The Supervisor, retry budgets, and the flaky test that earned its retry

Now the other half of the story. Readable specifications don’t help if the suite doesn’t run reliably, and DaemonTests was historically the least reliable project in Marten. It had its own workflow (daemon.yml) that shelled straight out to dotnet test, which meant the most timing-sensitive suite in the repo was the one with no retry story at all.

Today every Marten test target runs through the Bobcat Supervisor from the Nuke build, and the daemon suite is one job in the matrix of tests.yml. The whole configuration lives in build/SupervisedTests.cs, and the interesting part is small:

csharp

const int MaxRetriesPerRun = 25;
const int MaxAttemptsPerTest = 3;
static readonly TimeSpan StallThreshold = TimeSpan.FromMinutes(5);
void configureSupervisor(Supervisor supervisor, TestLedger ledger)
{
supervisor.RetryBudget = DisableTestRetry
? RetryBudget.None
: new RetryBudget { MaxAttemptsPerTest = MaxAttemptsPerTest, MaxRetriesPerRun = MaxRetriesPerRun };
// The policy only ever retries in a fresh process, so idle lane workers are released
// before the retry passes. Without it, workers+1 test hosts sit resident at once — which is
// what OOM-killed 16GB GitHub runners on Wolverine, both times during a retry.
supervisor.ReleaseIdleLanes = true;
// Report-only observability. A test in flight past the threshold is named at once (a wedged
// daemon test used to be visible only as a job hitting its 20 minute cap), and a one-line
// heartbeat every 30s says how far a run got and what has been running longest.
supervisor.StallThreshold = StallThreshold;
supervisor.StallAction = StallAction.Report;
supervisor.HeartbeatInterval = TimeSpan.FromSeconds(30);
// Longest-processing-time-first lane balancing from the previous run's ledger, keyed by
// display name (bobcat#730). No ledger, or no match, degrades to balancing by test count,
// and the run report's first lines say how many durations matched.
supervisor.KnownTestDurations = ledger.KnownDurations();
}

A few things about that policy that I think are important, and that took us a while to get right:

Retries are opt-in, per test, and always in a fresh process. Bobcat’s default failure policy only retries a test that carries [Trait("Retry", "N")]. Everything else that fails, fails. Up until we made that change, every failure in Marten’s CI was retried, which made “flaky” and “broken” impossible to tell apart without going and opening the log. A retry runs in a fresh process because a failed daemon test usually leaves a half-built store, schema objects, or a running daemon behind it. The fresh process is the reset.

The retry budget is the operator’s ceiling, not the test’s. MaxAttemptsPerTest = 3 is the most any test gets, including the first attempt. A test’s own [Trait("Retry", "N")] can lower that but never raise it. MaxRetriesPerRun = 25 caps the whole run, and the build’s own comment says how to read it: a run sitting at the cap isn’t “25 flaky tests,” it’s 25 flaky tests plus an unknown tail.

Stall detection is report-only. A test in flight past five minutes is named in the log, the job summary, and the run report, but nothing gets killed. Before this, a wedged daemon test was only visible as a job hitting its 20 minute cap with no indication of which test wedged. Nothing in Marten’s suites legitimately runs that long as one test.

A pass on a retry is never laundered into a green tick. This is the RetryLedger, and its header comment is the best summary of the problem I’ve read:

Nothing in the GitHub UI distinguishes a job that passed cleanly from a job that passed after retrying nine tests. Both render as a green tick, and a Serilog warning buried in the log of a job that PASSED is a warning nobody has a reason to go read. Without this, “we added retries” quietly becomes “we stopped being able to tell whether the suite works”.

So every supervised run writes its retry count to the GitHub job summary, emits a ::warning annotation when the count is nonzero so it shows up on a green run, and uploads a JSON ledger that a flakiness roll-up job aggregates across every job in the workflow. Deliberately not in there is failing the build past a retry threshold. The value is in the visibility, not in a cliff that turns master red on a bad day.

What the ledger says about the daemon suite right now

Here’s the daemon job’s ledger from the most recent green master run as I write this (November 2nd):

Tests393
Clean passes392
Passed on retry1
Retries performed1
Failed0
Wall clock5m 48s, one lane

The one test that passed on a retry was Bug_catching_up_apply_errors.should_catch_apply_event_exceptions, and it’s the best example I have of how the opt-in policy is supposed to work. That test carries [Trait("Retry", "3")], and the comment above it explains how it earned that:

// Retry is opt-in under the supervisor (JasperFx/bobcat#565), and this test earned it with
// evidence: in 33 green master runs (2026-09 to 2026-10-15) its first attempt failed in 13 —
// "Task should throw System.AggregateException but did not" — and passed on a fresh-process
// retry every time. It is still reported as FLAKY in the retry ledger and the job summary;
// the trait keeps a known intermittent from turning the job red, it does not hide it. The
// projection throws deterministically, so a CatchUpAsync that returns cleanly is worth an
// investigation of its own (state left by an earlier test in the same process is the lead).
[Fact]
[Trait("Retry", "3")]

That’s the whole philosophy in one comment. The ledger gave us the evidence (13 first-attempt failures in 33 runs, same message every time, always passes on a fresh process). The trait keeps a known intermittent from turning the job red. The ledger still reports it as flaky on every run so it can’t quietly disappear. And the comment leaves the next investigator a lead. A second test in the same file picked up the same trait a couple of weeks later after one miss turned a whole job red for a pull request that couldn’t possibly have touched it.

One worker, and a local gate

The daemon job runs supervised at one worker. The issue that drove this work measured two lanes green locally, 374 of 374 twice, at 5m10s against 7m09s with retries off, but raising the worker count in CI is a separate decision we want to make on CI evidence rather than on a laptop. Marten’s heavier suites do split across lanes with a database per lane, and the parallel-ready suites page is the checklist we run a suite through before we let it.

The last piece is that the same supervisor configuration runs locally. ./build.sh LocalGate runs the logical suites of tests.yml on your own machine through Bobcat’s local gate, with the same opt-in retry policy, the same stall report, and the same lane balancing from the ledger, before you push. The implementation is in build/LocalGate.cs and the header comment there is, again, the documentation.

What I’d tell you to do with your own suite

If you’ve got a big integration suite with a lot of history, this is the order I’d go in, because it’s the order that worked on DaemonTests:

  1. Put the suite under the Supervisor first, with retries off (--disable-test-retry in Marten’s build), and read the ledger for a couple of weeks. You’ll find out which tests are actually flaky and which are just broken.
  2. Give the proven-flaky tests a [Trait("Retry", "N")] with a comment that says what the evidence was. Keep the ledger reporting them.
  3. Pick the test classes that explain how the system is supposed to behave, decorate the three or four shared helpers they all call, and mark the classes with [BobcatFeature]. Don’t touch the test bodies.
  4. Add marker comments where a test’s narrative isn’t carried by a helper, and turn on BobcatProjectAssertions.
  5. Replace your bare TimeoutException waits with something that says what was behind. If you’re on Marten, Polecat or Fisher, WolverineFx.Bobcat has that built in.
  6. Write the new specifications with CritterStackSpec or WolverineSpec, and let the existing tests stay existing tests.

The code for all of this is public:

Bobcat is MIT licensed, 1.0 is out, and DaemonTests is the suite I’d point you at if you want to see what it looks like on code that wasn’t written for it.

Using Event Sourcing? Prefer the Decider over AggregateRoot

Functional Composition.

I noticed there’s a new book coming out next month on CQRS & Event Sourcing from a .NET perspective, so I was of course curious to look at their sample code. I was admittedly looking to see if the samples used Marten and the rest of the Critter Stack, but also to just see what approach the author was using. The answers were “something else” for the tooling but what triggered this post is that the samples were all built around the old AggregateRoot pattern that was popular in the early days of Event Sourcing. So, just to summarize the rest of this post:

When you are doing Event Sourcing, I very strongly recommend you express your domain logic using the Decider Pattern and avoid the old AggregateRoot Pattern.

If you see any halfway recent literature on doing Event Sourcing and CQRS with the Critter Stack, you’ll see examples of command handlers that use the “Decider” pattern taken from Functional Programming folks like this one:

public record CategoriseIncident(
IncidentCategory Category,
Guid CategorisedBy,
int Version
);
public static class CategoriseIncidentEndpoint
{
// This is Wolverine's form of "Railway Programming"
// Wolverine will execute this before the main endpoint,
// and stop all processing if the ProblemDetails is *not*
// "NoProblems"
public static ProblemDetails Validate(Incident incident)
{
return incident.Status == IncidentStatus.Closed
? new ProblemDetails { Detail = "Incident is already closed" }
// All good, keep going!
: WolverineContinue.NoProblems;
}
// This tells Wolverine that the first "return value" is NOT the response
// body
[EmptyResponse]
[WolverinePost("/api/incidents/{incidentId:guid}/category")]
public static IncidentCategorised? Post(
// the actual command
CategoriseIncident command,
// Wolverine is generating code to look up the Incident aggregate
// data for the event stream with this id
[Aggregate("incidentId")] Incident incident)
{
if (incident.Category == command.Category) return null;
// This is a simple case where we're just appending a single event to
// the stream.
return new IncidentCategorised(incident.Id, command.Category, command.CategorisedBy);
}
}

Focus on the Post() method up above. It’s a pure function, meaning that it only deals with inputs and returns outputs. No invocations of infrastructure whatsoever. Its only real responsibility is to “decide” what if any new events should be appended to mark a change of state given the initial state of the current Incident and the incoming CategoriseIncident command. There’s of course plenty of infrastructure around that simple function, but Wolverine is mediating between the Marten persistence and your little domain logic up above.

To level set, there’s an example of using AggregateRoot in a section below just in case you’re not already familiar with that pattern.

From here on out though, here some of the reasons why I heavily recommend using the Decider over AggregateRoot in today’s development work:

  • The decider pattern makes the relationship much more clear between incoming commands and the events emitted. In other words, I think the Decider usage will make your system easier to reason about by removing layers and indirection.
  • I think unit testing is easier with the pure Decider functions than the aggregates
  • From years of supporting users and now JasperFx clients using Marten, people using the AggregateRoot pattern clearly run into more problems troubleshooting and debugging their application to the point where I tell people that I will not support them online if they run into trouble with their AggregateRoot usage (unless they’re a JasperFx customer of course, but all of our customers generally use the Decider in newer work)
  • Marten with or without Wolverine has a lot of performance optimizations built in with the assumption that the projected aggregates are not mutated by application code. You can still get *some* of that goodness by using immutable types for aggregates, but that would blow up your aggregate root usage anyway
  • I’ll agree with a common opinion that it’s helpful to separate the “evolve” functionality of how the projected aggregate state is calculated away from the “decide” logic. Especially when projections need to change later.

As a bonus, using the Decider pattern in Wolverine handlers 100% makes it a lot easier for our CritterWatch tool to derive an accurate Event Model visualization for your system.

You can find plenty of other articles out there from other people making the same case I am here, but usually with some kind of “Functional Programming is better than Object Oriented Programming” hook. My thinking on that has changed quite a bit over the years and you can clearly see much more FP thinking evident in both Marten and especially Wolverine. As much as I despise getting lectured online by FP enthusiasts and my own history as an OOP guy, I’ll say that “It doesn’t matter if a cat is black or white, as long as it catches mice” and happily use the FP Decider approach over the OOP AggregateRoot in this particular case.

As folks frequently point out, Wolverine especially has veered into FP-centric usage in our approach to command handlers or HTTP endpoints. I’ll simply say that that cat catches mice.

I also see this Decider > AggregateRoot valuation to be mostly orthogonal to the question of using “classical” stream-based event sourcing modeling versus the newer fangled Dynamic Consistency Boundary (DCB) approach because DCB very naturally fits well with Decider functions.

What’s this AggregateRoot thing anyway?

Let’s level set a little bit with a real example taken from some older documentation in Marten (which very much recommends against the approach), the “AggregateRoot” pattern calls for you to generally to model all writes to an event stream through a single aggregate type usually derived from something like this base type from our old docs example:

// Infrastructure to capture modifications to state in events
public abstract class AggregateBase
{
// For indexing our event streams
public string Id { get; protected set; }
// For protecting the state, i.e. conflict prevention
// The setter is only public for setting up test conditions
public long Version { get; set; }
// JsonIgnore - for making sure that it won't be stored in inline projection
[JsonIgnore] private readonly List<object> _uncommittedEvents = new List<object>();
// Get the deltas, i.e. events that make up the state, not yet persisted
public IEnumerable<object> GetUncommittedEvents()
{
return _uncommittedEvents;
}
// Mark the deltas as persisted.
public void ClearUncommittedEvents()
{
_uncommittedEvents.Clear();
}
protected void AddUncommittedEvent(object @event)
{
// add the event to the uncommitted list
_uncommittedEvents.Add(@event);
}
}

Then in real usage, it might be something like this:

public sealed class Invoice: AggregateBase
{
public Invoice(int invoiceNumber)
{
if (invoiceNumber <= 0)
{
throw new ArgumentException("Invoice number needs to be positive", nameof(invoiceNumber));
}
// Instantiation creates our initial event, capturing the invoice number
var @event = new InvoiceCreated(invoiceNumber);
// Call Apply to mutate state of aggregate based on event
Apply(@event);
// Add the event to uncommitted events to use it while persisting the events to Marten events store
AddUncommittedEvent(@event);
}
// Public parameterless ctor for Marten's event-store replay. Pre-9.0
// Marten could call a private parameterless ctor reflectively; the
// source-generated dispatch path in 9.0 emits direct calls instead,
// which require public visibility. See the 9.0 migration guide.
public Invoice()
{
}
// Enforce any contracts on input, then raise event capturing the data
public void AddLine(decimal price, decimal vat, string description)
{
if (string.IsNullOrEmpty(description))
{
throw new ArgumentException("Description cannot be empty", nameof(description));
}
var @event = new LineItemAdded(price, vat, description);
// Call Apply to mutate state of aggregate based on event
Apply(@event);
// Add the event to uncommitted events to use it while persisting the events to Marten events store
AddUncommittedEvent(@event);
}
public override string ToString()
{
var lineItems = string.Join(Environment.NewLine, lines.Select(x => $"{x.Item1}: {x.Item2} ({x.Item3}% VAT)"));
return $"{lineItems}{Environment.NewLine}Total: {Total}";
}
public decimal Total { get; private set; }
private readonly List<Tuple<string, decimal, decimal>> lines = new List<Tuple<string, decimal, decimal>>();
// Apply methods need to be `public` for the source-generated dispatcher
// to invoke them — pre-9.0 Marten reflected over private members; the
// SG-emitted dispatch path in 9.0 emits direct method calls. See the
// 9.0 migration guide.
public void Apply(InvoiceCreated @event)
{
Id = @event.InvoiceNumber.ToString(CultureInfo.InvariantCulture);
// Ensure to update version on every Apply method.
Version++;
}
public void Apply(LineItemAdded @event)
{
var price = @event.Price * (1 + @event.Vat / 100);
Total += price;
lines.Add(Tuple.Create(@event.Description, price, @event.Vat));
// Ensure to update version on every Apply method.
Version++;
}
}

And you’d maybe wrap the AggregateRoot access in a repository class that deals with some of the grubby technical details:

public sealed class AggregateRepository
{
private readonly IDocumentStore store;
public AggregateRepository(IDocumentStore store)
{
this.store = store;
}
public async Task StoreAsync(AggregateBase aggregate, CancellationToken ct = default)
{
await using var session = await store.LightweightSerializableSessionAsync(token: ct);
// Take non-persisted events, push them to the event stream, indexed by the aggregate ID
var events = aggregate.GetUncommittedEvents().ToArray();
session.Events.Append(aggregate.Id, aggregate.Version, events);
await session.SaveChangesAsync(ct);
// Once successfully persisted, clear events from list of uncommitted events
aggregate.ClearUncommittedEvents();
}
public async Task<T> LoadAsync<T>(
string id,
int? version = null,
CancellationToken ct = default
) where T : AggregateBase
{
await using var session = await store.LightweightSerializableSessionAsync(token: ct);
var aggregate = await session.Events.AggregateStreamAsync<T>(id, version ?? 0, token: ct);
return aggregate ?? throw new InvalidOperationException($"No aggregate by id {id}.");
}
}

Again, this is a very old code sample from before Wolverine and the “aggregate handler workflow” (our version of the Decider pattern).

Building Bobcat Specifications

Bobcat 1.0, the Critter Stack’s new tool for executable specifications (automated tests that are human readable for easier review) was released just yesterday. As I wrote about in the release post yesterday, Bobcat is the “spiritual” successor to a mostly forgotten OSS project of mine called Storyteller that was itself trying to be a better version of FitNesse which was trying to solve the same problems of making human readable but executable specifications that has mostly been popularized by Gherkin tools like Cucumber.

I’ve admittedly just lifted this from the documentation website, but here’s an introduction to authoring a specification with Bobcat:

What you will build: one business rule, specified three ways in plain C# against a plain .NET class, with no Critter Stack and nothing hosted. You start from an xUnit test you could have written yesterday, make it a specification without touching its body, give it a vocabulary so the next test reads the same way, and then write the data-heavy cases as tables. By the end you have seen what the run prints for a pass, a wrong value, a wrong row and a thrown exception.

Bobcat specifications are code. There is no second file and no binding layer between the specification and the test that executes it: the test is the specification. It keeps running on its own runner, in your IDE’s test explorer, and Bobcat renders it as readable steps with a verdict per step.

That is a deliberate compromise. A Gherkin or FitNesse tool theoretically lets a non-technical domain expert author a specification directly, in a file of their own; a projected test is C#, so authoring stays with the developers and the domain expert reviews the rendered specification instead.

There is a little bit of an exemption though for being able to express table centric tests through Markdown tables directly embedded inside of C# (we’ll try to add F# later, but for right now, we’re using C# specific features). The folks who did use Storyteller effectively usually mention the effectiveness of Storyteller’s support for table driven testing for data intensive tests, the “set verification” feature, and the ability to express decision tree tests in Storyteller and now Bobcat with declarative markdown tables.

I should point out that the source generator with Bobcat can (hopefully) validate the Markdown table text at compile time to help eliminate those pesky binding problems that were so nasty with Gherkin or FitNesse.

The project setup is Getting Started: the three packages, the runner adapter, and global.json. This page assumes that and starts at the first test. Every sample on it is compiled and run in Bobcat’s own repository, in src/Bobcat.Xunit.Samples, and every output block is what that run printed.

The rule

A bank account that may not be overdrawn:

public record Transaction(string Kind, decimal Amount);
public class Account
{
public decimal Balance { get; private set; }
public List<Transaction> Transactions { get; } = new();
public void Deposit(decimal amount)
{
Balance += amount;
Transactions.Add(new Transaction("Deposit", amount));
}
public bool TryWithdraw(decimal amount)
{
if (amount > Balance) return false;
Balance -= amount;
Transactions.Add(new Transaction("Withdrawal", amount));
return true;
}
}

Step 1: a test you already have, as a specification

Here is the test as most teams would write it, with Shouldly:

[Fact]
public void a_withdrawal_larger_than_the_balance_is_refused_as_most_teams_write_it()
{
var account = new Account();
account.Deposit(100);
var accepted = account.TryWithdraw(200);
accepted.ShouldBeFalse();
account.Balance.ShouldBe(100);
}

Make it a specification without changing a line of its logic. Mark the class [BobcatFeature("Withdrawals")], and say what each part of the body does in a comment that opens with Given, When or Then:

[Fact]
public void a_withdrawal_larger_than_the_balance_is_refused_narrated()
{
// Given an account with a balance of 100
var account = new Account();
account.Deposit(100);
// When 200 is withdrawn
var accepted = account.TryWithdraw(200);
// Then the withdrawal is refused and the balance is still 100
accepted.ShouldBeFalse();
account.Balance.ShouldBe(100);
}

On xUnit, [BobcatFeature] is all it takes: the Bobcat.Xunit package opens a scenario around every test in the class and closes it with the verdict xUnit reported. On TUnit, NUnit and MSTest, put [BobcatScenario] beside it. (The sample project references Bobcat by project rather than by package, so it states [BobcatScenario] itself.) Run dotnet test in a terminal and the run prints the specification it produced:

Feature: Withdrawals
═════════════════════
a withdrawal larger than the balance is refused narrated OK
────────────────────────────────────────────────────────────
· Given an account with a balance of 100 — declared
· When 200 is withdrawn — declared
✓ Then the withdrawal is refused and the balance is still 100 (3ms)
✓ Then accepted should be false (1ms)
✓ And account.Balance should be 100 (2ms)

The declared is honest. A marker comment declares a step; nothing observed the Given or the When, so they have no duration and no verdict of their own. The Then is different only because the sample project opts into projecting Shouldly assertions as steps, so the two calls under the comment rendered beneath it with their own verdicts. Without that opt-in the Then would read declared as well, and when a Shouldly assertion threw, the scenario would be red with the exception and nothing narrower: no expected and actual pair, and the second assertion never reached. That is the limit of this style, and also its whole appeal. Opting an existing suite in costs one attribute per class and some comments (Marker comments, Projecting the assertions you already wrote).

Keep the comments, add the helpers

The two styles compose. A comment gives a test its narrative, and a decorated helper called underneath it renders nested under that sentence, with its own timing and verdict (They are different steps, and they nest). Most suites end up with both.

Step 2: a vocabulary

The second withdrawal spec should read like the first, and the tenth like the second. Move the mechanics into step helpers on a class of their own: methods decorated with [Given], [When] or [Then], carrying a sentence whose {placeholders} are filled from the arguments of those names:

private readonly Account _account = new();
private bool _accepted;
[Given("an account with a balance of {balance}")]
internal void AnAccountWithABalanceOf(decimal balance) => _account.Deposit(balance);
[When("{amount} is withdrawn")]
internal void IsWithdrawn(decimal amount) => _accepted = _account.TryWithdraw(amount);
[Then("the withdrawal is {outcome}")]
internal void TheWithdrawalIs(string outcome)
=> SpecAssert.Check("outcome", _accepted ? "accepted" : "refused", outcome);
[Then("the balance is {balance}")]
internal void TheBalanceIs(decimal balance) => SpecAssert.Check("balance", _account.Balance, balance);

The specification is now four calls:

[Fact]
public void a_withdrawal_larger_than_the_balance_is_refused()
{
_steps.AnAccountWithABalanceOf(100);
_steps.IsWithdrawn(200);
_steps.TheWithdrawalIs("refused");
_steps.TheBalanceIs(100);
}

Every call to a decorated helper is a recorded step: Bobcat’s source generator intercepts the call at build time, so there is no reflection and no registration. Two details carry weight:

  • The helpers are internal, not private. The generated interceptor is an extension method, which cannot reach a private member. That one keyword is the whole adoption cost on an existing helper class (BOBCAT039).
  • SpecAssert.Check records instead of throwing. It writes an expected/actual cell onto the step in progress and returns, so the specification keeps going and reports every disagreement it has. The adapter fails the test once, at the end, with all of them (Checks that gather instead of throwing).

Now write a specification whose expectations are wrong. A withdrawal of 30 from 100 is accepted and leaves 70, and this spec says otherwise, twice:

[Fact]
public void a_withdrawal_within_the_balance_is_refused()
{
_steps.AnAccountWithABalanceOf(100);
_steps.IsWithdrawn(30);
_steps.TheWithdrawalIs("refused");
_steps.TheBalanceIs(100);
}
  a withdrawal within the balance is refused FAILED
  ────────────────────────────────────────────────────
    ✓ Given an account with a balance of 100
    ✓ When  30 is withdrawn
    ✗ Then  the withdrawal is expected 'refused', got 'accepted' (1ms)
    ✗ And   the balance is expected '100', got '70' (1ms)
  Failed with Rights: 2, Wrongs: 2, Errors: 0

Both wrongs are there. A cell named for the placeholder it belongs to renders in the sentence, where the value sits, and the test runner’s own failure message says the same thing:

failed Bobcat.Xunit.Samples.Specs.WithdrawalSpecs.a_withdrawal_within_the_balance_is_refused (38ms)
Xunit.MicrosoftTestingPlatform.XunitException: Bobcat.Engine.SpecAssertionException : 2 specification steps failed:
Then the withdrawal is refused => outcome: expected 'refused', got 'accepted'
And the balance is 100 => balance: expected '100', got '70'

A helper that throws is different. An exception is an error, not a wrong, and it stops the scenario: the step is marked !, the exception is printed once at the bottom, and every step after it renders as ○ … — not run with its placeholders unfilled, because its arguments were never evaluated (Report everything, then stop).

Step 3: the boundary as a decision table

The interesting cases of the overdraft rule are the ones at the edge: exactly the balance, a penny over. Four specs of four lines each would say less than one table. A helper that takes a StepTable receives the whole table from the call site, and TableRunner.Run calls a row method once per row:

[Then("withdrawals are decided as")]
internal Task WithdrawalsAreDecidedAs(StepTable table) => TableRunner.Run(this, nameof(decide), table);
private string decide(decimal balance, decimal amount)
{
var account = new Account();
account.Deposit(balance);
return account.TryWithdraw(amount) ? "accepted" : "refused";
}

The balance and amount columns bind to the parameters of those names. The returned value is compared against the one column no parameter names, outcome, so the table is a decision table with a verdict per row:

[Fact]
public Task the_overdraft_boundary() => _steps.WithdrawalsAreDecidedAs("""
| balance | amount | outcome |
| 100 | 99.99 | accepted |
| 100 | 100 | accepted |
| 100 | 100.01 | refused |
""");

The table is pipe-delimited text, read by an implicit conversion to StepTable. A markdown table pastes in unchanged, alignment row and all, so the table in the pull request and the table in the test are the same text. Give one row the wrong answer and the grid says which:

[Fact]
public Task the_overdraft_boundary_with_a_wrong_row() => _steps.WithdrawalsAreDecidedAs("""
| balance | amount | outcome |
| 100 | 99.99 | accepted |
| 100 | 100 | refused |
| 100 | 100.01 | refused |
""");
  the overdraft boundary with a wrong row FAILED
  ─────────────────────────────────────────────────
    ✗ Then  withdrawals are decided as (4ms)
╭───┬─────────┬────────┬────────────────────────────────────┬─────────╮
│ # │ balance │ amount │ outcome                            │ Verdict │
├───┼─────────┼────────┼────────────────────────────────────┼─────────┤
│ 1 │ 100     │ 99.99  │ accepted                           │   OK    │
│ 2 │ 100     │ 100    │ expected 'refused', got 'accepted' │  FAIL   │
│ 3 │ 100     │ 100.01 │ refused                            │   OK    │
╰───┴─────────┴────────┴────────────────────────────────────┴─────────╯
  Failed with Rights: 2, Wrongs: 1, Errors: 0

A row that throws is that row’s failure and the remaining rows still run. out parameters are output columns too, so one row method can judge several results (Decision tables).

Step 4: the history as a set verification

The account’s transaction list is a collection, and a collection claim is a set verification: the expected rows are a table, the system’s rows are matched against them, and every row gets its own verdict.

[Then("the transactions are")]
internal void TheTransactionsAre(StepTable expected)
=> SetVerificationComparer.Verify(_account.Transactions, expected, ordered: true);
[Fact]
public void the_history_records_what_happened()
{
_steps.AnAccountWithABalanceOf(100);
_steps.IsWithdrawn(30);
_steps.IsWithdrawn(200);
_steps.TheTransactionsAre("""
| Kind | Amount |
| Deposit | 100 |
| Withdrawal | 30 |
""");
}

The refused withdrawal must leave no trace, and the table says so by not listing it. Now a specification that forgets a withdrawal that did happen:

[Fact]
public void the_history_the_specification_did_not_expect()
{
_steps.AnAccountWithABalanceOf(100);
_steps.IsWithdrawn(30);
_steps.TheTransactionsAre("""
| Kind | Amount |
| Deposit | 100 |
""");
}
  the history the specification did not expect FAILED
  ──────────────────────────────────────────────────────
    ✓ Given an account with a balance of 100
    ✓ When  30 is withdrawn
    ✗ Then  the transactions are (4ms)
╭───┬────────────┬────────┬─────────╮
│ # │ Kind       │ Amount │ Verdict │
├───┼────────────┼────────┼─────────┤
│ 1 │ Deposit    │ 100    │   OK    │
│ 2 │ Withdrawal │ 30     │  EXTRA  │
╰───┴────────────┴────────┴─────────╯
  Failed with Rights: 4, Wrongs: 1, Errors: 0

EXTRA is a row the system produced that the specification does not describe. MISSING is a row the specification describes that the system never produced, shown with the values it expected. FAIL is a matched row with a wrong cell, and ORDER is a row in the wrong place when order was part of the claim. Name keyColumns: to say which columns identify a row, and leave ordered: out when order is not the claim (Set verification).

Step 5: one specification per example

A decision table judges many rows inside one specification, with one verdict. When each example is something a person may want to run, see fail or track on its own, make each row its own specification instead. [BobcatTheory] is xUnit’s [Theory] plus the scenario bracket, and [BobcatExamples] writes its rows as the same table:

[BobcatTheory]
[BobcatExamples("""
| balance | amount | outcome |
| 100 | 100 | accepted |
| 100 | 100.01 | refused |
""")]
public void a_withdrawal_is_decided(decimal balance, decimal amount, string outcome)
{
_steps.AnAccountWithABalanceOf(balance);
_steps.IsWithdrawn(amount);
_steps.TheWithdrawalIs(outcome);
}

Each row runs as its own test with its own identity and its own rendered steps:

  a withdrawal is decided (balance: 100, amount: 100, outcome: accepted) OK
  ────────────────────────────────────────────────────────────
    ✓ Given an account with a balance of 100
    ✓ When  100 is withdrawn
    ✓ Then  the withdrawal is accepted (1ms)

Columns bind to parameters by name, and because the table is a constant, the build checks it: a column naming no parameter or a cell the parameter’s type cannot read is a compile error rather than a surprise at run time (A theory’s examples as a table).

Reading and running

  • The run prints its specifications when a terminal is attached and nothing is listening on the monitor wire. BOBCAT_SPEC_CONSOLE=1 prints anyway, into CI’s captured output; BOBCAT_SPEC_CONSOLE_FILE=spec.txt writes the report to a file instead (Reading the specification the run produced).
  • bobcat run renders the specification tree in the terminal, and bobcat preview shows every specification without executing anything, because the steps are known at build time (The bobcat tool).
  • Your IDE’s test explorer shows each specification as the ordinary test it is, so you run and debug it there (Integrating Bobcat with Your IDE).

Three things to know before you start

  • xUnit v3, TUnit, NUnit or MSTest (3.10+). xUnit v2 has no adapter and is not waiting for one. Its BeforeAfterTestAttribute cannot hand over a test result, which is the seam the adapter needs (xUnit v2 is not supported).
  • Use the shipped runner adapter, Bobcat.Xunit, Bobcat.TUnit, Bobcat.NUnit or Bobcat.MSTest. Marten’s hand-rolled one never set the verdict, so every scenario it published was a clean pass, red tests included (What you have to add).
  • Declared is not executed. A marker comment describes what the test does; it does not make the test do it. The two can drift, and a comment can never carry a cell. When a step is worth a verdict of its own, it is worth a helper (Declared is not executed).

Bobcat 1.0 is here!

JasperFx released Bobcat 1.0 yesterday, our new MIT license tool for:

  • “Executable Specifications”, but this time we’re focused strictly on being able to “project” human readable specifications for easy review with domain experts or us developers from the testing tools we already use, including xUnit.net, TUnit, MSTest, and NUnit. No Gherkin, markdown, or wiki text that gets magically mapped to code. Just C# or F#.
  • Okay, a little bit of markdown tables within the tests to for declarative table-centric testing that I’ve always found very valuable — but at least this time the source generators in Bobcat can verify the syntax at compile time!
  • Improved test automation with the kind of infrastructure heavy integration tests that are rife with flakiness and long testing times through its Supervisor library. See Introducing the Bobcat Supervisor for Reliable .NET Integration Suites for more about this.
  • Spec Driven Development within the Critter Stack’s overarching Event Modeling strategy, and some of the Critter Stack’s new application scaffolding from Event Models landed in Bobcat just because that’s what deals with specifications

For the dozen of you out there who remember my previous Storyteller tool, Bobcat is the spiritual successor that tries to incorporate the elements of Storyteller that were very effective (especially for data intensive testing where Gherkin isn’t a great fit in my opinion), but ditch the elements of Storyteller that made it clumsy to use and to ultimately fail.

And of course, this is the era of AI assisted development so there absolutely has to be an AI hook! One of the lessons that Bobcat took from Storyteller was the ability to embed and report on application telemetry and instrumentation as part of reporting test failures for easier test failure debugging — and folks, there will be test failures and bigger tests involving asynchronous behavior can be nasty to debug. As an example, the Bobcat integration package for Wolverine message handlers “knows” how to write telemetry about all the messaging behavior including failures, retries, dead letter queues, scheduled messages, outgoing messages, and exceptions that happened during the execution to console output in a way that’s optimized for AI agent consumption. The hope here is that this makes it easy for AI agents to quickly cycle between test failures and fixes by giving the AI agents exactly the information they need to complete their tasks.

We have also updated the JasperFx AI Skills pack for Bobcat usage, including skills to help trouble shoot specifications, to run specifications in “watch” mode for faster cycles, and to utilize the supervisor functionality for more robust and optimized CI builds.

Lastly, and this is a little weird for me to consider, but Bobcat is going to be the very last OSS project that I ever started before the advent of AI assisted development. There wasn’t much there, but Bobcat at its core does use some of the earliest code I wrote when I was just piddling around with this in odd moments and the original vision for Bobcat comes from over a decade of me thinking and talking to others about what a Storyteller replacement could look like.

Anyway, check it out and let us know about any feedback you have on the Critter Stack Discord server. There’s already a bobcat room open.

Why is it called Bobcat?

Because I think that bobcats are cool? I picked the codename years ago before we’d adopted the current “Critter Stack” Mustilidae naming scheme, but it took awhile to get around to making it real.

Read Your Writes with the Critter Stack

The image isn’t completely related, but it’s more than a little funny that the Superfriends had a super computer with incredible AI capabilities in the 70’s but yet its interface was writing out ticker tape. And for any other Gen X kids out there who read this: “meanwhile, at the Hall of Justice…”

JasperFx Software is happy to do interactive, customized workshops on the Critter Stack or Event Driven Architecture in general for your folks. Our workshops tend to be very conversational and we’ll happily tailor the content to the problems that your shop faces. Learn more about that on the “Workshops” page on the JasperFx website.

I was giving an online workshop this morning for a JasperFx client and one of our topics today was data consistency inside of a system using Event Sourcing. One of the slides from today is this one:

Roughly speaking, this is a guarantee that after a client writes or updates data, you can issue queries to that same client will be able to read the prior updates without any eventual consistency delay.

If you’ve only ever developed against relational databases and a single representation of data, you’re saying to yourself “well, of course, because that’s the way databases work?” However, if you’re working with Event Sourcing there’s a good chance that you run into Eventual Consistency of some sort and this “Read your own writes” guarantee is something you can’t take for granted — unless your event store is Marten, Polecat, or Fisher!

All of the Critter Stack event stores have plenty of options for strong consistency that frankly cannot be matched by the commercial event store tools or most of the OSS event stores out there for that matter. But let’s say for now that you have a real need to use our Async lifecycle for a single stream projection (performance? throughput? our blue/green deployment support requires that?), but you really want to be able to return the exact state of a single stream from an HTTP endpoint or from a message handler being used from IMessageBus.InvokeAsync<Report>() (Wolverine as a “mediator”). It’s a use case that’s common enough (HTTP endpoints or GraphQL mutations being the most common usage) that we’ve built some first class support for that into Marten, but first let’s look at an older usage.

You have long been able to do this:

// Today's pattern: forced flush + re-read
session.Events.StartStream<Report>(id, new ReportCreated("Q1"));
await session.SaveChangesAsync(ct); // forced flush
// This gives you the absolutely latest version of Report based
// on the events written to the event store, but this would
// *probably* make a network round trip to the database to
// fetch the data again
var report = await session.Events.FetchLatest<Report>(id, ct); // re-read
return report;

The FetchLatest API in Marten will happily fetch you the latest version of Report for a single stream that reflects the latest events no matter what the underlying Marten ProjectionLifecycle happens to be. Except in some particular circumstances (Inline plus having the identity map turned on), calling that makes a separate round trip to the database — and network round trips are the leading cause of poor performance in enterprise-y applications, so we ideally don’t want that!

So let’s cut out the extra network round trip and change the API usage to ProjectLatest:

// Better: project locally including pending events
session.Events.StartStream<Report>(id, new ReportCreated("Q1"));
var report = await session.Events.ProjectLatest<Report>(id, ct);
// SaveChangesAsync happens later (e.g., Wolverine AutoApplyTransactions)
return report;

ProjectLatest is slightly different in that it will use the in memory events and any initial fetch of an aggregate projection within the same session to build out the latest, greatest Report aggregate above without making any extra round trips to the database for the best possible performance in your system.

Now though, let’s add Wolverine to the mix with its integration with Marten and what we call the “aggregate handler workflow” (it’s the Decider pattern, but I think we named our implementation of that before any of us had seen the original Decider pattern paper). Wolverine has a little helper for Marten/Polecat/Fisher called UpdatedAggregate you can see in usage in this sample code where Order is a projected aggregate view for a single stream:

public static (UpdatedAggregate, Events) Handle(
MarkItemReady command,
[WriteAggregate]
Order order)
{
var events = new Events();
if (order.Items.TryGetValue(command.ItemName, out var item))
{
item.Ready = true;
events.Add(new ItemReady(command.ItemName));
}
else
{
throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order");
}
if (order.IsReadyToShip())
{
events.Add(new OrderReady());
}
return (new UpdatedAggregate(), events);
}

When Wolverine sees that UpdatedAggregate type in your handler signature, Wolverine is making the single [WriteAggregate] type be the response type for either an HTTP handler or a “cascading” message type for message handlers and calling the correct Marten APIs for you in the most efficient way it can. You just have to worry about the declarative signature.

And if you’re one of those many people who very much care about OpenAPI, Wolverine.HTTP creates the correct metadata describing an HTTP endpoint’s response if you use UpdatedAggregate.

For more information, see:

  1. ProjectLatest — Include Pending Events in Marten
  2. Returning the Updated Aggregate in Wolverine

Summary

Marten and Wolverine are all heavily used by a very engaged community, plus JasperFx is always active supporting the platform. The result is a mature set of tools that have already been through a lot of optimizations and happily provide solutions for almost any common use case in the realm of server side .NET development.

.NET 11 and the Critter Stack

Hey Critter Stack folks and JasperFx Software clients, absolutely none of this is set in stone and I’m mostly writing it to elucidate my own thinking, so call this the first pass.

.NET 11 will be publicly available on November 10th. For all of my career as an OSS author, I’ve been deep into my own thoughts and plans and usually caught badly off guard by major new .NET versions. Not this time though! We’re planning to have all the Critter Stack tools release with a .NET 11 target right after .NET 11 itself is officially released.

We’ve had some discussions about this on the Critter Stack Discord about how to address this with a couple choices — but know that we for better or worse do take SemVer pretty seriously:

  • Add .NET 11 targeting to our current versions and just make a minor version bump but also keep .NET 9 support just to avoid making any breaking changes
  • Do a major version release of all the major tools, so Marten 10.0 and Wolverine 7.0 for example and now target .NET 10 and .NET 11 across the stack

I’m hesitant to do the major version bump because that’s a lot of work, we just barely made major version releases in late May in our Critter Stack 2026 wave and this feels too soon to do another, and lastly, major version releases come with the pressure to make any breaking changes or consolidations you’ve already been thinking about.

On the flip side, .NET 9 is end of life and many of our dependencies, especially EF Core, are unholy nightmares to support with multi-targeting. And also, I’m tempted to lift the lid and make all those breaking changes and consolidations we’ve already been thinking of doing:)

And no, we’re not going to compromise on SemVer versioning rules. Dropping a .NET version from your support is 100% a breaking change. To paraphrase Winston Churchill, SemVer is the worst versioning system — except for all the others.

I think I’m personally landing on doing the major version bumps to add .NET 11 and drop .NET 9 at the same time. I’m also thinking that if we do that that we’ll make very few breaking changes to make upgrades easy and to back port fixes to the existing version lines at least through January of 2027. That being said, here’s my first thoughts on what new versions mean:

  • Marten 10, Wolverine 7, Polecat 6, Weasel 10, Fisher 2, JasperFx 3, and we’re going to wait Bobcat 1.0 for .NET 11 now. I’m not sure if Stoat goes 1.0 before Christmas anyway.
  • CritterWatch 1.2 will support .NET 11 and the newer versions of the other tools as well as being backward compatible with older Wolverine versions
  • Add .NET 11 support
  • Drop .NET 9 support
  • Marten 9.0 fixed what I long considered to be Marten’s “Original Sin” by finally turning the identity map mechanics off by default, so now it’s time to fix Wolverine’s and rewire Wolverine so that there’s a one to one relationship between handler chains and handlers. Gobbledygook to folks not familiar to Wolverine, so let me summarize that as “make Wolverine friendlier to modular monoliths by default“
  • We’ve identified quite a few opportunities to utilize .NET 11 capabilities as optimizations
  • Add C# discriminated union support to Marten/Polecat/Fisher because I just know that’s coming as a user request
  • Try to eliminate more structural duplication between Marten, Polecat, and Fisher. Especially with their respective Wolverine integrations but also for anything else we can
  • A big time way to dog food Stoat as an agent coordination tool

I don’t think there’s many breaking changes other than some types moving namespaces as part of the consolidation and less so than there was in the previous wave of major versions.

Welp, what does anyone think?

Storing and Querying a Hierarchy of Types with Marten

I was giving a version of the workshop on CQRS and Event Sourcing to a JasperFx client today (because that’s something that we do if you’re interested!). In the workshop, I give a little almost digression into Marten’s old document database functionality just to introduce that side of things because Marten’s projections subsystem relies heavily on that same document database functionality. I was speaking to an EF Core heavy crowd and they stopped me to ask questions about Marten’s ability to store and query hierarchies of related types — which is something Marten does very well and has done so for over a decade now.

Most of my time is spent reacting to various problems or trying to fill in the handful of gaps in Critter Stack capabilities, so it was nice to just stop and talk about something that Marten does very well and that’s been so stable for so long that I basically forget about it. Long story short, this functionality is easier and simpler in Marten (or Polecat or Fisher too) than it is with EF Core.

Let’s say you’ve got a Payment concept with a handful of specialized flavors. This is close to the example that Milan Jovanović used in his post on EF Core inheritance mapping back in August, and I’m borrowing it here because it’s about as representative of the “real” problem as anything I could make up:

public abstract class Payment
{
public Guid Id { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; } = "USD";
public DateTimeOffset CreatedAt { get; set; }
}
public class CreditCardPayment : Payment
{
public string CardHolderName { get; set; }
public string LastFour { get; set; }
}
public classBankTransferPayment : Payment
{
public string BankName { get; set; }
public string AccountNumber { get; set; }
}
public class CryptoPayment : Payment
{
public string WalletAddress { get; set; }
public string Network { get; set; }
}

You want to be able to query for “all payments over $100 last week” and get back a mixed bag of concrete types, and you want to be able to say “give me the credit card payments for this card holder” without wading through the bank transfers. That’s not an exotic requirement. That’s Tuesday.

The EF Core way, where you have to pick

If you’re using EF Core, the official inheritance docs give you three mapping strategies, and you really do have to choose one up front because the choice changes your database schema, your generated SQL, and in one case, how your primary keys get generated. Quickly:

Table per hierarchy (TPH) is the default. All four types land in one Payments table with a discriminator column, and every subclass-only property becomes a nullable column that’s NULL on every row of every other subtype:

public class PaymentConfiguration : IEntityTypeConfiguration<Payment>
{
public void Configure(EntityTypeBuilder<Payment> builder)
{
builder.ToTable("Payments");
builder.HasDiscriminator<string>("PaymentType")
.HasValue<CreditCardPayment>("CreditCard")
.HasValue<BankTransferPayment>("BankTransfer")
.HasValue<CryptoPayment>("Crypto");
builder.Property("PaymentType").HasMaxLength(50);
}
}

Table per type (TPT) gives you a Payments table plus one table per subclass joined back to the base by a foreign key. Your schema looks exactly like your class diagram, which is appealing right up until you look at the query plans. Loading “all payments” is a LEFT JOIN per subclass:

modelBuilder.Entity<Payment>().ToTable("Payments");
modelBuilder.Entity<CreditCardPayment>().ToTable("CreditCardPayments");
modelBuilder.Entity<BankTransferPayment>().ToTable("BankTransferPayments");
modelBuilder.Entity<CryptoPayment>().ToTable("CryptoPayments");

Table per concrete type (TPC) gives you one standalone table per concrete class, each with the full set of columns copied in. Polymorphic queries become a UNION ALL across every table. Because there’s no longer a single table to own the identity, EF has to hand out ids from a shared database sequence, and you can’t put a real foreign key on anything that points at “a payment” because a payment’s key might live in any of three tables:

modelBuilder.Entity<Payment>().UseTpcMappingStrategy();

To be fair to the EF team, they’re pretty candid about all this. The modeling for performance page says in so many words that TPT is “in most cases the inferior mapping technique from a performance standpoint,” and their own benchmark loading 35,000 rows across a 7-type hierarchy clocks TPT at roughly double the time and allocations of TPH. Their summary guidance is: use TPH unless you’ve measured a reason not to, use TPC when you mostly query one leaf type at a time, and “use TPT only if constrained to do so by external factors.”

So the recommended answer is “put it all in one table with a discriminator column.” Great! That’s the right answer. Now notice how much ceremony it took to get there, and how many ways there are to get it wrong: nullable sparse columns, HasValue<T>() registrations that have to be kept in sync with your class list, the IsComplete(false) escape hatch for when the database has discriminator values your model doesn’t know about, shared columns between siblings needing explicit HasColumnName() calls, and a migration every single time a subclass grows a property.

The Marten way, where the choice collapses

Marten stores documents (entities) as JSONB in PostgreSQL. There is no column per property, so there is no such thing as a sparse column, and no reason to ever spread a class hierarchy across multiple tables. The whole TPH/TPT/TPC decision tree just isn’t there. One table, one row per document, and a small discriminator column so Marten knows which concrete type to deserialize. Just get things done.

Here’s the entire configuration for the payment hierarchy above:

var store = DocumentStore.For(opts =>
{
opts.Connection(connectionString);
opts.Schema.For<Payment>()
.AddSubClassHierarchy(
typeof(CreditCardPayment),
typeof(BankTransferPayment),
typeof(CryptoPayment));
});

That’s it. No OnModelCreating(), no IEntityTypeConfiguration<T>, no discriminator values to hand-maintain, no column mapping. Add a property to CryptoPayment next month and nothing in this configuration changes and there’s no migration, because the property just shows up in the JSON. If Payment were a concrete class you’d use the same call, and if you’d rather Marten found the subtypes for you, there’s a parameterless AddSubClassHierarchy() overload that scans the base type’s assembly (I’d use the explicit list in anything with a big assembly, but it’s there). You can read more on the document hierarchies page, including the multi-level case where you want to query against an intermediate type in the hierarchy.

What does the table actually look like? I ran this configuration against PostgreSQL 17 and pulled the shape straight out of information_schema:

column type nullable
id uuid NO
data jsonb NO
mt_last_modified timestamp with time zone YES
mt_version uuid NO
mt_dotnet_type character varying YES
mt_doc_type character varying YES

Same table you’d get for any Marten document (see document storage and metadata), plus mt_doc_type, which is the discriminator. Marten derives the discriminator value from the type name, so the three rows I stored look like this:

mt_doc_type data
bank_transfer_payment {"Id": "...", "Amount": 1000.0, "BankName": "First Bank", "Currency": "USD", "AccountNumber": "123", ...}
credit_card_payment {"Id": "...", "Amount": 250.0, "Currency": "USD", "LastFour": "4242", "CardHolderName": "Jeremy", ...}
crypto_payment {"Id": "...", "Amount": 50.0, "Network": "ETH", "Currency": "USD", "WalletAddress": "0xabc", ...}

No NULL columns for the properties a subtype doesn’t have. Each row carries exactly the JSON for its own type. Marten also drops a btree index on mt_doc_type for you automatically, so narrowing to a subtype is cheap from day one.

One operational note: because the discriminator comes from the class name, renaming CryptoPayment to CryptocurrencyPayment would orphan the existing rows. If you think a subtype’s name might change, pin the alias explicitly with a MappedType and Marten will use that instead of the derived name:

opts.Schema.For<Payment>()
.AddSubClassHierarchy(
typeof(CreditCardPayment),
typeof(BankTransferPayment),
new MappedType(typeof(CryptoPayment), "crypto"));

How LINQ works against the hierarchy

The part I’m proudest of is that querying a hierarchy in Marten is just… querying. You query for the base type and get the whole family back as their concrete types, or you query for a subtype and get only that subtype. Here are the queries and the SQL Marten actually generated for them, lifted from ToCommand() on a real session:

// Every payment, deserialized to its concrete typevar all = await session.Query<Payment>().ToListAsync();
// select d.id, d.data, d.mt_doc_type from public.mt_doc_payment as d;// Polymorphic filter on a base type propertyvar big = await session.Query<Payment>()
.Where(x => x.Amount > 100)
.ToListAsync();
// select d.id, d.data, d.mt_doc_type from public.mt_doc_payment as d// where CAST(d.data ->> 'Amount' as decimal) > :p0;// Just the credit card paymentsvar cards = await session.Query<CreditCardPayment>().ToListAsync();
// select d.id, d.data, d.mt_doc_type from public.mt_doc_payment as d// where (d.mt_doc_type = 'credit_card_payment');// A subtype query filtering on a subtype-only propertyvar mine = await session.Query<CreditCardPayment>()
.Where(x => x.CardHolderName == "Jeremy")
.ToListAsync();
// select d.id, d.data, d.mt_doc_type from public.mt_doc_payment as d// where ((d.mt_doc_type = 'credit_card_payment') and d.data ->> 'CardHolderName' = :p0);

A few things to notice. Marten selects mt_doc_type alongside the JSON so it knows which concrete type to deserialize each row into, which is how Query<Payment>() hands you back a list with a CryptoPayment, a CreditCardPayment, and a BankTransferPayment in it. Querying for a subtype adds the discriminator predicate for you, and that predicate hits the automatic index. There’s no UNION ALL, no LEFT JOIN chain, and no “shared column” gymnastics, because the subtypes don’t have separate columns.

The same holds for loading by id. LoadAsync<Payment>(id) returns the concrete subtype, LoadAsync<CreditCardPayment>(id) returns the document if it’s a credit card payment, and LoadAsync<CryptoPayment>(sameId) returns null rather than mis-casting somebody’s card into a wallet.

If you need to filter on a subtype’s property while still querying for the base type, there’s a WhereSub<TSub>() operator for exactly that:

var eth = await session.Query<Payment>()
.WhereSub<CryptoPayment>(x => x.Network == "ETH")
.ToListAsync();
// select d.id, d.data, d.mt_doc_type from public.mt_doc_payment as d// where d.data ->> 'Network' = :p0;

I’ll be honest that WhereSub() is a little bit of a specialty item. It’s really there for the case where you’re composing one query across the hierarchy and want to reach into a subtype’s members; if you just want crypto payments, Query<CryptoPayment>() is the better call. The full list of what Marten’s LINQ provider supports lives under querying with LINQ, and when you want to see what any query is going to do, ToCommand() or ExplainAsync() will show you.

Indexes, including partial indexes against a single subtype

This was a request from a JasperFx client (well, actually a pull request, but hey, that means that we support this going forward if there’s any issues with it).

Now for the part that I think makes the single table approach genuinely better in Marten than TPH is in a relational ORM, and not merely less work.

PostgreSQL supports partial indexes: an index with a WHERE clause, so it only covers the rows that match. Because every subtype in a Marten hierarchy lives in one table with a discriminator column, you can build an index that only covers one subtype, on a property that only that subtype has, and PostgreSQL will keep it tiny and use it when the query’s predicate lines up.

Marten exposes this through the Predicate option on a computed index, and on the index that comes with a duplicated field. Here’s the payment configuration again with two subtype-only indexes added:

opts.Schema.For<Payment>()
.AddSubClassHierarchy(
typeof(CreditCardPayment),
typeof(BankTransferPayment),
typeof(CryptoPayment))
// A computed index on Amount, but ONLY for credit card payments.// Bank transfers and crypto payments never enter this index.
.Index(x => x.Amount, i =>
{
i.Predicate = "mt_doc_type = 'credit_card_payment'";
})
// CardHolderName only exists on CreditCardPayment, so duplicate it// into its own column and index that column, again only for the// credit card rows.
.Duplicate<CreditCardPayment>(x => x.CardHolderName,
configure: i => i.Predicate = "mt_doc_type = 'credit_card_payment'");

That Duplicate<TSub>() overload is the thing to notice. The docs will tell you that searchable fields are declared on the top type of the hierarchy, and that’s true, but you declare them for a subtype by naming the subtype in the generic argument. Marten adds a card_holder_name column to the table, keeps it populated on write for credit card rows, and leaves it NULL for everybody else, which is exactly the one “sparse column” you actually asked for rather than one per property per subtype.

Here is the DDL Marten generated, straight from pg_indexes:

CREATEINDEX mt_doc_payment_idx_amount
ON public.mt_doc_payment USING btree ((((data ->> 'Amount'::text))::numeric))
WHERE ((mt_doc_type)::text = 'credit_card_payment'::text);
CREATEINDEX mt_doc_payment_idx_card_holder_name
ON public.mt_doc_payment USING btree (card_holder_name)
WHERE ((mt_doc_type)::text = 'credit_card_payment'::text);
-- Marten adds this one on its own for any hierarchyCREATEINDEX mt_doc_payment_idx_mt_doc_type
ON public.mt_doc_payment USING btree (mt_doc_type);

And because the LINQ provider adds the mt_doc_type = 'credit_card_payment' predicate to every Query<CreditCardPayment>() call, the generated query matches the partial index’s WHERE clause and PostgreSQL picks it up without any extra hinting on your part:

var mine = await session.Query<CreditCardPayment>()
.Where(x => x.CardHolderName == "Jeremy")
.ToListAsync();
// select d.id, d.data, d.mt_doc_type from public.mt_doc_payment as d// where ((d.mt_doc_type = 'credit_card_payment') and d.card_holder_name = :p0);
Index Scan using mt_doc_payment_idx_card_holder_name on mt_doc_payment d
Index Cond: ((card_holder_name)::text = 'Jeremy'::text)

Note that with the duplicated field in place, the generated SQL switched from data ->> 'CardHolderName' to the real card_holder_name column with no change to the LINQ. That’s the whole point of duplicated fields.

You can do the equivalent in EF Core, for what it’s worth. You’d add HasFilter("[PaymentType] = 'CreditCard'") to an HasIndex() call in your TPH configuration. The difference is that in EF Core you had to have already chosen TPH, already named the discriminator column and its values, and already accepted a nullable column for every subtype property, to earn the right to do it. In Marten it’s one option on an index you were probably going to declare anyway.

What this means for the table

Since this is all one table, everything else Marten does to a document table applies uniformly to the hierarchy:

  • GIN indexes on the data column cover every subtype at once, which is a cheap way to get “good enough” querying across the whole family before you commit to targeted indexes.
  • Soft deletes, optimistic concurrency, and the metadata columns (last modified, causation, correlation, and so on) are configured once on the base type and apply to every subtype. You don’t configure deletes three times for three tables.
  • Table partitioning and multi-tenancy work exactly as they do for any other document, because to PostgreSQL this is just a table.
  • Growth is linear. Adding a fourth subtype is one more typeof() in the configuration and zero schema changes, because the only new thing in the database is a new mt_doc_type value.

The honest tradeoffs are the same ones TPH carries anywhere. One hot table means autovacuum and bloat are shared across subtypes that may have very different write rates, and you can’t declare a database-level NOT NULL constraint on a subtype-only property (although Duplicate<TSub>() with notNull: true gets you partway there for the fields you care about). If two of the subtypes have nothing in common and are never queried together, they probably shouldn’t be a hierarchy in the first place, and Marten is perfectly happy to store them as two unrelated document types in two tables.

Summary

Marten collapses the table-per-whatever decision down to the one answer the relational ORMs end up recommending anyway, and then makes it close to free: one table, one line of configuration, no ORM mapping, LINQ that already knows about the discriminator, and partial indexes that let you tune for a single subtype without the rest of the hierarchy paying for it. Hierarchy storage has been in Marten since the very early days and I still think it’s one of the quieter wins of putting a document store on top of PostgreSQL.

Questions, war stories, or a hierarchy that doesn’t fit this shape? Find us in the Critter Stack Discord and we’ll talk it through.

Wolverine passed 10 million downloads

Right now things are a bit of a blur just because of the high rate of incoming GitHub traffic we get for the Critter Stack, with Wolverine being the most common recipient of new issues, community pull requests, and JasperFx client requests.

It’s nice to occasionally take a minute and celebrate the project wins whenever they come, and this weekend it’s Wolverine crossing the 10 million download mark (Nuget download statistics are a bit broken right now, but in at least one view Wolverine has 10.2+ M right now).

If you’re not familiar with it, Wolverine is essentially a server side application framework for .NET with a very strong emphasis on low code ceremony, high testability, performance, and (sigh) now AI friendliness. Along the way, Wolverine has answers for usage as:

  • an asynchronous messaging tool that supports more messaging broker options than any other in .NET as well as having first class options for SignalR or messaging via HTTP or gRPC
  • an alternative HTTP or gRPC endpoint programming model that’s particularly useful because of Wolverine’s transactional outbox and messaging
  • if you absolutely have to, you can use Wolverine as “just” a Mediator tool

Thank you to all of the other Critter Stack core team members, Oskar & Corey for being there in the beginning, and the very vibrant Wolverine community for making so many contributions and giving the feedback it takes to make a tool like Wolverine.

All in all, it’s particularly gratifying to me because Wolverine has been such a slow burn project for almost a decade (more so if I count it as the successor project to FubuMVC / FubuTransportation) that I had completely given up on during the pandemic before growing into a tool that collects over a million downloads a month this year.

AI has moved the bottlenecks in software development

As an experienced software developer and the owner of a software tooling company I’d really like to continue to grow, I’m fully embracing the central role that AI assisted development now takes. I can’t say that I completely like how things have changed and we’re still clearly adapting as we go.

Just to complain a bit and try to make sense of our new world order, here’s where I think the bottle necks in software development have moved to, at least for me:

CI via GitHub Actions. We the entire OSS community are hammering GitHub in a probably unsustainable way and CI runs are now the ultimate bottle neck for Critter Stack development. This of course is after we got very aggressive with AI tools to expand the testing that runs in CI to the point where Wolverine currently has 46 CI gates and Marten has 32 (some of that is from matrix testing on different JSON serializers, .NET versions, and PostgreSQL versions). That explosion in usage plus GitHub implementing some rate limiting on the number of available action runners is definitely an impediment. More and more, I’m leaning back into using local testing runs to gate commits where I think that is suitably low risk. I think it’s also worth thinking hard about making CI runs be more selective about what CI gates are necessary for different pull requests or commits as another way of reducing usage.

I don’t know how I feel about this yet, but we’re occasionally opting for much bigger pull requests like this one that spans 8-9 issues. I know there’s a bit of a backlash against pull requests right now for plenty of reasons I’m quite sympathetic to, but pull requests provide tracking and visibility into progress and release notes for us. I’d like to keep using pull requests for that reason only for right now, but it’s something we need to evaluate changing.

Test execution time. Most of the Critter Stack tools almost have to be tested through their integration with infrastructure like databases and message brokers. To make that much worse, we have to frequently stand up and trash .NET applications with different configurations to test more use cases. The advent of AI has also made it much easier to build in far, far more test coverage to explore more permutations, “soak” tests, and other exploratory testing. Yea for more test coverage! Boo for the increased time to run test suites. Also, and this is a good problem to have, AI is making it much easier for our users to add actionable issues for normally hard to observe problems in the tools they find from their own exploratory testing (which is also easier to do now and you should!) — and those issues tend to require specialized testing harnesses to recreate their exact problem and tend to be expensive tests to run.

My own energy level. The increased mental overload from context switching, code reviews, and generally trying to make decisions fast enough and well enough to keep the agents spinning makes me burn out faster during the day. I take more breaks now than I did before.

Development box resources. I got unexpectedly lucky by needing to replace my main development box earlier this year right before memory prices spiked. In all seriousness, memory and CPU utilization for locally running Docker containers and our heavyweight test suites “limits” how many parallel agents I run — but that number is admittedly more than I can generally handle mentally anyway.

Nuget.org indexing time. I doubt this impacts many people nearly as much as it does me, but we’re frequently having to do cross repository work in the Critter Stack lately, and the latency of Nuget.org indexing newly published packages is noticeable, if better than I think it was this time last year.

Sorry, I don’t have a ton of answers to any of these problems or expect them to be the exact same issues you might be experiencing yourself. But hey, it’s at least good to start seeing where the bottlenecks are and concentrate our efforts to solving those problems first I guess.

And apropos of nothing, my colleague is lounging on the job again today and I’m pretty sure that anyone walking into my office is going to know right off the bat that this is the lair of a Gen X guy who finally has the posters he wish he’d had as a kid.

Wolverine’s Community is Busy!

I worked with my Dad’s construction crew in High School and college. On one job a lumber salesman would always come by right when we were taking a water break in the late morning and start by teasing us that we were always lounging around! I’m embarrassed to realize years later that he obviously did that on purpose to catch Dad at a good time to talk about what we were going to need next. Duh.

I think this week might have been one of the busiest weeks ever for GitHub traffic to Wolverine and I’m close to “I cannot work anymore, I just want to play pinball” levels of burned out on a Friday this week after dealing with all of that. And yes, having AI tools now makes that a lot easier mechanically, but it also makes it a lot easier for other folks to build pull requests or to analyze their own usage which has brought out plenty of issues that would probably otherwise go undetected. All told, the amount of context switching just trying to keep up with reviewing pull requests (because the buck stops here) and driving other fixes is a lot of (AI Brain Fry) work, just different than before.

Honestly, just to make me feel better, I had Claude do some research today to prove out that Wolverine actually gets much more community contribution than its .NET competitors — which I think is partially attributable to JasperFx’s “Open Core” model and partially because Wolverine is actually a much larger, more ambitious tool set in many ways than MassTransit or NServiceBus is (HTTP endpoints, more multi-tenancy options, quite a bit more modular monolith support, supporting an absurd number of messaging brokers, and far more options for simplifying application code being prime examples).

I’m focusing on pull requests here, and specifically on pull requests from people who are not part of the core team or on the payroll of the company behind the tool. Issues, Discord questions, and reproductions matter a great deal too (more on that later), but a merged pull request is the least ambiguous signal I know of that somebody outside the project cared enough to roll up their sleeves.

The numbers

Merged pull requests from outside contributors, October 2024 through October 2026:

ProjectCommunity PRs mergedDistinct community contributorsCame back for a 2nd PRPRs over 500 lines
Wolverine3971546452
Brighter230291888
MassTransit554373
NServiceBus15510
MediatR10620

And the trend, year over year:

ProjectOct 2024 to Oct 2025Oct 2025 to Oct 2026
Wolverine116 PRs / 63 people281 PRs / 110 people
Brighter83 PRs / 11 people147 PRs / 22 people
MassTransit50 PRs / 39 people5 PRs / 4 people
NServiceBus4 PRs / 3 people11 PRs / 3 people
MediatR8 PRs / 5 people2 PRs / 2 people

A few things jump out at me:

  • 154 different people outside the core team got code or docs merged into Wolverine in two years. That’s more than three times the next closest tool in this list, and it nearly doubled from the first year to the second
  • 64 of those folks came back and did it again. To me that’s the number that says the most about whether contributing to a project is a pleasant experience
  • The median community pull request to Wolverine was merged about 21 hours after it was opened, and roughly four out of five community pull requests that get opened end up merged
  • Brighter deserves a real tip of the hat here. It has a smaller contributor base than Wolverine, but a very committed one, and its community pull requests tend to be big
  • The MassTransit and MediatR numbers fall off a cliff in the second year, which lines up with both tools moving to commercial licensing in 2025. I’m not taking a shot at anybody for that, building a sustainable business around OSS is hard and I’ve had to make my own choices there. But it does change what “community contribution” looks like for those tools going forward

For my part, I’m genuinely impressed with Brighter and I didn’t realize that it was nearly that active, so hat’s off to Ian and their community.

Obviously, MassTransit and NServiceBus are mostly maintained by actual employees, but so is Wolverine (mostly me, but also Babu). And MediatR is small and mature, so you wouldn’t expect too much traffic there.

Where the community has made Wolverine better

Raw counts are one thing. What I actually care about is that big, important chunks of Wolverine exist because somebody outside of JasperFx Software built them or pushed for them. Here are some of the recent themes.

Deduplication with a response, just this week

Wolverine 6.45 added [DeduplicatedWithResponse], which gives you Stripe-style idempotency keys on HTTP endpoints where a repeated request gets the first response back instead of a bare “duplicate” refusal. That was Laurence Gillian‘s idea, Laurence’s issue, and then Laurence’s pull request, which weighed in at just under 4,000 lines with tests and documentation. Laurence followed that up with two more fixes for cases where a deduplication claim outlived a request that did no work.

In the same week, alesdvorakcz fixed the mark-as-handled path so it matches the whole inbox identity inside the caller’s transaction and releases ownership of handled rows, and Marko Lahma found and fixed an inline retry that was holding onto the failed attempt’s outgoing messages. That’s three different people hardening the same corner of the idempotency story in about four days.

Native AOT

I’ll be honest that I didn’t expect AOT to matter much for a tool that was built around runtime code generation. The community has been steadily telling me otherwise. This one hasn’t been pull request driven so much as driven by people actually trying it and writing up exactly where it fell over:

  • RaySinner reported a set of Native AOT startup crashes on Linux across both JasperFx and Wolverine
  • Derek Choate followed that up with the proposal to emit [DynamicDependency] rooting straight from the generated code
  • Christian Bergum Bergersen tracked down generic frame types that were still being closed reflectively under TypeLoadMode.Static, first in the event registration path and then in HTTP chain building. The first of those shipped in 6.45, and the second is in flight right now with an honest to goodness Native AOT smoke test for the HTTP path

Not every valuable contribution is a pull request. A precise issue from somebody running a real AOT build is worth more to me than most pull requests.

EF Core and multi-tenancy

I’d like the number of EF Core features we don’t support to be zero and continue to make Wolverine friendlier to EF Core users because that’s always going to be where most of the .NET community is. Some recent highlights:

  • Ali Yuksekkaya filed the three issues that touched off last month’s EF Core sweep, and this week landed a 1,500 line first pull request fixing step-method [Entity] loading, query plans, and multi-tenant DbContext resolution. Three of the seven bugs it fixed were silent data loss
  • PerfectlyNormal fixed a missing transaction commit for multi-tenanted EF Core
  • Geoffrey Marc made domain event scraping work on the managed multi-tenancy commit path and made tenanted message stores honor MessageStoreRole
  • Julien Mounier added optimistic concurrency for EF Core backed sagas, and Ferdavs Majitov and Artyom both improved how the outbox lines up with EF Core’s own transaction model

Whole transports and subsystems

This is the part that still surprises me when I look at the list. A lot of what’s on the Wolverine feature matrix arrived as a community pull request:

The unglamorous work

And lastly, the single most prolific outside contributor to Wolverine over the last two years is Dmytro Pryvedeniuk with 28 merged pull requests who did absolutely heroic work. It’s flaky test fixes, CI consolidation, build warnings, vulnerable dependency bumps, and resource disposal. That kind of work is exactly what makes it possible for everybody else’s pull request to get merged in under a day, and I’m very grateful for it.

Thank you

I’ve been doing OSS development for a long time now, and the thing I’m most proud of with the Critter Stack isn’t any particular feature. It’s that there’s a real community of people who use these tools hard, tell us when they break, and more and more often just send the fix. If you’re one of the 154, thank you. If you’ve been thinking about being number 155, the door’s open, and I’ll do my best to keep that 21 hour number honest.

How I counted

  • Source is the GitHub GraphQL API for JasperFx/wolverine, MassTransit/MassTransit, Particular/NServiceBus, LuckyPennySoftware/MediatR, and BrighterCommand/Brighter, counting pull requests merged between October 2, 2024 and October 2, 2026
  • Bot authors (Dependabot, Renovate, and friends) are excluded everywhere
  • “Community” means the author is not on the project’s core team or employed by the company behind it. For Wolverine I excluded myself, Babu Annamalai, Jaedyn, Anne Erdtsieck, and other JasperFx organization members. For MassTransit that’s Chris Patterson, for MediatR it’s Jimmy Bogard, for Brighter it’s the BrighterCommand organization members, and for NServiceBus it’s everybody I could identify as Particular Software staff from their public profile, organization membership, or commit email
  • GitHub’s own “member vs contributor” flag was not reliable for this because private organization membership shows up as a plain contributor, so the staff lists were built by hand. If I’ve put somebody in the wrong bucket, tell me and I’ll fix it