Skip to content

The F5 Experience, Even with Rabbit MQ, AWS SQS, or Azure Service Bus

Jeremy Miller29th September 2026
WolverineMessagingRabbit MQAWS SQSAzure Service BusDockerAspireTestingDeveloper Experience
Wolverine

Resource configuration and test suite reliability are two of the first things JasperFx looks at when we're brought in to help a team with an existing system. If your new hires are spending their first day fighting infrastructure setup, we'd be happy to help.

This post is dedicated to a friend who suggested that you can't have an "F5 Experience" with a system that uses asynchronous messaging with messaging brokers. I'm happy to prove him wrong today by showing how Wolverine very happily provides a smooth experience for "clone'n go" development even with Rabbit MQ, AWS SQS, Azure Service Bus, and every single messaging broker that Wolverine supports.

Truth be told, all of Wolverine's major competitors do something similar, if not to the same extent. But at least we're significantly better with database provisioning!

To rewind, by "F5 Experience" I mean that a developer should be able to do a clean git clone of your repository, possibly run docker compose up -d once (or use Aspire), open the solution, hit F5 (or dotnet run, or dotnet test), and have the system or tests run. No wiki page of setup steps. No "ask Bob for the script he has on his laptop." No half day of getting the database into the right state before the very first test goes green.

Most of that last paragraph was admittedly written by AI, but I've lived that experience far too many times. Except it was multiple days of upgrading npm and manually running SQL scripts.

From the database side of things, Marten has delivered the F5 Experience for a decade with our Weasel library for "it should just work" database schema configuration. Weasel now sits underneath Wolverine, Polecat, and Fisher for all of their database configuration and that now works for EF Core too. Today, though, let's talk about asynchronous messaging for my friend who doubts that it can be usable locally!

The moment your system starts publishing messages through Rabbit MQ, Amazon SQS, or Azure Service Bus, there's a new category of infrastructure that has to exist before anything works. Exchanges. Queues. Bindings. Topics and subscriptions. Dead letter queues. Somebody has to create all of that, and in far too many shops "somebody" means a wiki page, a bash script that drifted from reality two years ago, or a Terraform module that only runs in CI. Or even worse, you have to use a shared broker set up by someone else (you hope) and all your integration tests are now untrustworthy because of the shared, stateful resource. Your integration tests can't run until the broker has the right objects in it, so nobody runs the integration tests locally, and the integration tests rot.

Wolverine's answer is that the application itself knows what messaging infrastructure it needs, so the application should be able to create it. That's what AutoProvision() does, and it's the reason a Wolverine system with a real message broker in the loop can still be git clone, docker compose up -d (or Aspire if you prefer), F5.

First, be friendly to Docker ​

None of this works without a broker to talk to, so let's start there. My strong opinion is that every external dependency of your system should be runnable locally through Docker, and your repository should carry a docker-compose.yml at the root that stands all of it up in one command. That file is the setup wiki page. It's versioned with the code, it can't drift the way prose does, and a new developer doesn't need to know anything about it beyond docker compose up -d.

In all seriousness, you can much more successfully incorporate Docker-friendly technical infrastructure into automated tests, and that gives you a vastly better approach to local testing than we had years ago when we were obsessed with being able to mock or stub every dependency in tests.

Here's the subset of Wolverine's own docker-compose.yml that covers PostgreSQL plus all three of the brokers in this post:

yaml
services:
  postgresql:
    image: "postgres:17"
    ports:
      - "5433:5432"
    environment:
      - POSTGRES_DATABASE=postgres
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres

  rabbitmq:
    image: "rabbitmq:4-management"
    ports:
      - "5672:5672"
      - "15672:15672"

  localstack:
    image: localstack/localstack:4
    ports:
      - "127.0.0.1:4566:4566"            # LocalStack Gateway
      - "127.0.0.1:4510-4559:4510-4559"  # external services port range
    environment:
      - DEBUG=${DEBUG-}
      - DOCKER_HOST=unix:///var/run/docker.sock
    volumes:
      - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"

  asb-sql:
    image: "mcr.microsoft.com/azure-sql-edge"
    environment:
      - "ACCEPT_EULA=Y"
      - "MSSQL_SA_PASSWORD=Strong_Passw0rd#2025"
    networks:
      sb-emulator:

  asb-emulator:
    image: "mcr.microsoft.com/azure-messaging/servicebus-emulator:2.0.1"
    volumes:
      - ./docker/asb/Config.json:/ServiceBus_Emulator/ConfigFiles/Config.json
    ports:
      - "5673:5672"
      - "5300:5300"
    environment:
      SQL_SERVER: asb-sql
      MSSQL_SA_PASSWORD: "Strong_Passw0rd#2025"
      ACCEPT_EULA: "Y"
      EMULATOR_HTTP_PORT: 5300
    depends_on:
      - asb-sql
    networks:
      sb-emulator:

We're not here to besmirch Aspire, but maybe don't pay attention to Microsoft folks trying to say that Docker Compose is unusable because "YAML is too scary!" Most of what you (or your AI agent) will actually do is copy/paste anyway.

A few notes on that:

  • Rabbit MQ is the easy one. The official image just works, and the -management tag gives you the management UI on port 15672, which is genuinely useful when you're trying to understand what Wolverine set up for you.
  • Amazon SQS runs locally through LocalStack, which is a faithful enough SQS (and SNS) that Wolverine's entire AWS test suite runs against it.
  • Azure Service Bus was the holdout for years. Microsoft's Azure Service Bus emulator finally gained support for the management API, which is what Wolverine needs to create queues and topics, and since then Wolverine's whole Azure Service Bus test suite runs against the emulator in Docker too. The emulator needs a SQL Server-ish backend (Azure SQL Edge here) and a tiny Config.json. The one Wolverine uses is about as minimal as it gets, because Wolverine is going to create the queues itself:
json
{
  "UserConfig": {
    "Namespaces": [
      {
        "Name": "sbemulatorns"
      }
    ],
    "Logging": {
      "Type": "File"
    }
  }
}

I wrote up the emulator setup in more detail in the Wolverine docs, including the one gotcha that the emulator uses separate ports for messaging and management while real Azure Service Bus does not.

Let me at least call out, though, that the Azure Service Bus emulator has improved enough that I think it's genuinely usable now.

So that's step one, and it's the same for every developer on the team, on every operating system:

bash
git clone https://github.com/your-org/your-system.git
cd your-system
docker compose up -d

Now the broker exists. It's completely empty, and that's fine.

AutoProvision(): let the application build its own messaging infrastructure ​

Wolverine already knows every queue, exchange, topic, and subscription your application needs because you told it so in the configuration. There's no reason to make a human repeat that information to the broker by hand. Opting into AutoProvision() on any of the broker transports tells Wolverine to check for and create any missing objects when it needs them.

With Rabbit MQ:

csharp
using var host = await Host.CreateDefaultBuilder()
    .UseWolverine(opts =>
    {
        opts.UseRabbitMq(rabbit => { rabbit.HostName = "localhost"; })
            // I'm declaring an exchange, a queue, and the binding
            // key that we're referencing below.
            // This is NOT MANDATORY, but rather just allows Wolverine to
            // control the Rabbit MQ object lifecycle
            .DeclareExchange("exchange1", ex => { ex.BindQueue("queue1", "key1"); })

            // Lets Wolverine create any missing declared Rabbit MQ objects
            // on demand when it needs them
            .AutoProvision();

        opts.PublishAllMessages().ToRabbitExchange("exchange1");
    }).StartAsync();

With Amazon SQS, where the UseAmazonSqsTransportLocally() helper points Wolverine at LocalStack on its default port:

csharp
using var host = await Host.CreateDefaultBuilder()
    .UseWolverine(opts =>
    {
        // Connect to an SQS broker running locally
        // through LocalStack
        opts.UseAmazonSqsTransportLocally()

            // Let Wolverine create missing queues as necessary
            .AutoProvision();

        opts.ListenToSqsQueue("orders");
        opts.PublishAllMessages().ToSqsQueue("orders");
    }).StartAsync();

And with Azure Service Bus against the emulator, where UseAzureServiceBusEmulator() returns the same configuration object as UseAzureServiceBus(), so everything else chains off of it exactly as it would against a real namespace:

csharp
var builder = Host.CreateApplicationBuilder();
builder.UseWolverine(opts =>
{
    // Connect to a locally running Azure Service Bus emulator using the
    // standard emulator ports (AMQP on 5672, management on 5300)
    opts.UseAzureServiceBusEmulator()

        // The emulator starts out empty, so let Wolverine build
        // any queues, topics, or subscriptions it needs
        .AutoProvision()
        .AutoPurgeOnStartup();

    opts.ListenToAzureServiceBusQueue("my-queue");
    opts.PublishAllMessages().ToAzureServiceBusQueue("my-queue");
});

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

That's it. Start the application, and the exchanges, queues, bindings, topics, and subscriptions appear in the broker. Change the configuration to add a new listener and restart, and the new queue appears. It's exactly the same mental model as Marten and Weasel applying schema changes at startup, just pointed at a message broker instead of a database. And yes, this covers the queues Wolverine discovers on its own through conventional routing, not just the ones you declared explicitly.

A quick word on semantics, because it matters later in this post. AutoProvision() is on demand. Each endpoint creates its own objects as it initializes, which for a listener is at startup and for a sender is on first use. If you'd rather have every known resource built eagerly before the application does anything else, that's what the stateful resource model below is for, and the two work fine together.

AutoPurgeOnStartup() for tests ​

Meh, this works very well for some of the brokers and less than perfectly for others. Rabbit MQ, in my opinion, is the all-time champ for local development friendliness here.

The natural companion is AutoPurgeOnStartup(), which tells Wolverine to purge any existing messages out of the queues it knows about when the application starts. You very much do not want that in production, but in a test suite it's the difference between "tests start from a known empty state" and "test #14 failed because of a message test #9 left behind yesterday." Wolverine's own transport tests use exactly this pair on every broker:

csharp
opts.UseRabbitMq().AutoProvision().AutoPurgeOnStartup();

The Rabbit MQ transport also lets you purge selectively, if you'd rather not purge everything:

csharp
using var host = await Host.CreateDefaultBuilder()
    .UseWolverine(opts =>
    {
        opts.UseRabbitMq()
            .DeclareQueue("queue1")
            .DeclareQueue("queue2", q => q.PurgeOnStartup = true);
    }).StartAsync();

Integration tests that go through the real broker ​

The payoff of all of this is that your integration tests can go through the actual broker, on a fresh clone, with no setup step, and still be reliable. Here's a lightly trimmed test from Wolverine's SQS suite. It bootstraps a host against LocalStack, lets Wolverine provision and purge the queue, then sends a message all the way through SQS and back:

csharp
public class send_and_receive : IAsyncLifetime
{
    private IHost _host = null!;

    public async ValueTask InitializeAsync()
    {
        _host = await Host.CreateDefaultBuilder()
            .UseWolverine(opts =>
            {
                opts.UseAmazonSqsTransportLocally()
                    .AutoProvision().AutoPurgeOnStartup();

                opts.ListenToSqsQueue("send_and_receive");

                opts.PublishAllMessages().ToSqsQueue("send_and_receive");
            }).StartAsync();
    }

    public async ValueTask DisposeAsync()
    {
        await _host.StopAsync();
        _host.Dispose();
    }

    [Fact]
    public async Task send_and_receive_a_single_message()
    {
        var message = new SqsMessage("Josh Allen");

        var session = await _host.TrackActivity()
            .IncludeExternalTransports()
            .Timeout(5.Minutes())
            .SendMessageAndWaitAsync(message);

        session.Received.SingleMessage<SqsMessage>()
            .Name.ShouldBe(message.Name);
    }
}

Two things to notice. First, there's no arrange step that creates the queue, because AutoProvision() already did. Second, the tracked session uses IncludeExternalTransports() so that Wolverine waits for the message to make the round trip through SQS rather than only tracking in-process activity. Swap UseAmazonSqsTransportLocally() for UseRabbitMq() or UseAzureServiceBusEmulator() and the test is otherwise identical. The Azure Service Bus docs have a complete version of this test against the emulator.

If your tests also need Wolverine's durable inbox and outbox tables reset between runs, there's IHost.ClearAllWolverineStorageAsync() for that. And for the Marten side of the house, this all sits happily next to Marten's own integration testing recipe with ResetAllData() and IInitialData.

The same thing with Aspire ​

If your shop has gone the .NET Aspire route instead of a hand-written docker-compose.yml, nothing changes on the Wolverine side. Wolverine has a small Aspire + Rabbit MQ sample whose AppHost is this:

csharp
var builder = DistributedApplication.CreateBuilder(args);

// Provision a RabbitMQ container. The name "rabbitmq" becomes the
// connection string key injected into all referenced projects as
// ConnectionStrings__rabbitmq (or ConnectionStrings:rabbitmq in config).
var rabbitmq = builder.AddRabbitMQ("rabbitmq")
    // Expose the RabbitMQ management UI at http://localhost:15672
    .WithManagementPlugin();

builder.AddProject<Projects.Ponger>("ponger")
    .WithReference(rabbitmq)
    // WaitFor ensures Ponger does not start until RabbitMQ is healthy,
    // which means Wolverine's AutoProvision() will reliably succeed.
    .WaitFor(rabbitmq);

builder.AddProject<Projects.Pinger>("pinger")
    .WithReference(rabbitmq)
    .WaitFor(rabbitmq);

await builder.Build().RunAsync();

And the service just picks up the connection string Aspire injected:

csharp
var builder = Host.CreateApplicationBuilder(args);

builder.UseWolverine(opts =>
{
    // UseRabbitMqUsingNamedConnection reads the "rabbitmq" connection string from
    // IConfiguration. .NET Aspire injects this automatically via the WithReference()
    // call in the AppHost when you run AddRabbitMQ("rabbitmq").
    opts.UseRabbitMqUsingNamedConnection("rabbitmq")
        // AutoProvision directs Wolverine to create any missing exchanges, queues,
        // or bindings at startup. Combined with Aspire's WaitFor(), the RabbitMQ
        // broker is guaranteed to be healthy before this runs.
        .AutoProvision()
        .DeclareExchange("pings", ex =>
        {
            // Declare the exchange and bind the queue so Wolverine auto-creates
            // both at startup via AutoProvision()
            ex.BindQueue("pings");
        });

    // Listen for pong replies coming back from the Ponger service
    opts.ListenToRabbitQueue("pongs");

    // Publish PingMessage to the "pings" exchange
    opts.PublishMessage<PingMessage>().ToRabbitExchange("pings");
});

builder.Services.AddHostedService<PingerService>();

await builder.Build().RunAsync();

The one thing that matters is the .WaitFor(rabbitmq), so that Aspire doesn't start your service until the broker's health check passes and Wolverine can actually connect when it goes to declare things. The Rabbit MQ docs say the same for Aspire, and the Azure Service Bus page has the equivalent wiring for a real namespace with DefaultAzureCredential.

The stateful resource model ​

AutoProvision() is the on-demand, "just make it work" knob. Underneath it is a more general idea that I've been chasing since the Oakton days in 2022 and that I wrote up for the Critter Stack in 2024: the stateful resource model in the shared JasperFx library.

The idea is that anything external and stateful that your application depends on, a database schema, a set of Rabbit MQ exchanges and queues, a set of SQS queues, a set of Azure Service Bus topics and subscriptions, can be described by one small interface:

csharp
public interface IStatefulResource
{
    string Type { get; }
    string Name { get; }
    Uri SubjectUri { get; }
    Uri ResourceUri { get; }

    // Check whether the configuration for this resource is valid.
    // An exception should be thrown if the check is invalid
    Task Check(CancellationToken token);

    // Clear any persisted state within this resource
    Task ClearState(CancellationToken token);

    // Tear down the stateful resource represented by this implementation
    Task Teardown(CancellationToken token);

    // Make any necessary configuration to this stateful resource
    // to make the system function correctly
    Task Setup(CancellationToken token);

    // Optionally return a report of the current state of this resource
    Task<IRenderable> DetermineStatus(CancellationToken token);
}

Marten exposes its database schema as a stateful resource. Wolverine exposes its durable inbox and outbox storage as one, and every one of its broker transports exposes one as well, so the Rabbit MQ, SQS, and Azure Service Bus objects your application uses are all Setup()-able, Check()-able, ClearState()-able, and Teardown()-able through the same abstraction. That gives you two more tools on top of AutoProvision().

Eager setup at startup ​

If you'd rather build everything up front rather than on demand, register the resource setup hosted service. This is from Wolverine's kitchen sink sample, which is Marten plus Rabbit MQ in one web application:

csharp
var builder = WebApplication.CreateBuilder(args);

builder.Host.ApplyJasperFxExtensions();

builder.Host.UseWolverine(opts =>
{
    opts.PublishAllMessages()
        .ToRabbitExchange("issue_events", exchange => exchange.BindQueue("issue_events"))
        .UseDurableOutbox();

    opts.ListenToRabbitQueue("issue_events").UseDurableInbox();

    opts.UseRabbitMq(factory =>
    {
        factory.HostName = "localhost";
        factory.Port = 5672;
    })

    // Use for on-demand Rabbit MQ provisioning in addition to bootstrap time below
    .AutoProvision();
});

// Registers an IHostedService that sets up every known JasperFx resource
// at startup, including Marten/Wolverine database objects and Rabbit MQ objects
builder.Services.AddResourceSetupOnStartup();

builder.Services.AddMarten(opts =>
{
    opts.Connection(Servers.PostgresConnectionString);
    opts.DatabaseSchemaName = "issues";
    opts.RegisterDocumentType<Issue>();
}).IntegrateWithWolverine(x => x.MessageStorageSchemaName = "issue_service");

var app = builder.Build();

app.MapGet("/", () => "Hello World!");

// Actually important to return the exit code here!
return await app.RunJasperFxCommands(args);

AddResourceSetupOnStartup() runs Setup() on every known resource before any other hosted service starts, so the Marten tables, the Wolverine envelope tables, and the Rabbit MQ objects all exist before the first message moves. For test harnesses there's also AddResourceSetupOnStartup(StartupAction.ResetState), which does Setup() and then ClearState() so every test host starts from a clean, known state. If you'd prefer to keep that out of production entirely, UseResourceSetupOnStartupInDevelopment() on the host builder only registers it when the environment name is Development.

dotnet run -- resources ​

The other thing the stateful resource model gives you is a command line. Because the sample above ends with RunJasperFxCommands(args), the same application can be asked to manage its own infrastructure:

bash
dotnet run -- resources list         # what stateful resources does this app know about?
dotnet run -- resources check        # are they all in place and reachable?
dotnet run -- resources setup        # create or update all of them
dotnet run -- resources statistics   # queue depths, table counts, etc.
dotnet run -- resources clear        # purge queues and clear message storage, keep the structure
dotnet run -- resources teardown     # delete all of it

Every action takes --type and --name flags to narrow it to, say, only the Wolverine broker resources, and a --timeout in seconds. resources check uses Check() on each resource, so for a broker transport it connects and confirms every queue, exchange, topic, and subscription the application expects actually exists, and it fails with the list of what's missing if not. resources setup is the one I want to draw your attention to for deployment.

My recommended production recipe is the opposite of local development: disable the automatic migrations and provisioning at startup, and run resources setup as an explicit deployment step. That's how Wolverine's durability management docs describe it. An explicit resources setup is treated as intent to provision, so it always applies the message storage migration as CreateOrUpdate (which never drops data) even when the automatic paths are turned off. And as of Wolverine 6.30 a broker object that can't be provisioned fails the sweep instead of logging and moving on, so a deploy step can't exit 0 having created nothing. If you do run with AutoProvision() in production, remember that creating SQS queues or Azure Service Bus topics requires permissions your production identity may not have. The SQS docs are frank about that: it's common to enable AutoProvision() only in development and test environments and let infrastructure-as-code own production. If some of your queues are owned by another team altogether, mark them ExternallyOwned() and Wolverine will neither declare nor tear them down.

If you're on Aspire, the JasperFx.Aspire integration exposes the same command as an "Apply resources" button on the dashboard, and you can register it as a startup gate so the queues and inbox/outbox tables exist before the service takes its first message:

csharp
builder.AddProject<Projects.Api>("api")
    .WithReference(messaging)
    .WithReference(db)
    .WaitFor(messaging)
    .WaitFor(db)
    .WithJasperFxStartup(c =>
    {
        c.Run("resources", "setup"); // provision transports + inbox/outbox before start
        c.Run("codegen", "write");   // pre-generate handlers — no first-request codegen
    });

Environment checks: is everything actually there? ​

The last piece is the one that catches the "it works on my machine" class of problem. The JasperFx command line has an environment check facility:

bash
dotnet run -- check-env

That runs every registered check and exits non-zero if any of them fail. What's useful here is that the stateful resources participate automatically. check-env calls Check() on every resource the application exposes, so out of the box it answers:

  • Can the application connect to its configured database, and is the Wolverine message storage there?
  • Can the application connect to its configured Rabbit MQ, Amazon SQS, or Azure Service Bus broker, and does every queue, exchange, topic, and subscription it expects actually exist?
  • Are the IoC container registrations valid?

You can add your own checks alongside those with a one-liner:

csharp
services.CheckEnvironment(
    "Database is reachable",
    async (IServiceProvider sp, CancellationToken ct) =>
    {
        // Throw an exception to indicate failure
        await Task.CompletedTask;
    });

And any IHealthCheck you've already registered for ASP.NET Core's /health endpoint is picked up by check-env as well, so there's no need to write the same check twice. If you want the checks to run at startup and refuse to boot a misconfigured application, there's:

bash
dotnet run -- run --check

Where this shines is in a deployment pipeline. Run resources setup, then check-env, and you've verified that the infrastructure the new build expects is really in place before you cut traffic over to it. And for the new developer on day one, check-env is a much friendlier way to find out that Docker isn't running than a stack trace from somewhere deep in a Rabbit MQ client.

Putting it together ​

For local development and integration testing, the whole recipe is:

  1. Commit a docker-compose.yml that stands up every external dependency, brokers included. Rabbit MQ, LocalStack, and the Azure Service Bus emulator all run fine in Docker now.
  2. Use AutoProvision() on the transport (and AutoPurgeOnStartup() in test hosts), or AddResourceSetupOnStartup() if you'd rather everything be built eagerly. Or both.
  3. git clone, docker compose up -d, F5.

For deployment:

  1. Turn the automatic knobs off.
  2. dotnet run -- resources setup as a deployment step.
  3. dotnet run -- check-env before you take traffic.

That the same application can do all of that, against all three brokers, with the same commands, is not an accident. It's the point of having a shared stateful resource model instead of a one-off "create the queues" script per project. The messaging infrastructure stops being a thing that lives outside your codebase in someone's head or someone's wiki, and becomes just another part of the application that the application knows how to make true.

Wolverine's Rabbit MQ, SQS, and Azure Service Bus object management docs have the full set of options for each transport, and the command line diagnostics tutorial walks through the resources and check-env commands alongside the rest of Wolverine's diagnostics. Questions and war stories are welcome in the Critter Stack Discord.

RSS Feed · All Rights Reserved.