
A recurring theme in our consulting work at JasperFx is that the real world is far more complex and varied than any of us realize. For example, it's actually not uncommon to want a single application to communicate with multiple messaging brokers, and sometimes even multiple brokers of completely different technologies all in one single application. Fortunately, Wolverine has you covered!
One Application, Many Brokers
Almost nobody gets to design a greenfield system on a single, uniform message broker and keep it that way. What actually happens is some version of this:
- You run Rabbit MQ internally, but the partner integration your sales team just signed lands on Azure Service Bus.
- Your analytics team wants an event firehose, and that means Kafka.
- The devices in the field speak MQTT, because that's what constrained devices on flaky networks speak.
- And there's a legacy system on a broker you'd very much like to migrate off of, but not this quarter.
The conventional .NET answer to this is to deploy more processes. A bridge here, a relay service there, a "connector" app whose entire job is to read from one broker and write to another. Each one is a thing to deploy, monitor, secure, and page someone about at 3am.
Wolverine's position is that this is a configuration problem, not a deployment problem.
Many transports, one application
A Wolverine application registers as many transports as it likes, side by side, and routes message types to whichever one fits. There is no primary transport, no bridge process, and no ceremony:
builder.UseWolverine(opts =>
{
// Internal work over Rabbit MQ
opts.UseRabbitMq(rabbitConnectionString);
// Partner integration over Azure Service Bus
opts.UseAzureServiceBus(asbConnectionString);
// Analytics firehose to Kafka
opts.UseKafka(kafkaBootstrapServers);
// Field devices over MQTT
opts.UseMqtt(mqtt => mqtt.WithClientOptions(c => c.WithTcpServer("mqtt-broker")));
// Now just route message types wherever they belong
opts.PublishMessage<AllocateInventory>().ToRabbitQueue("inventory");
opts.PublishMessage<PartnerShipmentReady>().ToAzureServiceBusQueue("partner-out");
opts.PublishMessage<OrderPlaced>().ToKafkaTopic("orders");
opts.ListenToRabbitQueue("incoming");
opts.ListenToAzureServiceBusQueue("partner-in");
opts.ListenToMqttTopic("devices/+/telemetry");
});That is the whole "bridge." A message arrives on Azure Service Bus, gets handled by an ordinary Wolverine handler, and the cascaded messages that handler returns go out over Rabbit MQ and Kafka — inside one transactional outbox, in one process, with one set of logs and metrics. Wolverine currently ships transports for Rabbit MQ, Azure Service Bus, Kafka, MQTT, NATS, Pulsar, Redis, Amazon SQS, Amazon SNS, GCP Pub/Sub, SignalR (!), plus database-backed queues on PostgreSQL, SQL Server, MySQL, and SQLite — and they all coexist happily.
Two brokers of the same type
Mixing broker technologies is only half the problem. The other half is two Rabbit MQ clusters, or your namespace and a partner's namespace, or the old cluster and the new one during a migration. Wolverine handles that with named brokers:
opts.UseRabbitMq(internalConnection);
// A second, entirely independent Rabbit MQ broker
opts.AddNamedRabbitMqBroker(new BrokerName("partner"), f => f.Uri = partnerUri);
opts.PublishMessage<NotifyPartner>()
.ToRabbitQueueOnNamedBroker(new BrokerName("partner"), "notifications");
opts.ListenToRabbitQueueOnNamedBroker(new BrokerName("partner"), "inbound");Every endpoint on a named broker gets its own Uri scheme — partner://queue/inbound instead of rabbitmq://queue/inbound — so the two brokers' endpoints can't collide even when the queue names are identical. Named brokers are available across the transports in the same shape: AddNamedAzureServiceBusBroker, AddNamedKafkaBroker, AddNamedMqttBroker, AddNamedNatsBroker, AddNamedAmazonSqsBroker, and so on. See the multiple brokers documentation for details.
Named brokers are static topology, though. You pin a specific endpoint to a specific broker at configuration time. That's exactly right for a partner integration or a migration. It's exactly wrong for multi-tenancy, where you don't know at configuration time which broker a given message belongs on.
Broker per Tenant
Here's where it gets interesting, and where we think Wolverine is genuinely unique in the .NET space.
Most multi-tenancy stories in .NET messaging are persistence-first: you read a tenant_id header, and you swap the database connection string. The broker stays shared. Every tenant's traffic flows through the same queues, and isolation is a matter of everyone's code being careful forever.
That's a hard sell in a few industries, and it's a very hard sell in IoT. If you're running a cloud service that talks to equipment at your customers' physical sites, "we're careful about the header" is not the isolation story your customer's security review is looking for. They want their devices talking to their broker.
Wolverine's answer is broker per tenant: you declare one logical topology, register a connection per tenant, and Wolverine decides which physical connection to use at runtime based on the message's tenant id.
An MQTT example
MQTT is the natural place to start, because it's the natural place IoT starts. Each tenant gets its own MQTT broker — often literally a broker sitting at that customer's site — while your application code sees one topic topology:
opts.UseMqtt(mqtt => mqtt.WithClientOptions(c => c.WithTcpServer("shared-broker")))
// What should Wolverine do with a message whose TenantId is null or unknown?
// FallbackToDefault (the default) uses the shared connection;
// TenantIdRequired throws; IgnoreUnknownTenants silently drops it.
.TenantIdBehavior(TenantedIdBehavior.FallbackToDefault)
// Each tenant gets its OWN dedicated MQTT connection
.AddTenant("acme-west",
mqtt => mqtt.WithClientOptions(c => c.WithTcpServer("west-broker")))
.AddTenant("acme-east",
mqtt => mqtt.WithClientOptions(c => c.WithTcpServer("east-broker")));
// One shared topology. Declared once. Fans out across every tenant connection.
opts.PublishMessage<FirmwareUpdateRequested>().ToMqttTopic("commands");
opts.ListenToMqttTopic("telemetry");Read that last pair of lines carefully, because it's the whole point. You declared one listener. At runtime Wolverine builds a compound listener that runs a subscription on every tenant's connection, and stamps each inbound envelope with the tenant id of the connection it arrived on. The tenant isn't parsed out of a payload or trusted from a header — it's inferred from physical provenance. A message that came in on the acme-west broker is, by construction, an acme-west message.
Sending is the mirror image. Stamp the tenant on the send and Wolverine's TenantedSender dispatches to the right connection:
await bus.SendAsync(
new FirmwareUpdateRequested(deviceId, version),
new DeliveryOptions { TenantId = "acme-west" });One MQTT-specific detail worth calling out, because it will bite anyone hand-rolling this: MQTT brokers forcibly disconnect a second connection that shares a ClientId. Wolverine always derives a unique ClientId per tenant connection (<clientId>-tenant-<tenantId>), even if you pre-set one, so tenant connections can never kick each other off. That's the kind of thing you discover in production at 2am when you build it yourself.
The same thing with Azure Service Bus
Now swap in the cloud side of the same system. Azure Service Bus tenants get their own fully qualified namespace — the strongest isolation boundary ASB offers — and the configuration shape is identical:
opts.UseAzureServiceBus(defaultConnectionString)
.TenantIdBehavior(TenantedIdBehavior.TenantIdRequired)
// A separate ASB namespace per tenant...
.AddTenantByNamespace("acme-west", config.GetValue<string>("asb_ns_west")!)
.AddTenantByNamespace("acme-east", config.GetValue<string>("asb_ns_east")!)
// ...or a wholly separate connection string, credentials and all
.AddTenantByConnectionString("globex", config.GetConnectionString("asb_globex")!);
// Listens to "incoming" on the default namespace AND on every tenant namespace
opts.ListenToAzureServiceBusQueue("incoming");
// And when something genuinely *is* cross-tenant, say so explicitly
opts.ListenToAzureServiceBusQueue("control-plane").GlobalListener();
opts.PublishMessage<SystemWideAnnouncement>()
.ToAzureServiceBusTopic("announcements").GlobalSender();Note GlobalListener() and GlobalSender(). Not everything in a multi-tenant system is tenant-scoped — control plane traffic, billing rollups, platform-wide announcements — and Wolverine lets you opt specific endpoints out of tenancy rather than forcing you into an all-or-nothing model.
This isn't an MQTT and ASB special case, either. Broker-per-tenant is implemented across ten transports today: Rabbit MQ (virtual hosts or separate brokers), Azure Service Bus, MQTT, Kafka, NATS, Pulsar, Redis, Amazon SQS, Amazon SNS, and GCP Pub/Sub. Each maps the tenant onto whatever that broker's natural isolation boundary happens to be.
Where it actually pays off: the tenant id doesn't stop at the broker
Everything above would be a nice trick and not much more if the tenant id evaporated the moment the message hit your handler. It doesn't. TenantId is first-class envelope metadata in Wolverine, and it:
- Is stamped automatically on inbound messages from the tenant connection they arrived on.
- Propagates automatically to every cascaded and outgoing message from that handler — no re-stamping.
- Flows into the transactional inbox and outbox, so the durable messaging bookkeeping is tenant-scoped too.
- Flows into the storage layer — Marten, EF Core, PostgreSQL, SQL Server — so the same id that picked the broker also picks the database or schema for the unit of work.
Which means a handler looks like this, and does the right thing:
public static async Task Handle(
DeviceTelemetryReceived message,
TenantId tenantId, // just ask for it
IDocumentSession session) // already scoped to that tenant's database
{
session.Store(new TelemetryReading(message));
// This outgoing message inherits the tenant id automatically
await session.SaveChangesAsync();
}A telemetry reading lands on Acme West's on-site MQTT broker. Wolverine stamps it acme-west. The handler writes to Acme West's database. The alert it cascades goes out over Acme West's Azure Service Bus namespace. Nobody wrote a line of routing code, and there is no code path where a mistake leaks one tenant's data into another's infrastructure — because the isolation is physical, not conditional.
That end-to-end continuity is the part we'd point at if you only gave us one slide.
How this compares
We get asked this constantly, so let's be specific and fair about it.
NServiceBus
To be transparent, we wrote this post after seeing an email from Particular Software promoting their recent improvements for addressing multiple messaging brokers in a single process and we couldn't resist the temptation to compare it to Wolverine's more expansive support
NServiceBus is a mature, well-supported product and this is not a criticism of its engineering. But its model draws the boundaries in a different place.
An NServiceBus endpoint is configured with a single transport. When you need to span two brokers, the documented answer is the Messaging Bridge — a separate component that you deploy and run as its own process, whose stated purpose is to let "NServiceBus endpoints connect to other endpoints that are not using the same transport." It handles transport migration and cost-driven transport mixing well. It is also, unavoidably, another process in your topology. Wolverine does the same job in-process, as configuration.
On tenancy, NServiceBus's multi-tenancy story is persistence-first: resolve the tenant from a header, swap the database connection. The transport stays shared. Broker-per-tenant isn't a built-in feature — when a user asked for a queue per tenant on Particular's own forum, the answer was to build the routing themselves.
NServiceBus 10.2's new multiple endpoint hosting does let you co-host many isolated endpoints in one process, and Particular explicitly names high-density multi-tenancy as a motivating scenario — so this gap is narrowing. But it gives you hosting slots; the per-tenant transport wiring, routing, and tenant-id plumbing are still yours to assemble, and Particular's own post cautions that "most systems should continue using one host, one container, and one endpoint."
The short version: in NServiceBus, one tenant is an endpoint you configure. In Wolverine, one tenant is AddTenant("acme-west", ...).
MassTransit
MassTransit does have a real multi-transport story, and it deserves credit for it. MultiBus lets one application host several bus instances, each with its own transport and its own host configuration, addressed through marker interfaces:
public interface ISecondBus : IBus { }You then inject IBus or ISecondBus depending on which broker you want. That genuinely solves "talk to two brokers from one app," and it does it without a sidecar process.
The difference is when the decision gets made and what stays unified. MultiBus is static binding — the broker is chosen by which interface you took a dependency on, at the injection point, at compile time. It's much closer to Wolverine's named brokers than to broker-per-tenant. There's no built-in notion of a tenant id selecting the connection at runtime, and the MassTransit docs note that some features don't span bus instances (the in-memory outbox, for one, though the transactional outbox does across buses in v9.1+). Wolverine's model keeps one routing table, one outbox, and one envelope pipeline across every broker and every tenant.
For tenancy specifically, MassTransit doesn't ship broker-per-tenant. The community patterns are tenant-suffixed queue names with Send instead of Publish, and per-tenant receive endpoints registered at startup — and in the maintainers' own discussion threads, the guidance is to assemble that routing yourself. It works. It's just code you own and test forever, rather than a feature you configure.
The honest summary
| NServiceBus | MassTransit | Wolverine | |
|---|---|---|---|
| Multiple transport types in one app | Separate Messaging Bridge process | ✅ MultiBus, static binding | ✅ Native, one runtime |
| Multiple brokers of the same type | DIY / endpoint slots | ✅ Via MultiBus | ✅ Named brokers |
| Broker/namespace/vhost per tenant | ❌ Build it yourself | ❌ Build it yourself | ✅ AddTenant(...), 10 transports |
| Tenant inferred from inbound connection | ❌ Parse a header | ❌ Parse a header | ✅ Automatic |
| Tenant id auto-propagated to outgoing messages | ❌ Manual | ❌ Manual | ✅ Automatic |
| Same tenant id spans broker and database | Persistence-only, wired separately | Persistence-only, wired separately | ✅ One id, end to end |
None of the above means "don't use NServiceBus" or "don't use MassTransit." It means that if broker-level tenant isolation is on your requirements list — and for regulated, IoT, and on-premises-per-customer systems it very often is — Wolverine is the framework where that's a configuration block instead of a project.
Next time: multi-tenancy all the way down
We've deliberately only shown you the transport half of the picture here. The broker is where the tenant id gets established; the more interesting story is everything downstream of that — database per tenant, schema per tenant, conjoined tenancy in a single database, adding and removing tenants at runtime without a deployment, and per-tenant observability so you can actually see what each customer costs you.
That's the subject of the next post. In the meantime:
- The Multi-Tenancy Tutorial walks the transport and storage halves end to end.
- Multi-Tenancy with Wolverine covers how the tenant id is tracked across messages.
- Broker-per-tenant reference docs for Rabbit MQ, Azure Service Bus, and MQTT.
- Coming from an existing estate? There's a migration guide and built-in interoperability so Wolverine can talk to NServiceBus and MassTransit endpoints on the wire — you don't have to do this as a big bang.
If you're staring down a multi-broker or multi-tenant architecture and want a second opinion before you commit, that's exactly the kind of work we do. Or just come argue with us in the Critter Stack Discord — we're there most days.



