Mert Özen Further With Every Line
Backend Working with Message Queues — #14

Client Version Migrations: RabbitMQ.Client 6→7 (IModel to IChannel) and MassTransit 8→9

Mert Özen Nov 8, 2026 13 min 3 views
Client Version Migrations: RabbitMQ.Client 6→7 (IModel to IChannel) and MassTransit 8→9

Moving to RabbitMQ.Client 7 gave 5 compiler errors on the first build and 10 more on the second; the real risks are the ones the compiler misses: body lifetime, timeouts becoming cancellations and concurrency. Plus RabbitMQ 4.x rejecting mirroring policies, queue type mismatches, the AMQP 1.0 client, MassTransit 9 refusing to start without a license, v8 support ending on 31 December 2026, and the old Service Bus SDK mapping.

Version note: The code in this post was built and run with .NET 10, RabbitMQ 4.4, RabbitMQ.Client 6.8.1 and 7.2.2, RabbitMQ.AMQP.Client 1.4.1, and MassTransit 8.5.12 and 9.2.5.

Guess First

There is a 35-line producer/consumer written with RabbitMQ.Client 6.8.1: it declares an exchange and a quorum queue, publishes one message with a publisher confirm, and reads and acks it with an async consumer. You upgrade the package to 7.2.2 and build. How many compiler errors do you get? Once you fix them all, do you think you are done? Second question: in a Billing service using MassTransit 8.5.12, you upgrade the package to 9.2.5 without changing any code. Does it build? Does it run?

This last post of the series is about version migrations: RabbitMQ.Client from 6 to 7, the broker from RabbitMQ 3.13 to 4.x, MassTransit from 8 to 9, and Azure Service Bus from the old SDK (Software Development Kit) to the new one. Each comes with real compiler errors and runtime surprises.

What Changed in RabbitMQ.Client 7?

RabbitMQ.Client 7.0.0 shipped on NuGet on 6 November 2024 and became the biggest breaking release of the .NET client: the synchronous API (Application Programming Interface) was removed entirely, and everything turned into async methods that take a CancellationToken. A summary of the official migration guide and of what the compiler showed us:

6.x7.x
IModelIChannel
factory.CreateConnection()await factory.CreateConnectionAsync()
connection.CreateModel()await connection.CreateChannelAsync(options)
DispatchConsumersAsync = trueRemoved; consumers are always async
consumer.Receivedconsumer.ReceivedAsync
channel.CreateBasicProperties()new BasicProperties()
ConfirmSelect() + WaitForConfirmsOrDie()CreateChannelOptions(publisherConfirmationsEnabled, publisherConfirmationTrackingEnabled)
BasicPublish(exchange, key, props, body)await BasicPublishAsync(exchange, key, mandatory, props, body)
QueueDeclare, BasicQos, BasicAck…QueueDeclareAsync, BasicQosAsync, BasicAckAsync…
TimeoutException on timeoutA cancelled operation (OperationCanceledException) on timeout

Compiler Errors in Waves

The code written with 6.8.1, which builds and runs:

using System.Text;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;

var factory = new ConnectionFactory
{
    HostName = "localhost",
    Port = 17301,
    DispatchConsumersAsync = true
};
using IConnection connection = factory.CreateConnection();
using IModel channel = connection.CreateModel();

channel.ExchangeDeclare("bookstore.ordering", ExchangeType.Topic, durable: true);
channel.QueueDeclare("lab14.billing", durable: true, exclusive: false, autoDelete: false,
    arguments: new Dictionary<string, object> { ["x-queue-type"] = "quorum" });
channel.QueueBind("lab14.billing", "bookstore.ordering", "order.placed");
channel.ConfirmSelect();

IBasicProperties props = channel.CreateBasicProperties();
props.MessageId = Guid.NewGuid().ToString();
props.Persistent = true;
channel.BasicPublish("bookstore.ordering", "order.placed", props, Encoding.UTF8.GetBytes("{}"));
channel.WaitForConfirmsOrDie(TimeSpan.FromSeconds(5));

channel.BasicQos(0, 10, false);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.Received += async (_, ea) =>
{
    Console.WriteLine($"received {ea.BasicProperties.MessageId}");
    channel.BasicAck(ea.DeliveryTag, multiple: false);
    await Task.Yield();
};
channel.BasicConsume("lab14.billing", autoAck: false, consumer);
await Task.Delay(1000);
received 4da49a62-acfb-426a-a56d-66c3496fd3c4

After upgrading the package to 7.2.2, the first build gives five errors:

Program.cs(9,5): error CS0117: 'ConnectionFactory' does not contain a definition for 'DispatchConsumersAsync'
Program.cs(12,7): error CS0246: The type or namespace name 'IModel' could not be found (are you missing a using directive or an assembly reference?)
Program.cs(11,40): error CS1061: 'ConnectionFactory' does not contain a definition for 'CreateConnection' and no accessible extension method 'CreateConnection' accepting a first argument of type 'ConnectionFactory' could be found (are you missing a using directive or an assembly reference?)
Program.cs(12,35): error CS1061: 'IConnection' does not contain a definition for 'CreateModel' and no accessible extension method 'CreateModel' accepting a first argument of type 'IConnection' could be found (are you missing a using directive or an assembly reference?)
Program.cs(28,10): error CS1061: 'AsyncEventingBasicConsumer' does not contain a definition for 'Received' and no accessible extension method 'Received' accepting a first argument of type 'AsyncEventingBasicConsumer' could be found (are you missing a using directive or an assembly reference?)

Five errors look like little, but that is misleading: because IModel cannot be resolved, the compiler cannot check any of the calls on channel. Fix these five mechanically (IChannel, CreateConnectionAsync, CreateChannelAsync, ReceivedAsync, delete DispatchConsumersAsync), build again, and the second wave arrives (I trimmed the end of the long messages):

Program.cs(13,9): error CS1061: 'IChannel' does not contain a definition for 'ExchangeDeclare'
Program.cs(14,9): error CS1061: 'IChannel' does not contain a definition for 'QueueDeclare'
Program.cs(16,9): error CS1061: 'IChannel' does not contain a definition for 'QueueBind'
Program.cs(17,9): error CS1061: 'IChannel' does not contain a definition for 'ConfirmSelect'
Program.cs(22,9): error CS1061: 'IChannel' does not contain a definition for 'BasicPublish'
Program.cs(23,9): error CS1061: 'IChannel' does not contain a definition for 'WaitForConfirmsOrDie'
Program.cs(25,9): error CS1061: 'IChannel' does not contain a definition for 'BasicQos'
Program.cs(33,9): error CS1061: 'IChannel' does not contain a definition for 'BasicConsume'
Program.cs(19,34): error CS1061: 'IChannel' does not contain a definition for 'CreateBasicProperties'
Program.cs(20,13): error CS1061: 'IChannel' does not contain a definition for 'BasicAck'

In a real project this second wave arrives as hundreds of lines from every corner of the code. Do not plan based on the first wave. The migrated version:

using System.Text;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;

var factory = new ConnectionFactory
{
    HostName = "localhost",
    Port = 17301
};
await using IConnection connection = await factory.CreateConnectionAsync();
// confirms are now chosen when the channel is opened; with tracking on, BasicPublishAsync waits for the broker's ack
await using IChannel channel = await connection.CreateChannelAsync(
    new CreateChannelOptions(publisherConfirmationsEnabled: true, publisherConfirmationTrackingEnabled: true));

await channel.ExchangeDeclareAsync("bookstore.ordering", ExchangeType.Topic, durable: true);
await channel.QueueDeclareAsync("lab14.billing", durable: true, exclusive: false, autoDelete: false,
    arguments: new Dictionary<string, object?> { ["x-queue-type"] = "quorum" });
await channel.QueueBindAsync("lab14.billing", "bookstore.ordering", "order.placed");

var props = new BasicProperties
{
    MessageId = Guid.NewGuid().ToString(),
    Persistent = true
};
await channel.BasicPublishAsync("bookstore.ordering", "order.placed", mandatory: true, props,
    Encoding.UTF8.GetBytes("{}"));

await channel.BasicQosAsync(0, 10, false);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (_, ea) =>
{
    Console.WriteLine($"received {ea.BasicProperties.MessageId}");
    await channel.BasicAckAsync(ea.DeliveryTag, multiple: false);
};
await channel.BasicConsumeAsync("lab14.billing", autoAck: false, consumer);
await Task.Delay(1000);
received 5f4d025e-06e1-4155-9017-03204cfb387e

The line count is almost the same, but there are three meaningful differences. The ConfirmSelect + WaitForConfirmsOrDie pair is gone; confirms are chosen when the channel is opened, and with tracking on, every BasicPublishAsync waits for the broker's ack. BasicProperties is no longer an interface but a class you create directly. And the ack call is now awaited; the artificial await Task.Yield() in the handler is no longer needed.

What the Compiler Does Not Tell You

Code that builds is not always code that works. Points whose behavior changed in 7.x and that the compiler does not catch:

The lifetime of the body. According to the migration guide, ea.Body in ReceivedAsync is valid only while the handler runs; if you use it after the handler (for example, putting it into a Channel<T> to process on another thread), you have to copy it (ea.Body.ToArray()). For a team that wrote this code on 6.x, this is the sneakiest bug: it builds, it mostly works in tests, and under load it reads corrupted messages at random.

A timeout is now a cancellation. When an operation times out, 7.x returns a cancelled task instead of throwing TimeoutException; catch (TimeoutException) blocks no longer run, and the failure surfaces as an OperationCanceledException. Code that bases its retry decision on the exception type (such as IsTransient in the eighth post) has to be reviewed.

The concurrency default. ConsumerDispatchConcurrency defaults to 1; even async handlers run one after another on a channel. When migrating a consumer that built its own parallelism with Task.Run on 6.x, set this value deliberately; we could only see the race in the tenth post after setting it to 4.

New headers. With confirm tracking on, the client adds an x-dotnet-pub-seq-no header to the messages it publishes; we saw that header in the MassTransit _error message in the ninth post. If you have code that compares headers exactly or copies all headers somewhere, you will notice. 7.x also produces its own traces through ActivitySource and adds a traceparent header when there is a listener (the twelfth post).

From RabbitMQ 3.13 to 4.x: Mirrored Queues Are Gone

When you upgrade the client, the broker will most likely be upgraded too. RabbitMQ 4.0 removed classic mirrored queues, which had been deprecated for years. I tried applying a mirroring policy that worked on 3.13 to 4.4:

$ rabbitmqctl set_policy ha-all "^lab14\." '{"ha-mode":"all","ha-sync-mode":"automatic"}' --apply-to queues
Validation failed

[{<<"ha-mode">>,<<"all">>},{<<"ha-sync-mode">>,<<"automatic">>}] are not recognised policy settings

Every queue that needs replication has to move to a quorum queue, and there is a trap here: a queue's type cannot be changed afterwards. The lab14.billing queue above was created as quorum. The result of two calls declaring the same name without arguments (as in the old code) and with x-queue-type = classic:

qargs=none: The AMQP operation was interrupted: AMQP close-reason, initiated by Peer, code=406, text='PRECONDITION_FAILED - inequivalent arg 'x-queue-type' for queue 'lab14.billing' in vhost '/': received 'classic' but current is 'quorum'', classId=50, methodId=10
qargs=x-queue-type=classic: The AMQP operation was interrupted: AMQP close-reason, initiated by Peer, code=406, text='PRECONDITION_FAILED - inequivalent arg 'x-queue-type' for queue 'lab14.billing' in vhost '/': received 'classic' but current is 'quorum'', classId=50, methodId=10

Both calls got the same error: with no argument, the client counts as having asked for "classic". So a single old service whose declare code you forgot to update after moving the queue to quorum loses its channel at startup. Setting the vhost's default queue type to quorum solves argument-less declares for new queues, but it does not change the type of a queue that already exists. The migration path is always the same: create a quorum queue under a new name, add the bindings, switch the consumers to the new queue, and delete the old queue once it is empty; to migrate without interrupting traffic you use Shovel or a blue-green switch with a new vhost. Quorum queues are always durable and cannot be exclusive or auto-delete; temporary queues that use those features should stay classic.

The second silent change in 4.0 is the delivery limit we saw in the eighth post: 20 by default on quorum queues. A poison message that was requeued forever on 3.13 goes to the DLX (Dead Letter Exchange) after 20 attempts on 4.x; without a DLX it is dropped. Before upgrading, verify that every quorum queue has a DLX. There is also an ordering requirement: before moving from 3.13 to 4.0, all stable feature flags must be enabled; otherwise the upgrade cannot proceed.

The good news: client and broker versions are independent of each other. The 6.8.1 code above connected to RabbitMQ 4.4 and ran without problems; you can upgrade the broker first and then the services one at a time.

AMQP 1.0 and RabbitMQ.AMQP.Client

With RabbitMQ 4.0, AMQP (Advanced Message Queuing Protocol) 1.0 became a core protocol that needs no plugin, and the RabbitMQ team released a new .NET client for it: RabbitMQ.AMQP.Client. The API differs from the 0-9-1 client; there is no concept of a channel, and topology is managed from a separate management object:

ConnectionSettings settings = ConnectionSettingsBuilder.Create()
    .Host("localhost").Port(17301).ContainerId("bookstore-billing").Build();
IEnvironment environment = AmqpEnvironment.Create(settings);
IConnection connection = await environment.CreateConnectionAsync();

IManagement management = connection.Management();
IQueueInfo queue = await management.Queue("lab14.amqp10").Type(QueueType.QUORUM).DeclareAsync();
Console.WriteLine($"queue {queue.Name()} type={queue.Type()}");

IPublisher publisher = await connection.PublisherBuilder().Queue("lab14.amqp10").BuildAsync();
PublishResult result = await publisher.PublishAsync(new AmqpMessage("{}").MessageId("msg-1"));
Console.WriteLine($"publish outcome: {result.Outcome.State}");

var received = new TaskCompletionSource<string>();
IConsumer consumer = await connection.ConsumerBuilder()
    .Queue("lab14.amqp10")
    .MessageHandler((context, message) =>
    {
        context.Accept();
        received.TrySetResult($"{message.MessageId()}");
        return Task.CompletedTask;
    })
    .BuildAndStartAsync();
Console.WriteLine($"consumed {await received.Task.WaitAsync(TimeSpan.FromSeconds(5))}");

await consumer.CloseAsync();
await publisher.CloseAsync();
await management.Queue("lab14.amqp10").DeleteAsync();
await connection.CloseAsync();
queue lab14.amqp10 type=QUORUM
publish outcome: Accepted
consumed msg-1

The result of a publish is an "outcome": Accepted means the broker accepted the message; the counterpart of 0-9-1's publisher confirm is built into the protocol itself. On the consumer side, ack becomes Accept, reject becomes Discard and requeue becomes Requeue. There is no compelling reason to move to this client right now; RabbitMQ.Client 7 continues to be supported, and every example in this series ran on it. But some of 4.4's new features (for example deferred messages, where a consumer can defer a message under a token and retrieve it later) are available only over AMQP 1.0; for a new RabbitMQ-only project this client is worth considering.

From MassTransit 8 to 9

MassTransit 9.0.0 shipped on NuGet on 6 January 2026 with a commercial license. I upgraded the Billing sample from the eighth post from 8.5.12 to 9.2.5 without touching a single line of code. The project built without errors; running it:

Unhandled exception. MassTransit.ConfigurationException: The bus configuration is invalid:
[Failure] License must be specified with SetLicense/SetLicenseLocation or by setting the MT_LICENSE/MT_LICENSE_PATH environment variables. See https://masstransit.massient.com/configuration/license for details.

The bus does not start and the host does not come up. The license can be provided in four ways: the MT_LICENSE environment variable (the key itself), MT_LICENSE_PATH (a file path), or SetLicense and SetLicenseLocation on the bus configurator. Validation happens locally, without calling a license server; according to the documentation, running services do not stop when a subscription ends, a warning is logged, and versions released during the subscription can keep being used. According to Massient's site, the license covers development, CI (Continuous Integration), test, staging and production; there are 100% discounted options for companies with annual revenue under 1 million dollars and for nonprofits with annual expenses under 1 million dollars.

An observation: with the same 9.2.5 package, the test harness tests from the thirteenth post passed without a license; the check was not triggered in the in-memory harness. That is technical behavior, not license permission: the license terms cover CI and testing too.

9.x changes that may affect your code (from the change log): JSON (JavaScript Object Notation) serialization settings are configured per bus or endpoint, not globally; the deprecated TransactionalBus was removed in 9.2; the transactional outbox tables changed in 9.1 and need a schema migration; 9.1 brought UseQueueBasedDelayedRedelivery for RabbitMQ, which needs no plugin, and harness.Act(...) for the test harness. Then there is package management: dotnet add package MassTransit.RabbitMQ run without a version now brings 9.x. If you stay on v8, pin the version explicitly and disable major upgrades in tools such as Dependabot or Renovate.

Decision time: Massient announces that v8 support ends on 31 December 2026. As of November 2026, when this post is published, staying on v8 means living with a dependency that stops receiving security fixes in a few weeks. The options are in the table in the seventh post of the series: a v9 license, Wolverine (MIT, the Massachusetts Institute of Technology license), Rebus, NServiceBus (commercial), or, as we did for most of this series, the clients directly.

From the Old Service Bus SDK to the New One

On the Azure side the migration became mandatory a while ago: the Microsoft.Azure.ServiceBus package on NuGet says it is obsolete as of 30 September 2026 and no longer maintained, and recommends Azure.Messaging.ServiceBus instead. The counterparts when reading an old code base:

Microsoft.Azure.ServiceBusAzure.Messaging.ServiceBus
QueueClient, TopicClientServiceBusClient + ServiceBusSender
MessageServiceBusMessage (sending), ServiceBusReceivedMessage (receiving)
LabelSubject
UserPropertiesApplicationProperties
SystemProperties.DeliveryCountDeliveryCount
RegisterMessageHandler + MessageHandlerOptionsServiceBusProcessor + ServiceBusProcessorOptions
AutoCompleteAutoCompleteMessages
ManagementClientServiceBusAdministrationClient

I left this section as a reading note; I did not run code with the old SDK. The concepts stay the same in the migration; what changes is the lifetime of the objects: in the new SDK a single ServiceBusClient lives for the whole application, and senders, receivers and processors are created from it. That is exactly the singleton rule from the fourth post of the series.

Production Notes

Upgrade one thing at a time. If the broker major version, the client major version and the abstraction library all change in the same release, finding which one caused a problem takes hours. Broker first (clients are backward compatible), then the services one by one.

Run the tests against both versions. Running the Testcontainers tests from the thirteenth post against both the old and the new broker image during the migration shows behavior differences (delivery limit, queue type) before production does.

Migrate a consumer as a canary. Running just one of a queue's consumers on the new client version and comparing errors, processing time and the DLQ (Dead-Letter Queue) rate with the metrics from the twelfth post is safer than upgrading the whole fleet at once.

Manage the license like a secret. The MassTransit v9 license key is a secret, like a connection string; it belongs in a secret store and an environment variable, not in source code. Its expiry does not stop running services, but it can block deploying a new version; put the renewal date in the calendar.

Common Pitfalls

Planning the migration based on the first compiler wave. Until IModel is resolved, the errors in channel calls stay hidden; the real work is in the second wave.

Using ea.Body outside the handler. In 7.x the body is valid only for the duration of the handler; handing it to another thread without copying means random data corruption.

Relying on catch (TimeoutException). In 7.x a timeout arrives as a cancellation; update your retry and error classification code accordingly.

Trying to change a queue's type in place. Declaring the same name with a different type always gives PRECONDITION_FAILED; the migration is done with a new queue.

Moving to 4.x with quorum queues that have no DLX. The default delivery limit silently drops the poison message that looped forever on 3.13.

Unpinned packages. A dotnet add package without a version in CI, or a wide version range, will one day take MassTransit to 9, and the service will fail to start with a license error.

A Small Experiment

★☆☆ Change the consumer in the 6.8.1 code so that it puts the message body into a Channel<ReadOnlyMemory<byte>> and processes it in a separate task, then migrate it to 7.2.2. Run it with 1000 messages without copying and with copying via ToArray(), and compare the results.

★★☆ Move billing.order-placed from classic to quorum without interrupting traffic: create a quorum queue under a new name, add the same bindings, switch the consumer to the new queue and delete the old queue once it is empty. Show with a test like the ones in the thirteenth post that no message was lost during the move.

★★★ Move a service that uses MassTransit to raw RabbitMQ.Client 7 while keeping the same consumers: the retry topology from the eighth post, the DLQ from the ninth, the inbox from the tenth and the outbox from the eleventh. Which pieces did you have to write yourself, and how does that maintenance cost compare with a v9 license?

With this post the Working with Message Queues series comes to an end. Over fourteen posts we sent the same OrderPlaced message through RabbitMQ, Azure Service Bus and Kafka; we built retry, the DLQ, idempotency, the outbox, observability and tests in code, and looked at real output for each. If you want to keep going, the Messaging and Event-Driven Architecture series deepens the conceptual side, the upcoming Background Jobs series covers the background work that hosts consumers, and the upcoming Observability series covers collecting and visualizing traces and metrics.