Skip to content

NServiceBus to Wolverine using our AI Skills

Jeremy Miller25th August 2026
WolverineAI SkillsCritter Stack
Wolverine

JasperFx Software is actively curating and evolving a series of AI Skill files to help our clients wring the very most value out of their development efforts targeting the Critter Stack. The AI Skills add value by pushing your AI agent into the direction of Critter Stack idioms that lead to more maintainable code, better testability, the right resiliency, concurrency protections, messaging guarantees, and even more performant code. Without the AI Skills, Claude or whatever will still be able to generate Critter Stack code, but won't necessarily use it the best possible way -- as this post will try to show. And lastly -- as I found out today while dogfooding our AI Skills -- sometimes you have to really be blunt with your AI agents to utilize all the command line diagnostics we've built into Wolverine especially for understanding your system's behavior and configuration.

Alright, let's get to the real show...

The scenario

You have an existing constellation of services that perform shipment-tracking, all connected by RabbitMQ and currently implemented using NServiceBus for messaging and let's say Dapper targeting SQL Server for persistence. The system is also today using ASP.NET Core Minimal API for its HTTP endpoints. You've made the decision to adopt the Critter Stack, or are at least curious to see how the Critter Stack tools might lead to simplified code.

We'll happily claim that Wolverine is the king of low code ceremony

So the three existing services are:

  • carrier-events -- webhook firehose, thousands a minute, per-shipment ordering
  • shipment-commands -- book and cancel, writes to SQL Server, publishes follow-on events
  • label-generation -- calls the carrier label API, 30-90 seconds per call

Let's start playing around with using an AI tool to convert the existing systems to Wolverine, but let's start first without any of the AI Skills installed.

First try, no AI Skills

Alright, let's try this prompt with no JasperFx AI Skills for a control:

Here's an NServiceBus endpoint we're moving to Wolverine. Convert the host
  configuration and this handler.

  var endpointConfiguration = new EndpointConfiguration("ShipmentTracking");
  endpointConfiguration.UseTransport<RabbitMQTransport>()
      .ConnectionString("host=localhost");
  endpointConfiguration.UsePersistence<SqlPersistence>();
  endpointConfiguration.EnableInstallers();
  var endpoint = await Endpoint.Start(endpointConfiguration);

  public class CarrierScanHandler : IHandleMessages<CarrierScanRecorded>
  {
      public Task Handle(CarrierScanRecorded message, IMessageHandlerContext context)
          => context.Publish(new ShipmentLocationUpdated(
                 message.ShipmentId, message.Location));
  }

After a little bit of churn, Claude Opus 5 spit out this code:

csharp
var builder = Host.CreateApplicationBuilder(args);

builder.UseWolverine(opts =>
{
    opts.ServiceName = "ShipmentTracking";

    opts.UseRabbitMq(new Uri("amqp://localhost"))
        // the EnableInstallers() equivalent for the broker:
        // declares exchanges/queues/bindings at startup
        .AutoProvision();

    // NServiceBus endpoint name == input queue. Wolverine's ServiceName is
    // only a label, so declare the listener explicitly.
    opts.ListenToRabbitQueue("ShipmentTracking")
        .UseDurableInbox();

    // CarrierScanRecorded is published by some other service, so bind this
    // endpoint's queue to that publisher's exchange (NServiceBus did this
    // for you via auto-subscribe at startup).
    opts.UseRabbitMq().DeclareBinding(new RabbitMqBinding("CarrierScanRecorded", "ShipmentTracking"));

    // UsePersistence<SqlPersistence>() -> inbox/outbox tables
    opts.PersistMessagesWithSqlServer(
        builder.Configuration.GetConnectionString("SqlServer")!);

    // outbox on everything this endpoint sends
    opts.Policies.UseDurableOutboxOnAllSendingEndpoints();

    // where ShipmentLocationUpdated goes
    opts.PublishMessage<ShipmentLocationUpdated>()
        .ToRabbitExchange("ShipmentLocationUpdated", ex => ex.ExchangeType = ExchangeType.Fanout);
});

// the database half of EnableInstallers(): create/patch the envelope tables
builder.Services.AddResourceSetupOnStartup();

using var host = builder.Build();
await host.RunAsync();

// Handler

public class CarrierScanHandler
{
    public ShipmentLocationUpdated Handle(CarrierScanRecorded message)
        => new(message.ShipmentId, message.Location);
}

and with this commentary:

No interface, no IMessageHandlerContext. Wolverine discovers this by convention (*Handler type, Handle method), and the returned value is a cascading message routed through the rules above — so context.Publish(...) disappears entirely. static is a small perf win since the handler holds no state.

Things that don't map cleanly

- Endpoint name vs. queue name. NSB derives the input queue from new EndpointConfiguration("ShipmentTracking"). In Wolverine ServiceName is only identity/telemetry — the ListenToRabbitQueue call is what actually creates the input queue, and it's on you to keep the name matching if you're doing a rolling cutover.
- Connection string. Your SqlPersistence config didn't show one (NSB pulls it from ConnectionStrings/NServiceBus/Persistence). I used ConnectionStrings:SqlServer — swap in whatever you're actually reading.
- Publish routing. NSB's Rabbit transport builds an exchange-per-event-type topology and subscribers bind themselves. Wolverine won't infer it: either declare each PublishMessage<T>() as above, or turn on opts.UseRabbitMq().UseConventionalRouting() if you want the NSB-ish "just works" behavior across the board. Declare the exchange as fanout if multiple services subscribe.
- Durability defaults are inverted. NSB's SqlPersistence gives you the outbox implicitly; Wolverine defaults to non-durable ("buffered") endpoints, hence the two Policies lines. Drop them only if you actually want fire-and-forget.
- Sagas. Nothing in this snippet uses them, but if the real endpoint has NSB sagas, PersistMessagesWithSqlServer covers Wolverine saga storage too — the saga model itself is a bigger rewrite than the handler was.

Not bad, but this is a very simple example and there's a few Wolverine-isms you'll want that the AI agent missed. Let's move on to something a little more complicated.

Now let's talk about messaging usage

Let's try this next prompt that will get us into more messaging:

This service has three RabbitMQ listening endpoints:

- carrier-events: a webhook firehose from carriers, thousands of messages a
  minute. Scans for the same shipment have to be processed in order.
- shipment-commands: BookShipment / CancelShipment. Each one writes to
  Postgres and publishes follow-on events.
- label-generation: calls the carrier's label API. Takes 30-90 seconds per
  label.

We're hitting a throughput ceiling. Configure the three listening endpoints
for maximum throughput.

Alright, that led to this:

⏺ Fetch(https://wolverinefx.net/guide/messaging/listeners.html)
Received 182.6KB (200 OK)

⏺ Fetch(https://wolverinefx.net/guide/messaging/transports/rabbitmq/listening.html)
Received 142.4KB (200 OK)

⏺ Web Search("Wolverine message partitioning group id ordered local queues sticky listeners")
Did 1 search in 9s

⏺ Fetch(https://wolverinefx.net/guide/messaging/partitioning.html)
Received 244.7KB (200 OK)

⏺ Fetch(https://jeremydmiller.com/2025/05/21/message-concurrency-parallelism-and-ordering-with-wolverine/)
Received 0 bytes (403 Forbidden)

⏺ Web Search("Wolverine global message partitioning across nodes forward message to owning node")
Did 1 search in 6s

⏺ Fetch(https://wolverinefx.net/guide/messaging/transports/rabbitmq/)
Received 189.8KB (200 OK)

⏺ Fetch(https://wolverinefx.net/guide/messaging/transports/rabbitmq/performance.html)
Received 140.7KB (200 OK)

Fetched the current docs to get the API right — a few things changed recently that matter here.

The short version

These three endpoints want opposite settings. "Maximum throughput" on all three at once is the trap:

┌─────────────┬────────────────┬───────────────────┬──────────────────────┐
│             │ carrier-events │ shipment-commands │   label-generation   │
├─────────────┼────────────────┼───────────────────┼──────────────────────┤
│ Bound by    │ message rate   │ Postgres pool     │ external API latency │
├─────────────┼────────────────┼───────────────────┼──────────────────────┤
│ Mode        │ NativeAck      │ Durable inbox     │ Durable inbox        │
├─────────────┼────────────────┼───────────────────┼──────────────────────┤
│ Parallelism │ slots × groups │ ≤ pool size       │ high (I/O bound)     │
├─────────────┼────────────────┼───────────────────┼──────────────────────┤
│ PreFetch    │ high           │ moderate          │ low                  │
└─────────────┴────────────────┴───────────────────┴──────────────────────┘

The default listener mode is inline — ack after successful handling, one message at a time per listener. [...] it's the same on all three.

Configuration

using JasperFx.Core;              // 1.Minutes()
using Wolverine.RabbitMQ;
using Wolverine.Postgresql;

builder.UseWolverine(opts =>
{
    opts.ServiceName = "ShipmentTracking";
    opts.UseRabbitMq(rabbit => rabbit.HostName = "localhost").AutoProvision();

    // you said Postgres — this replaces PersistMessagesWithSqlServer from before
    opts.PersistMessagesWithPostgresql(connectionString);
    opts.Policies.AutoApplyTransactions();

    // ---- how Wolverine derives the ordering key, once, for everything ----
    opts.MessagePartitioning
        .ByPropertyNamed("ShipmentId");
        // or, if you have a marker interface:
        // .ByMessage<IShipmentMessage>(x => x.ShipmentId)

    // ---- 1. carrier-events: firehose, ordered per shipment ----
    opts.ListenToRabbitQueue("carrier-events")
        .ProcessInParallelWithNativeAcks()
        .PartitionProcessingByGroupId(PartitionSlots.Seven)
        .MaximumParallelMessages(28)
        .PreFetchCount(200)
        .ConsumerDispatchConcurrency(8)
        .WithInMemoryIdempotency(window: 5.Minutes(), maxTracked: 100_000)
        .CircuitBreaker(cb =>
        {
            cb.PauseTime = 30.Seconds();
            cb.FailurePercentageThreshold = 15;
        });

    // ---- 2. shipment-commands: Postgres write + cascading events ----
    opts.ListenToRabbitQueue("shipment-commands")
        .UseDurableInbox()
        .PartitionProcessingByGroupId(PartitionSlots.Five)
        .MaximumParallelMessages(20)   // <= Npgsql Maximum Pool Size
        .PreFetchCount(40);

    // ---- 3. label-generation: 30-90s carrier API call ----
    opts.ListenToRabbitQueue("label-generation")
        .UseDurableInbox()
        .MaximumParallelMessages(50)
        .PreFetchCount(50)             // deliberately NOT the 100 default
        .CircuitBreaker(cb =>
        {
            cb.PauseTime = 2.Minutes();
            cb.FailurePercentageThreshold = 20;
        });

    opts.Policies.UseDurableOutboxOnAllSendingEndpoints();
});

Why each one

carrier-events. PartitionProcessingByGroupId hashes ShipmentId into 7 slots; within a slot, same-shipment scans run strictly sequentially, different groups run concurrently. That's how you get ordering without dropping to a single-threaded
listener. ProcessInParallelWithNativeAcks gives you buffered-mode throughput with inline-mode no-loss and a firehose you can't replay. Prefetch defaults to slot-count × 2 = 14 in that mode, which will starve you at thousands a minute, hence the explicit 200.

I deliberately did not put ListenerCount(N) on this one. Multiple Rabbit consumers on a partitioned queue is where ordering guarantees get murky; scale it with slots and MaximumParallelMessages instead.

shipment-commands. Durable inbox because these write to Postgres and publish follow-ons — you want the transactional outbox, and AutoApplyTransactions wraps the handler. Parallelism here is capped by your connection pool, not by Wolverine: setting MaximumParallelMessages above the Npgsql pool size just moves the queue from Rabbit into pool-wait. I partitioned this one too, because CancelShipment overtaking BookShipment for the same shipment is a real failure mode and you're paying for the grouping rule anyway.

label-generation. The counterintuitive one. Prefetch defaults to 2 × MaximumParallelMessages on durable endpoints = 100 messages held by one node, at 30-90s each — that node has hoarded up to two hours of work while your other pods sit idle.
Capping prefetch at the parallelism level is what actually raises cluster throughput. 50 is a starting [...] until the carrier API starts rate-limiting, then back off and let the circuit breaker handle the rest.

Two things that will undo all of this

Scaling out breaks carrier-events ordering. Partition slots are in-process. Run two pods against the same carrier-events queue and two scans for the same shipment can land on different pods simultaneously — no amount of listener config fixes
that. If you're already horizontally scaled, or plan to be, you need sharded queues [...]

opts.MessagePartitioning
    .ByMessage<ICarrierScan>(x => x.ShipmentId)
    .PublishToShardedRabbitQueues("carrier-events", 8, topology =>
    {
        topology.MessagesImplementing<ICarrierScan>();
        topology.MaxDegreeOfParallelism = PartitionSlots.Five;
    });

That's a change to whatever receives the carrier webhooks, not to this service. Worth knowing before you tune anything else — it's the difference between "ordered" and "ordered until we add a pod."

If the label client is sync-over-async, none of this helps. 50 concurrent .Result calls will eat the thread pool and starve the other two endpoints. Worth a grep before you deploy.

Version note: ProcessInParallelWithNativeAcks and WithInMemoryIdempotency need Wolverine 6.30+. On older versions, BufferedInMemory() + PartitionProcessingByGroupId gets you the same throughput with a small loss window on hard crash.

Sources: Listening Endpoints (https://wolverinefx.net/guide/messaging/listeners.html) · Partitioned Sequential Messaging (https://wolverinefx.net/guide/messaging/partitioning.html) · Rabbit MQ Performance Tuning (https://wolverinefx.net/guide/messaging/transports/rabbitmq/performance.html) · Rabbit MQ Listening (https://wolverinefx.net/guide/messaging/transports/rabbitmq/listening.html)

Okay, so Opus did a bit better than I was expecting (we do publish LLM friendly documentation for all the big tools), but it actually used a lot of settings that probably aren't necessary until proven and the code could have been simpler.

From /cost, we got this usage:

Session

Total cost:            $1.14
Total duration (API):  4m 19s
Total duration (wall): 10m 5s
Total code changes:    0 lines added, 0 lines removed
Usage by model:
    claude-haiku-4-5:  79.4k input, 3.9k output, 0 cache read, 0 cache write, 2 web search ($0.1191)
       claude-opus-5:  522 input, 15.9k output, 490.7k cache read, 37.1k cache write ($1.02)

Now let's move on and try this again with the skills completely installed.

Now with the AI Skills installed

After installing the JasperFx AI Skills, I tried the first prompt again and got this code:

csharp
using JasperFx;
using Wolverine;
using Wolverine.RabbitMQ;
using Wolverine.SqlServer;

var builder = Host.CreateApplicationBuilder(args);

builder.UseWolverine(opts =>
{
    opts.ServiceName = "ShipmentTracking";

    opts.UseRabbitMq(new Uri("amqp://localhost"))
        .UseConventionalRouting()
        .AutoProvision();

    opts.PersistMessagesWithSqlServer(
        builder.Configuration.GetConnectionString("SqlPersistence")!);
});

// Equivalent of EnableInstallers(): create queues, exchanges, and the
// envelope storage tables on startup.
builder.Services.AddResourceSetupOnStartup();

return await builder.Build().RunJasperFxCommandsAsync(args);

// Handler

public static class CarrierScanHandler
{
    public static ShipmentLocationUpdated Handle(CarrierScanRecorded message)
        => new(message.ShipmentId, message.Location);
}

This time Opus opted into the Critter Stack command line integration that's very important for all kinds of utilities, diagnostics, and deployment time usage with Wolverine and even EF Core or Polecat if we add that.

Now, here's the rest of the output from this prompt:

The returned message is a cascading message — Wolverine publishes it after the handler completes, inside the same outbox transaction. That removes the need for IMessageHandlerContext, makes the handler a synchronous pure function, and lets you unit test it with a plain assertion on the return value:

var updated = CarrierScanHandler.Handle(new CarrierScanRecorded("SHP-1", "Memphis"));
updated.ShipmentId.ShouldBe("SHP-1");

Things worth checking before you run this

Persistence provider. UsePersistence<SqlPersistence>() is SQL Server in the majority of NServiceBus installs, so I used PersistMessagesWithSqlServer. If that endpoint was actually pointed at PostgreSQL, swap to WolverineFx.PostgreSQL and opts.PersistMessagesWithPostgresql(...). The connection string also moves — NServiceBus took it from a SqlPersistence connection-string setting; Wolverine wants it passed in directly, so I pulled it from configuration.

Publish routing is not implicit. This is the one behavioral gap. NServiceBus derives the RabbitMQ topology from message type hierarchy, so context.Publish "just works". Wolverine requires you to say where messages go. UseConventionalRouting() gets you the closest equivalent — it creates an exchange named after the message type and binds a queue per listening endpoint. If you'd rather be explicit (and I'd recommend it for a migration, so the topology is reviewable):

opts.PublishMessage<ShipmentLocationUpdated>()
    .ToRabbitExchange("shipment-location-updated");

Note the naming differs from NServiceBus's convention (full type name as the exchange), so subscribers on the NServiceBus side won't bind to it as-is. If ShipmentTracking is being converted ahead of its subscribers, add .UseNServiceBusInterop() to the endpoint and opts.Policies.RegisterInteropMessageAssembly(typeof(ShipmentLocationUpdated).Assembly) so the wire format and type names stay compatible during the transition.

Recoverability. The original had no explicit Recoverability() block, so it was on NServiceBus defaults (5 immediate, 3 delayed). Wolverine's default is to move to the dead letter queue on the first failure, which is stricter. If you want the old behavior:

opts.OnException<Exception>()
    .RetryTimes(3)
    .Then.RetryWithCooldown(10.Seconds(), 30.Seconds(), 60.Seconds())
    .Then.MoveToErrorQueue();

Message types. If CarrierScanRecorded or ShipmentLocationUpdated are declared as interfaces (NServiceBus allows IEvent-derived interface messages), they must become concrete records before this compiles.

This time the agent called out a lot more concerns about the difference in behavior between NServiceBus and Wolverine with options to add resiliency or to opt into NServiceBus interoperability for an intermediate or incremental transformation.

Let's move on and run the 2nd prompt from up above:

markdown
This service has three RabbitMQ listening endpoints:

- carrier-events: a webhook firehose from carriers, thousands of messages a
  minute. Scans for the same shipment have to be processed in order.
- shipment-commands: BookShipment / CancelShipment. Each one writes to
  Postgres and publishes follow-on events.
- label-generation: calls the carrier's label API. Takes 30-90 seconds per
  label.

We're hitting a throughput ceiling. Configure the three listening endpoints
for maximum throughput.

Which first asks:

How many nodes does ShipmentTracking run on?

❯ 1. Multiple nodes (clustered)
     Scaled out horizontally — 2+ instances consuming from the same RabbitMQ queues. Per-shipment ordering then needs MessagePartitioning.GlobalPartitioned; a per-listener partitioning call would let two nodes process the same shipment concurrently.
  2. Single node
     One instance. Per-listener PartitionProcessingByGroupId is sufficient and correct for the carrier-events ordering guarantee.
  3. Single node now, clustering soon
     I'll write the global-partitioned form, which is correct in both cases, so scaling out later doesn't silently break the ordering guarantee.
  4. Type something.
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  5. Chat about this

Which is a very good question to ask here, because that will impact how you try to configure Wolverine! I'm going to choose 1. up above because that's more likely in real life -- but just know that there are startup optimizations for Solo hosting that the AI agent seems to know about here.

And, look at that, a second question:

Can the carrier scan handler tolerate processing the same scan twice?

❯ 1. Yes — it's idempotent
     Scans can be safely reapplied. Frees carrier-events to use NativeAck, which removes the per-message database writes that are most likely causing your ceiling.
  2. No — duplicates cause damage
     Double-processing a scan corrupts shipment state or double-publishes downstream events. Keeps carrier-events on the durable inbox, whose primary-key dedup is the only real protection — throughput comes from partition width instead.
  3. Not sure
     I'll write NativeAck plus an explicit idempotency guard on the handler, and flag exactly what to verify.
  4. Type something.
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  5. Chat about this

Idempotency! The AI agent with the skills knows to ask you about that and gather more information. I'm going to choose "Yes" and submit my answers.

And a little bit of churning:

markdown
⏺ Skill(wolverine-messaging-endpoint-modes)
Successfully loaded skill

Three endpoints with three different workload shapes — they need three different modes. Before I write the config, two things change the actual code:

⏺ Skill(wolverine-integrations-rabbitmq)
Successfully loaded skill

⏺ User answered Claude's questions:
· How many nodes does ShipmentTracking run on? → Multiple nodes (clustered)
· Can the carrier scan handler tolerate processing the same scan twice? → Yes — it's idempotent

Clustered plus idempotent scans — that settles carrier-events. Let me confirm the group-id API before I write it.

⏺ Skill(wolverine-messaging-message-routing)
Successfully loaded skill

────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 1 new message ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Here's the configuration. The three endpoints need three different modes — and one of them can't be tuned for throughput independent of the ordering constraint you named, which I'll come back to.

and we get some code:

csharp
using JasperFx;
using Wolverine;
using Wolverine.RabbitMQ;
using Wolverine.Postgresql;

var builder = Host.CreateApplicationBuilder(args);

builder.UseWolverine(opts =>
{
    opts.ServiceName = "ShipmentTracking";

    opts.UseRabbitMq(new Uri("amqp://localhost"))
        .UseQuorumQueues()
        .AutoProvision();

    opts.PersistMessagesWithPostgresql(
        builder.Configuration.GetConnectionString("Postgres")!);

    opts.Policies.AutoApplyTransactions();

    // ---------------------------------------------------------------
    // 1. carrier-events — firehose, ordered per shipment
    //    NativeAck: no inbox writes. Global partitioning: the ordering
    //    guarantee has to hold across nodes, not just within one.
    // ---------------------------------------------------------------
    opts.MessagePartitioning
        .ByMessage<CarrierScanRecorded>(x => x.ShipmentId);

    opts.MessagePartitioning
        .GlobalPartitioned(topology =>
        {
            topology.UseShardedRabbitQueues("carrier-events", 12);
            topology.ProcessInParallelWithNativeAcks();
            topology.Message<CarrierScanRecorded>();
        });

    // ---------------------------------------------------------------
    // 2. shipment-commands — Postgres write + follow-on events.
    //    Durable is mandatory: the outbox is what makes the write and
    //    the publish atomic. Throughput comes from width, not mode.
    // ---------------------------------------------------------------
    opts.ListenToRabbitQueue("shipment-commands")
        .UseDurableInbox(new BufferingLimits(500, 250))
        .MaximumParallelMessages(20)
        .ListenerCount(3)
        .PreFetchCount(200);

    // ---------------------------------------------------------------
    // 3. label-generation — 30-90s carrier API call per message.
    //    IO-bound, so parallelism is nearly free; durable because
    //    re-running a label call is expensive and probably billable.
    // ---------------------------------------------------------------
    opts.ListenToRabbitQueue("label-generation")
        .UseDurableInbox(new BufferingLimits(120, 60))
        .MaximumParallelMessages(60)
        .PreFetchCount(120);

    opts.OnException<HttpRequestException>()
        .RetryWithCooldown(5.Seconds(), 30.Seconds(), 2.Minutes())
        .Then.MoveToErrorQueue();
});

builder.Services.AddResourceSetupOnStartup();

return await builder.Build().RunJasperFxCommandsAsync(args);

And the agent's further output:

markdown
Why each one

carrier-events is the endpoint your ceiling is most likely coming from. Under a durable inbox every one of those thousands of messages a minute costs an inbox insert plus a mark-handled update, and there's no other database work in that handler to amortize it against — the persistence is pure overhead. NativeAck removes it entirely while keeping the no-loss guarantee, which is exactly the case it was built for. Since you confirmed the scan handler is idempotent, the at-least-once contract is acceptable.

The partitioning is the part worth being precise about. Because you're clustered, PartitionProcessingByGroupId on the listener would be wrong — it orders each node's own work and happily lets two nodes process the same shipment concurrently, which is the precise thing the requirement forbids, with no error to tell you. GlobalPartitioned gives you exclusive slot listeners spread across nodes with group→slot hashing, so a given shipment always lands on one slot on one node.

State the guarantee accurately when you brief the team on this: no two scans for the same shipment execute concurrently is the hard guarantee. Strict sequential processing in original order is best-effort — a failure, requeue, or redelivery can reorder within a shipment. If your scan handler assumes monotonically advancing scan timestamps, that assumption needs to survive an out-of-order pair.

shipment-commands stays durable and that isn't a tuning choice. The handler writes to Postgres and publishes follow-on events; the outbox is the only thing making those atomic. Drop to NativeAck and a crash between commit and publish silently loses the downstream events. The good news is the inbox cost is proportionally much smaller here, since the handler is already making a Postgres round trip. Get throughput from MaximumParallelMessages and ListenerCount instead.

label-generation is not a throughput problem in the usual sense — it's a concurrency arithmetic problem. At 90 seconds a label, each in-flight slot yields about 0.67 labels/minute, so your ceiling is roughly MaximumParallelMessages × 0.67. Sixty concurrent gets you ~40 labels/minute. Set the number from the rate you actually need, and note the threads are almost entirely idle awaiting HTTP, so a high number costs little.

Durable matters here for a different reason than on shipment-commands: the inbox dedups by primary key, and a duplicate label call likely means a duplicate label and a duplicate charge from the carrier. This is also why I'd keep it off NativeAck regardless of the throughput argument.

Things to check before this goes out

Where does the carrier firehose actually enter RabbitMQ? This is the one that could invalidate the carrier-events config. The sharded topology only works if the producer goes through Wolverine, because that's what assigns the group id and hashes it to a slot queue. If your webhook receiver is in this service and publishes CarrierScanRecorded via Wolverine, you're fine as written. If an external gateway writes straight into a single carrier-events queue, nothing will hash it, and you'll need a thin fan-in listener on that queue that re-publishes into the partitioned topology — one extra broker hop, still far cheaper than the inbox writes you're removing.

Pick the shard count generously now. Changing 12 later re-hashes every shipment across slots, and during that transition the ordering guarantee doesn't hold. Make it a comfortable multiple of your expected max node count.

Do BookShipment and CancelShipment need ordering too? You didn't say they did, so I left them unpartitioned. But a Cancel overtaking its Book for the same shipment is the kind of race that shows up rarely and expensively. If that's reachable in your system, move both types into the same GlobalPartitioned topology keyed on ShipmentId — though note they'd then have to give up the durable inbox, since that topology is native-ack, so you'd be trading the outbox for the ordering. That trade needs a real look rather than a quick config change.

Verify the modes you actually got rather than trusting the configuration — RabbitMQ queues default to Inline, and a SendInline() call on a shared send/listen endpoint can silently overwrite a listener's mode:

var runtime = host.Services.GetRequiredService<IWolverineRuntime>();
runtime.Endpoints.EndpointByName("label-generation").Mode
    .ShouldBe(EndpointMode.Durable);

RabbitMQ won't redeliver under your 90-second handler. Unlike SQS or Azure Service Bus, it runs no ack clock on an unsettled delivery, so the long label call is safe from the concurrent-duplicate failure mode. The one broker-side setting to confirm is consumer_timeout, whose default of 30 minutes leaves plenty of headroom — just make sure nobody has lowered it.

Since carrier-events is now at-least-once by construction, the wolverine-messaging-handler-idempotency skill is worth a pass to confirm the scan handler genuinely tolerates a repeat — you told me it does, and that's the assumption the whole carrier-events design rests on.

Clearly, Opus with the AI Skills is aware of far more issues around resiliency, concurrency, and scaling options.

Let's check the token usage again just for fun with /cost:

Session

Total cost:            $1.15
Total duration (API):  2m 46s
Total duration (wall): 13m 15s
Total code changes:    0 lines added, 0 lines removed
Usage by model:
    claude-haiku-4-5:  1.1k input, 22 output, 0 cache read, 0 cache write ($0.0012)
       claude-opus-5:  614 input, 12.0k output, 582.9k cache read, 55.3k cache write ($1.15)

Okay, admittedly, that's not really a difference in token cost, but the version with AI Skills did a fair amount more and got you to simpler code for your configuration.

Now do a big bang conversion!

The last thing I want to show off today is doing a full conversion from an NServiceBus application to Wolverine, then adding idiomatic integration testing against Wolverine, switching to Polecat for persistence, and just for fun, adding Aspire and CritterWatch in a buddy system for use at development time.

I used this big prompt -- but first let me make it crystal clear, there are no binary dependencies on NServiceBus in any repository on the JasperFx GitHub organization nor in anything we ship:

markdown
This is an NServiceBus application in [a local directory on my box]. Take it
through five changes, in order. Complete each phase and stop for review before
starting the next.

The converted service lands in ~/code/CritterStackSamples/ShipmentTracking. No
NServiceBus package reference or type may appear there.

PHASE 1 — Move it to Wolverine

Replace NServiceBus entirely. Keep behaviour identical: same messages, same
handler logic, RabbitMQ transport, same persistence guarantees.

The service handles three workloads with very different characteristics, all
sharing one endpoint and one concurrency setting today:

- RecordCarrierScan: a webhook firehose from carriers, thousands of messages a
  minute. Scans for the same shipment must be processed in order.
- BookShipment / CancelShipment: low volume. Each writes to the database and
  publishes follow-on events.
- GenerateLabel: calls the carrier's label API. Takes 30-90 seconds per label.

Configure the listening endpoints deliberately, and tell me why you chose what
you chose for each workload.

PHASE 2 — Move the HTTP endpoints to Wolverine.HTTP

The API is minimal API endpoints that inject IMessageBus and call it explicitly.
Move them to Wolverine.HTTP.

Keep every route, verb and status code exactly as they are — this is a change of
mechanism, not of contract. Preserve the OpenAPI metadata, and tell me anything
the move changes about what a client sees.

PHASE 3 — Move data access to Polecat

Replace the SQL persistence with Polecat on SQL Server 2025. Prefer declarative
persistence over injecting sessions into endpoints or handlers wherever they
allow it.

PHASE 4 — Add an integration test project

Cover every message handler and every HTTP endpoint. Real database, real
Wolverine, no mocks. No test may sleep to wait for anything.

PHASE 5 — Add Aspire and CritterWatch

Add a .NET Aspire AppHost orchestrating the service, its database and its
broker. Alongside it, stand up CritterWatch monitoring the service.

Conventions: match the layout of the existing samples in CritterStackSamples.

The end result is the various services rewritten to Wolverine and Wolverine.HTTP, using Polecat for persistence, simpler code all around by collapsing layers the way that Wolverine does, and full integration testing coverage because the Critter Stack is good at that and the AI Skills know how to utilize that. Just for fun, we also threw in Aspire hosting of all the projects as well as CritterWatch that itself is helpful for AI assisted development through all its visibility into your services and their activity.

More on the AI assisted development capabilities of CritterWatch very soon!

I had to make a few adjustments along the way after this prompting exercise because this is the real world:

  • Wolverine.HTTP doesn't (yet) have any special way to return an empty 202 status code, so I had to direct Opus to use Results.Accepted as a response type -- which Wolverine.HTTP does happily support
  • There was a weird result in the Saga conversion from NServiceBus I'm correcting before you read this 😃
  • And I made another correction to our Saga AI Skill to recommend using TimeoutMessage for simpler, cleaner code in your sagas
  • Opus found a small gap in Polecat functionality that it was missing compared to Marten related to database migrations, so there's an improvement opportunity for us! There was an easy workaround, though.
  • This turned up a gap for the very recently added [All] declarative persistence helper that just injects in all persisted values of a certain entity type. That's just syntactic sugar, but hey, Wolverine is all about the syntactic sugar and part of the point of the AI Skills is to help you write the cleanest, most performant code you can!
  • We've invested in a lot more command line diagnostics in Wolverine that helps explain just about everything you could possibly be doing inside your application including a quick preview of the source code that Wolverine generates to wrap your code -- which ends up being the singular best possible explanation of how all the applied middleware or conventions in Wolverine is applying to any given HTTP endpoint or message handler. In the course of doing this conversion, we reinforced the AI Skills to use this quick diagnostic capability instead of trying to read through raw Wolverine code. Lesson learned, and by the way, dogfooding your own tools turns out to be a great idea. Who knew?

You can now see the finished product of all of this exercise in our CritterStackSamples repository in the ShipmentTracking folder.

Getting the skills

The skills are curated and continuously improved -- see the announcement post for the background and the docs site for what's covered so far. Access is through a private NPM or NuGet feed, purchasable at jasperfx.net/our-products/#ai-skills.

And of course, see our support plans or get in touch. Questions always welcome in our Discord or on the product support tracker.

The Latest AI Skills Improvements

We had a little backlog of improvements we already wanted to make to the AI Skills today based on newer features or feedback from JasperFx clients. We also ended up doing quite a few improvements on the basis of the dogfooding we did today writing this post!

Everything below came from building and running a real Wolverine + Polecat + RabbitMQ + CritterWatch service with these skills, then reviewing the result. Nothing came from re-reading the skills. Three of the defects were shipped by the skills.

The service is ShipmentTracking in CritterStackSamples (PR #12, merged) — an NServiceBus app converted onto the Critter Stack in five phases.

Shipped: V1.9.0, V1.9.1, and V1.10.0 pending in PR #124. Touched: 38 existing skills edited, 1 new skill, 91 → 92.


1. The three that were actively misleading

DisableAllExternalWolverineTransports() voids an entire test suite, silently

wolverine-testing-integration recommended it as the default test host. It gives every external endpoint a NullSender — it does not reroute locally. For an application whose commands are routed ToRabbitQueue(...), no handler is ever invoked, and Sent still holds the record, so even a Sent assertion passes.

Sent: BookShipment -> rabbitmq://queue/shipment-commands
SHIPMENTS IN DB: 0

Twenty-nine tests were about to be written over a system doing nothing.

Fixed: decision table for which case you are in (grep for .ToRabbitQueue( / .ToTopic(), an anti-pattern entry, and the note that IncludeExternalTransports() is a single-host concern too — plus its flip side, that a session with it on hangs until timeout on a message sent to a queue nothing consumes.

A license-key lookup that could never succeed

wolverine-integrations-aspire and wolverine-integrations-critterwatch-setup both carried:

csharp
var licenseKey = builder.Configuration["JASPERFX__LICENSEKEY"];

Always null. .NET's environment-variable provider translates __ into :, so the value arrives as the key JasperFx:LicenseKey — which is what CritterWatch itself reads.

["JASPERFX__LICENSEKEY"] => NULL
["JASPERFX:LICENSEKEY"]  => the-real-key

The failure inverts the block's own purpose: it exists so license-gated operator actions work on monitored services, and instead it never ran. Only findable by building with the skill and then reviewing the result — reading it would not catch it, and neither would running the app.

[All] did not exist in any skill

wolverine-handlers-declarative-persistence covered [Entity] thoroughly and never mentioned its three siblings — so the skill's own advice pushed toward an injected IQuerySession for "give me every row," precisely the coupling it exists to remove. [All] was found by grepping Wolverine source for TryBuildAllFrame.

Fixed: [All], [FirstOrDefault] and [Queryable] documented with the constraints that bite — [All] takes IReadOnlyList<T> and nothing else, has no Required/OnMissing, is an unfiltered select * for small reference collections, and implements IBatchableFrame so it joins [Entity] in one round trip. [Queryable] framed as the sharp escape hatch: the type is portable, the LINQ is not. Plus a provider support matrix — CosmosDb implements [Queryable] only.


2. Corrections

SkillWas wrong
critterstack-arch-new-project-wolverine-polecatMissing WolverineFx.RuntimeCompilation and AddWolverineHttp() while its Marten twin covered both. Both are startup failures invisible to the compiler.
wolverine-integrations-critterwatch-setupVersion snapshot factually wrong — 1.0.1 pins WolverineFx 6.29.1, not 6.30.0. And enableClusterPartitioning documented as defaulting to true when every flavor defaults to false.
polecat-setup-and-decision-guideTaught db-apply as the schema command — a Marten copy-paste. Marten registers a Weasel IDatabaseSource; Polecat surfaces schema through ISystemPart, so the Weasel CLI finds nothing. The answer is resources setup. Also corrected "creates document and event tables on demand" — the event tables come from the resource model.
wolverine-handlers-query-plansTaught ICompiledQuery as a first-class option with no store caveat. 99 files in Marten, zero in Polecat and Fisher.
wolverine-integrations-aspirePolecat and Fisher absent from the persistence table; AddSqlServer not giving you SQL Server 2025; a project without launchSettings.json getting no endpoint; nothing provisioning an event store's schema when Aspire merely starts a host; and AspireUseCliBundle=true clearing the build warning while stopping the application from starting.
wolverine-testing-integrationMarten-only (no Polecat parity table); IAsyncLifetime examples used the xUnit v3 ValueTask shape; and InvokeMessageAndWaitAsync rethrows inline rather than recording on the session.
saga skills ×3NotFound missing entirely, and the TimeoutMessage exception backwards.

3. Saga NotFound, and the TimeoutMessage exception

Wolverine throws UnknownSagaException for a saga message whose saga cannot be loaded, unless the saga declares NotFound for that message type. The published guidance names a completed saga's timeout as the motivating example — and that is the one case Wolverine already handles.

codegen-preview settles it in one command:

csharp
// SagaChain: if (MessageType.CanBeCastTo<TimeoutMessage>())

// DeliverySlaExpired : TimeoutMessage
if (saga == null) { return; }

// LabelGenerated : plain record
if (saga == null) throw new UnknownSagaException(typeof(ShipmentDeliverySaga), sagaId);

A TimeoutMessage subclass is safe; a plain message scheduled with DelayedFor is not.

Added to wolverine-converting-from-nservicebus, wolverine-converting-from-masstransit and wolverine-handlers-efcore, with the audit question that matters:

Can a competing path publish this twice, or can slow work started before completion deliver it late?

That question found three reachable cases in one small saga — a label landing after cancellation, a duplicate delivery notification, and a second cancellation. Each was an UnknownSagaException and a dead letter in production.


4. "Preview the generated code" is now a standing directive

Per your instruction mid-session: an agent should never read Wolverine's source to learn what it generates.

The same callout now sits at the top of the six skills whose subject is resolved at code-generation time — declarative persistence, middleware, handler fundamentals, HTTP fundamentals, the Marten aggregate handler workflow, and message routing. It names the commands and says why the source is the wrong answer: slower, unavailable to a NuGet consumer, and describes every case instead of yours.

wolverine-observability-command-line-diagnostics became the authority, with a real annotated preview of a [Entity] + Storage.Update handler showing the six questions one command answers — including that MissingMessage uses an {Id} placeholder and that the write binds to the outboxed session.

It also carries a warning that describe-routing currently throws on any app using MessagePartitioning.GlobalPartitioned (wolverine#4132, filed from this work).


5. The Marten / Polecat asymmetry sweep

69 of 92 skills mention Marten; 36 of those never mentioned Polecat. Filtering the catalogue for polecat returned no projection skill at all, though the projection API is shared verbatim.

Ground truth was verified against the product source before anything was written, because claiming parity that does not exist is worse than the gap:

MartenPolecatFisher
SingleStreamProjection / MultiStreamProjection / EventProjectionyesyesyes
FlatTableProjection, composite / staged projectionsyesyesyes
IQueryPlan<T> / QueryListPlan<T>yesyesyes
Subscriptions, ancillary stores, multi-tenancy, aggregate attributesyesyesyes
ICompiledQueryyesnono

Eighteen shared-API skills gained one consistent callout — the substitution table (AddMartenAddPolecat/AddFisher, MartenOpsPolecatOps/FisherOps, [MartenStore][PolecatStore]/[FisherStore]), the note that IDocumentSession, IntegrateWithWolverine() and the aggregate attributes keep their names, and the warning that portability is API-level, not behavioural.

Each carries the one thing that actually differs on that page:

  • async-daemon-deep-dive — Fisher is Solo-only and refuses HotCold.
  • load-distribution — Fisher cannot distribute at all; it is a single-node store.
  • advanced-optimization — QuickAppend is Polecat's only append mode, so the Rich-vs-QuickAppend decision is Marten-specific.
  • projections-flat-table — all three have it; only the emitted DDL differs.
  • projections-event-enrichment — Marten and Polecat have EnrichEventsAsync; Fisher's source does not reference it, so the skill says verify, not assume.

Two skills are genuinely not portable and now say so loudly instead of silently: marten-advanced-indexes-and-query-optimization (JSONB, GIN and jsonb_path_ops are Postgres features, not Critter Stack features — port the intent, not the DDL) and wolverine-converting-from-eventstoredb (Marten is the destination it happens to use, worth stating for anyone leaving EventStoreDB precisely to stop running a server).

applies_to, tags and description updated on every portable skill so both catalogue filtering and retrieval find them.


6. New skill: Startup Failures a Compiler Cannot See

wolverine-troubleshooting-startup-failures — the 92nd skill, in PR #124.

Five of the day's defects were startup failures: a missing package, a missing registration, a missing endpoint, a missing schema step, a malformed URI. Every one compiled perfectly, and they were scattered across five different skills, so nothing told you they were a category.

Contents:

  • A two-minute gate before debugging anything: codegen testresources setupdescribe → start it.
  • A symptom → cause table keyed on the error text you would paste into a search, so the skill is usable from the failure rather than from the topic.
  • The loud onesWolverineFx.RuntimeCompilation, AddWolverineHttp(), and a connection string in another framework's shape.
  • The quiet ones, which are worse — an event store whose schema nothing provisions when the host is merely started, so it serves HTTP 200 and fails every message; RabbitMQ PRECONDITION_FAILED from mismatched queue arguments, which is order-dependent and so looks intermittent; and version skew inside one process.
  • Anti-patterns — treating a clean build as a smoke test, trusting a warning count from an incremental build, and debugging a startup failure by reading Wolverine's source.

7. Two issues filed against the products

  • polecat#501db-apply / db-assert / db-dump cannot see a Polecat store. Marten registers its tenancy as a Weasel IDatabaseSource; Polecat surfaces schema through ISystemPart instead. Suggested fix included.
  • wolverine#4132wolverine-diagnostics describe-routing throws NullReferenceException on any application using MessagePartitioning.GlobalPartitioned. MessageRoute.Describe() dereferences a Serializer that partition-slot routes never receive. Found while writing skill guidance that points people at that very command.

8. Three things worth changing about how we write skills

Say how much to trust yourself

The single most valuable line in any skill loaded all day was wolverine-integrations-critterwatch-setup saying of its own version numbers:

"treat these as a snapshot to verify, not as values to copy."

The snapshot was wrong, and that instruction is the only reason it did not become a TypeLoadException hunt. A skill that tells you how much to trust it beats one that is merely correct today. More skills should carry that.

Ship the command that resolves a value next to the value

Two version claims were wrong in one afternoon. dotnet list package --include-transitive settled both in seconds. Any quoted version, default or pin should sit beside the command that re-derives it.

Never send an agent into product source

Already actioned for codegen, but the principle is broader: for anything the application can be asked directly — what it generated, how it routed, what it resolved, what it provisioned — the skill should name the command. Source is slower, unavailable to a NuGet consumer, and describes every case instead of the reader's.


A coda on green

Three separate times, something reported success while being wrong:

  1. A test suite passed over a system that had done nothing.
  2. A build reported 0 Warning(s) from an incremental compile that skipped the test project — three real warnings hidden, and a merge nearly taken on the strength of it.
  3. A license block ran to completion having found nothing, in code whose own comment warned about exactly that outcome.

None were caught by the thing reporting green. Each was caught by asking a different question of a different tool — which is the whole argument for keeping a real broker and a real database in the loop while the skills are being written.

RSS Feed · All Rights Reserved.