TL;DR

  • MassTransit v9 moved to a commercial license requiring runtime keys. If you only publish and consume events, you do not need to blindly pay or panic.
  • Compare three real paths for the same order-processing flow: keep MassTransit (v8 open source or v9 licensed), migrate to MIT-licensed Wolverine 6, or write lightweight async consumers with RabbitMQ.Client 7.
  • Complete benchmarks, topology setups, retry/dead-letter policies, and an outbox comparison to pick the right option for your team.

Level: Intermediate · Prerequisites: Basic understanding of message queues and RabbitMQ, ASP.NET Core dependency injection · Reading time: 14 min

Dependabot opened a pull request this morning: MassTransit.RabbitMQ 8.5.11 to 9.2.3. The build is green, but the MassTransit docs now say v9 is a commercial product and that the license key must be available at runtime on every machine that runs MassTransit code, developer laptops included. Your Store.Api uses MassTransit for exactly one flow: when an order is placed, publish OrderPlaced, and a consumer reserves stock.

You can keep MassTransit (v8 for now, or v9 with a license), move to Wolverine, or use RabbitMQ.Client directly. This article builds the same flow in all three, with retries, an error queue, and idempotent handling.

The short answer

  • Choose MassTransit v9 with a license when you use sagas, routing slips, the transactional outbox, or several transports, and the subscription cost is smaller than the rewrite. Small organizations should check whether they qualify for the full discount.
  • Choose MassTransit v8 when you need the current code to keep running this quarter, and you put a dated migration or licensing decision on the roadmap.
  • Choose Wolverine when you want an MIT-licensed framework with retries, dead-letter queues, an inbox and outbox, and convention-based handlers, and you run on .NET 9 or 10.
  • Choose raw RabbitMQ.Client when you have one or two message types, want no framework dependency, and accept that you own retries, error queues, serialization, and topology.

What is being compared

MassTransit is a message bus framework for .NET with consumers, sagas, retries, an outbox, and testing helpers. The current major version is 9 (9.2.3 on NuGet in October 2026), sold by Massient, Inc., the company created by founder Chris Patterson. Version 9 needs a license; the license terms say v8 and earlier “remain free and open source under their original licenses” (Apache 2.0) but are unsupported under the commercial agreement. Version 8 still ships patch releases (8.5.11 at the end of September 2026).

Wolverine (WolverineFx) is a messaging and command-handling framework from JasperFx Software, the team behind Marten. The current major is 6 (6.50.0), released under the MIT license. Wolverine 6 targets .NET 9 and .NET 10 only; teams on .NET 8 must stay on 5.x.

RabbitMQ.Client is the official .NET client for RabbitMQ, maintained by the RabbitMQ core team. The current major is 7 (7.2.2), dual-licensed under Apache 2.0 and the Mozilla Public License 2.0. Version 7 made the API fully asynchronous.

The same task in each option

The task: POST /api/orders/{id}/placed-event publishes OrderPlaced. A consumer reserves stock for each line. Transient failures retry three times with a delay. Permanent failures (a product that does not exist) go straight to an error queue. A redelivered message never reserves stock twice.

Start RabbitMQ locally:

1
docker run -d --name store-rabbit -p 5672:5672 -p 15672:15672 rabbitmq:4-management

Add the settings to appsettings.Development.json:

1
2
3
{
  "RabbitMq": { "Host": "localhost", "Username": "guest", "Password": "guest" }
}

Shared code used by all three options

The contract, the idempotency table, and the business logic are identical everywhere.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// File: Orders/Messaging/OrderPlaced.cs
namespace Store.Api.Orders.Messaging;

public sealed record OrderPlaced(int OrderId, string UserId, DateTime PlacedAtUtc, IReadOnlyList<OrderPlacedLine> Lines);

public sealed record OrderPlacedLine(int ProductId, int Quantity);

public interface IOrderEvents
{
    Task PublishOrderPlacedAsync(OrderPlaced message, CancellationToken ct);
}

public sealed class ProcessedMessage
{
    public string Consumer { get; set; } = "";
    public string MessageKey { get; set; } = "";
    public DateTime ProcessedAtUtc { get; set; }
}

Register the table in AppDbContext (in Store.Api.Data) and add a migration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// File: Data/AppDbContext.cs (additions; add using Store.Api.Orders.Messaging;)
public DbSet<ProcessedMessage> ProcessedMessages => Set<ProcessedMessage>();

// Inside OnModelCreating:
modelBuilder.Entity<ProcessedMessage>(e =>
{
    e.HasKey(p => new { p.Consumer, p.MessageKey });
    e.Property(p => p.Consumer).HasMaxLength(100);
    e.Property(p => p.MessageKey).HasMaxLength(100);
});
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// File: Orders/Messaging/StockReservationService.cs
using Microsoft.EntityFrameworkCore;
using Store.Api.Data;

namespace Store.Api.Orders.Messaging;

public sealed class StockReservationService(AppDbContext db, TimeProvider time, ILogger<StockReservationService> logger)
{
    private const string ConsumerName = "reserve-stock";

    public async Task ReserveAsync(OrderPlaced message, CancellationToken ct)
    {
        var key = $"order-{message.OrderId}";
        if (await db.ProcessedMessages.AnyAsync(p => p.Consumer == ConsumerName && p.MessageKey == key, ct))
        {
            logger.LogInformation("Stock for order {OrderId} already reserved, skipping duplicate", message.OrderId);
            return;
        }

        await using var tx = await db.Database.BeginTransactionAsync(ct);
        foreach (var line in message.Lines)
        {
            // Atomic decrement: two orders reserving the same product cannot overwrite each other.
            var updated = await db.Products.Where(p => p.Id == line.ProductId)
                .ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - line.Quantity), ct);
            if (updated == 0)
                throw new InvalidOperationException($"Product {line.ProductId} does not exist.");
        }

        db.ProcessedMessages.Add(new ProcessedMessage
        {
            Consumer = ConsumerName,
            MessageKey = key,
            ProcessedAtUtc = time.GetUtcNow().UtcDateTime
        });
        await db.SaveChangesAsync(ct);
        await tx.CommitAsync(ct);
    }
}

The stock updates and the ProcessedMessages row commit in one transaction, so they succeed or fail together. If two copies of the same message race, the composite primary key rejects the second insert with a DbUpdateException, the retry policy runs it again, and the second attempt sees the row and skips. That is why the key is the business identity (order-42) and not a transport message id.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// File: Orders/OrderEventsController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Store.Api.Data;
using Store.Api.Orders.Messaging;

namespace Store.Api.Orders;

[ApiController]
[Route("api/orders")]
public sealed class OrderEventsController(AppDbContext db, IOrderEvents events) : ControllerBase
{
    [HttpPost("{id:int}/placed-event")]
    public async Task<IActionResult> PublishPlaced(int id, CancellationToken ct)
    {
        var order = await db.Orders.AsNoTracking().Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == id, ct);
        if (order is null) return NotFound();

        var lines = order.Lines.Select(l => new OrderPlacedLine(l.ProductId, l.Quantity)).ToList();
        await events.PublishOrderPlacedAsync(new OrderPlaced(order.Id, order.UserId, order.CreatedAtUtc, lines), ct);
        return Accepted();
    }
}

All three options also need builder.Services.AddSingleton(TimeProvider.System); and builder.Services.AddScoped<StockReservationService>(); in Program.cs.

MassTransit v8

1
dotnet add package MassTransit.RabbitMQ --version 8.5.11

Pin the version. A plain dotnet add package MassTransit.RabbitMQ now installs v9.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// File: Orders/Messaging/MassTransitBus/ReserveStockConsumer.cs
using MassTransit;

namespace Store.Api.Orders.Messaging.MassTransitBus;

public sealed class ReserveStockConsumer(StockReservationService reservations) : IConsumer<OrderPlaced>
{
    public Task Consume(ConsumeContext<OrderPlaced> context) =>
        reservations.ReserveAsync(context.Message, context.CancellationToken);
}

public sealed class ReserveStockConsumerDefinition : ConsumerDefinition<ReserveStockConsumer>
{
    public ReserveStockConsumerDefinition()
    {
        EndpointName = "order-placed.reserve-stock";
        ConcurrentMessageLimit = 10;
    }

    protected override void ConfigureConsumer(IReceiveEndpointConfigurator endpointConfigurator,
        IConsumerConfigurator<ReserveStockConsumer> consumerConfigurator, IRegistrationContext context)
    {
        endpointConfigurator.UseMessageRetry(r =>
        {
            r.Ignore<InvalidOperationException>();
            r.Intervals(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
        });
    }
}

public sealed class MassTransitOrderEvents(IPublishEndpoint publishEndpoint) : IOrderEvents
{
    public Task PublishOrderPlacedAsync(OrderPlaced message, CancellationToken ct) =>
        publishEndpoint.Publish(message, ct);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Program.cs, before builder.Build() (add using MassTransit; and using Store.Api.Orders.Messaging.MassTransitBus;)
var rabbit = builder.Configuration.GetSection("RabbitMq");
builder.Services.AddScoped<IOrderEvents, MassTransitOrderEvents>();
builder.Services.AddMassTransit(x =>
{
    x.AddConsumer<ReserveStockConsumer, ReserveStockConsumerDefinition>();
    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.Host(rabbit["Host"]!, "/", h =>
        {
            h.Username(rabbit["Username"]!);
            h.Password(rabbit["Password"]!);
        });
        cfg.ConfigureEndpoints(context);
    });
});
  • ConfigureEndpoints creates the queue order-placed.reserve-stock, an exchange per message type, and the bindings. You never declare topology.
  • UseMessageRetry retries in memory three times. Ignore<InvalidOperationException>() skips retries for the permanent error.
  • When retries run out, MassTransit moves the message to order-placed.reserve-stock_error with the exception details in headers, and publishes a Fault<OrderPlaced> you can consume.
  • Delayed redelivery (minutes, not seconds) is a separate feature that needs a message scheduler, such as the RabbitMQ delayed message exchange plugin.

Wolverine

1
2
3
dotnet add package WolverineFx
dotnet add package WolverineFx.RabbitMQ
dotnet add package WolverineFx.RuntimeCompilation

Since 6.0, runtime code generation is opt-in: add WolverineFx.RuntimeCompilation. It registers itself; opts.UseRuntimeCompilation() is needed only when extension auto-discovery is off.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// File: Orders/Messaging/WolverineBus/OrderPlacedHandler.cs
using Wolverine;

namespace Store.Api.Orders.Messaging.WolverineBus;

public sealed class OrderPlacedHandler
{
    public Task Handle(OrderPlaced message, StockReservationService reservations, CancellationToken cancellationToken) =>
        reservations.ReserveAsync(message, cancellationToken);
}

public sealed class WolverineOrderEvents(IMessageBus bus) : IOrderEvents
{
    public Task PublishOrderPlacedAsync(OrderPlaced message, CancellationToken ct) =>
        bus.PublishAsync(message).AsTask();
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Program.cs, before builder.Build()
// (add using Wolverine; using Wolverine.ErrorHandling; using Wolverine.RabbitMQ; using Store.Api.Orders.Messaging.WolverineBus;)
var rabbit = builder.Configuration.GetSection("RabbitMq");
builder.Services.AddScoped<IOrderEvents, WolverineOrderEvents>();
builder.Host.UseWolverine(opts =>
{
    opts.UseRuntimeCompilation(); // from WolverineFx.RuntimeCompilation
    opts.UseRabbitMq(f =>
        {
            f.HostName = rabbit["Host"]!;
            f.UserName = rabbit["Username"]!;
            f.Password = rabbit["Password"]!;
        })
        .AutoProvision();

    opts.PublishMessage<OrderPlaced>()
        .ToRabbitExchange("store.orders", exchange => exchange.BindQueue("order-placed.reserve-stock"));

    opts.ListenToRabbitQueue("order-placed.reserve-stock")
        .DeadLetterQueueing(new DeadLetterQueue("order-placed.reserve-stock.error"));

    opts.Policies.OnException<InvalidOperationException>().MoveToErrorQueue();
    opts.Policies.OnAnyException()
        .RetryWithCooldown(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15))
        .Then.MoveToErrorQueue();
});
  • No consumer interface. Wolverine finds public classes whose names end in Handler or Consumer and methods named Handle or Consume. Extra parameters (StockReservationService, CancellationToken) are injected per message.
  • Error rules are checked in order and the first match wins, so the specific rule comes before OnAnyException(). A message that matches no rule goes to the error queue.
  • By default Wolverine uses RabbitMQ’s native dead-letter exchange and a shared wolverine-dead-letter-queue. DeadLetterQueueing gives this queue its own error queue.
  • Wolverine’s durable inbox (with message storage such as WolverineFx.SqlServer) adds deduplication by envelope id. The business-key check above still matters, because a client that publishes the same order twice creates two envelopes.

Raw RabbitMQ.Client 7

1
dotnet add package RabbitMQ.Client

Now you write what the frameworks did: a long-lived connection, topology, publisher confirms, a consumer loop, and retries. Retries go to a queue with a 10-second message time to live (TTL); when it expires, RabbitMQ dead-letters the message back to the main queue.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// File: Orders/Messaging/RawRabbit/RabbitConnection.cs
using RabbitMQ.Client;

namespace Store.Api.Orders.Messaging.RawRabbit;

public sealed class RabbitConnection(IConfiguration configuration) : IAsyncDisposable
{
    private readonly SemaphoreSlim _gate = new(1, 1);
    private IConnection? _connection;

    public async Task<IConnection> GetAsync(CancellationToken ct)
    {
        if (_connection is not null) return _connection;
        await _gate.WaitAsync(ct);
        try
        {
            var section = configuration.GetSection("RabbitMq");
            _connection ??= await new ConnectionFactory
            {
                HostName = section["Host"]!,
                UserName = section["Username"]!,
                Password = section["Password"]!
            }.CreateConnectionAsync("Store.Api", ct);
            return _connection;
        }
        finally { _gate.Release(); }
    }

    public async ValueTask DisposeAsync()
    {
        if (_connection is not null) await _connection.DisposeAsync();
    }
}

public static class RabbitTopology
{
    public const string Exchange = "store.orders";
    public const string RoutingKey = "order.placed";
    public const string Queue = "order-placed.reserve-stock";
    public const string RetryQueue = Queue + ".retry";
    public const string ErrorQueue = Queue + ".error";

    public static readonly CreateChannelOptions Confirmed =
        new(publisherConfirmationsEnabled: true, publisherConfirmationTrackingEnabled: true);

    public static async Task DeclareAsync(IChannel channel, CancellationToken ct)
    {
        await channel.ExchangeDeclareAsync(Exchange, ExchangeType.Topic, durable: true, cancellationToken: ct);
        await channel.QueueDeclareAsync(Queue, durable: true, exclusive: false, autoDelete: false, cancellationToken: ct);
        await channel.QueueBindAsync(Queue, Exchange, RoutingKey, cancellationToken: ct);
        await channel.QueueDeclareAsync(RetryQueue, durable: true, exclusive: false, autoDelete: false,
            arguments: new Dictionary<string, object?>
            {
                ["x-message-ttl"] = 10_000,
                ["x-dead-letter-exchange"] = "",
                ["x-dead-letter-routing-key"] = Queue
            }, cancellationToken: ct);
        await channel.QueueDeclareAsync(ErrorQueue, durable: true, exclusive: false, autoDelete: false, cancellationToken: ct);
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// File: Orders/Messaging/RawRabbit/RabbitOrderEvents.cs
using System.Text.Json;
using RabbitMQ.Client;

namespace Store.Api.Orders.Messaging.RawRabbit;

public sealed class RabbitOrderEvents(RabbitConnection rabbit) : IOrderEvents, IAsyncDisposable
{
    private readonly SemaphoreSlim _gate = new(1, 1);
    private IChannel? _channel;

    public async Task PublishOrderPlacedAsync(OrderPlaced message, CancellationToken ct)
    {
        await _gate.WaitAsync(ct);
        try
        {
            if (_channel is null)
            {
                _channel = await (await rabbit.GetAsync(ct)).CreateChannelAsync(RabbitTopology.Confirmed, ct);
                await RabbitTopology.DeclareAsync(_channel, ct);
            }

            var properties = new BasicProperties
            {
                Persistent = true,
                ContentType = "application/json",
                MessageId = $"order-{message.OrderId}",
                Type = nameof(OrderPlaced)
            };
            await _channel.BasicPublishAsync(RabbitTopology.Exchange, RabbitTopology.RoutingKey, mandatory: true,
                basicProperties: properties, body: JsonSerializer.SerializeToUtf8Bytes(message), cancellationToken: ct);
        }
        finally { _gate.Release(); }
    }

    public async ValueTask DisposeAsync()
    {
        if (_channel is not null) await _channel.DisposeAsync();
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// File: Orders/Messaging/RawRabbit/ReserveStockWorker.cs
using System.Text.Json;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;

namespace Store.Api.Orders.Messaging.RawRabbit;

public sealed class ReserveStockWorker(RabbitConnection rabbit, IServiceScopeFactory scopes,
    ILogger<ReserveStockWorker> logger) : BackgroundService
{
    private const int MaxRetries = 3;
    private const string RetryHeader = "x-retry-count";
    private IChannel? _consumeChannel;
    private IChannel? _publishChannel;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var connection = await rabbit.GetAsync(stoppingToken);
        _publishChannel = await connection.CreateChannelAsync(RabbitTopology.Confirmed, stoppingToken);
        _consumeChannel = await connection.CreateChannelAsync(cancellationToken: stoppingToken);
        await RabbitTopology.DeclareAsync(_consumeChannel, stoppingToken);
        await _consumeChannel.BasicQosAsync(prefetchSize: 0, prefetchCount: 10, global: false, stoppingToken);

        var consumer = new AsyncEventingBasicConsumer(_consumeChannel);
        consumer.ReceivedAsync += HandleAsync;
        await _consumeChannel.BasicConsumeAsync(RabbitTopology.Queue, autoAck: false, consumer, stoppingToken);

        try { await Task.Delay(Timeout.Infinite, stoppingToken); }
        catch (OperationCanceledException) { }
    }

    private async Task HandleAsync(object sender, BasicDeliverEventArgs delivery)
    {
        try
        {
            var message = JsonSerializer.Deserialize<OrderPlaced>(delivery.Body.Span)
                ?? throw new InvalidOperationException("Empty OrderPlaced message.");
            await using var scope = scopes.CreateAsyncScope();
            await scope.ServiceProvider.GetRequiredService<StockReservationService>()
                .ReserveAsync(message, delivery.CancellationToken);
        }
        catch (Exception ex)
        {
            var attempt = ReadRetryCount(delivery.BasicProperties) + 1;
            var permanent = ex is JsonException or InvalidOperationException;
            var target = permanent || attempt > MaxRetries ? RabbitTopology.ErrorQueue : RabbitTopology.RetryQueue;
            logger.LogWarning(ex, "OrderPlaced failed on attempt {Attempt}, moving it to {Queue}", attempt, target);

            var properties = new BasicProperties(delivery.BasicProperties)
            {
                Headers = new Dictionary<string, object?>(delivery.BasicProperties.Headers ?? new Dictionary<string, object?>())
                {
                    [RetryHeader] = attempt,
                    ["x-last-error"] = ex.Message
                }
            };
            await _publishChannel!.BasicPublishAsync("", target, mandatory: true, basicProperties: properties, body: delivery.Body);
        }

        await _consumeChannel!.BasicAckAsync(delivery.DeliveryTag, multiple: false);
    }

    private static int ReadRetryCount(IReadOnlyBasicProperties properties) =>
        properties.Headers is not null && properties.Headers.TryGetValue(RetryHeader, out var value)
            ? value switch { int i => i, long l => (int)l, _ => 0 }
            : 0;
}
1
2
3
4
// Program.cs, before builder.Build() (add using Store.Api.Orders.Messaging.RawRabbit;)
builder.Services.AddSingleton<RabbitConnection>();
builder.Services.AddSingleton<IOrderEvents, RabbitOrderEvents>();
builder.Services.AddHostedService<ReserveStockWorker>();
  • Publisher confirms make BasicPublishAsync wait for the broker to accept the message, and mandatory: true turns “no queue bound” into an exception instead of a silent drop.
  • The failed copy is republished on a separate confirmed channel before the original is acknowledged. If the process dies between those two steps, the message is delivered twice, and the idempotency check absorbs it.

Publish order 42 twice with any option and the logs show the guard working:

1
2
info: Store.Api.Orders.Messaging.StockReservationService[0]
      Stock for order 42 already reserved, skipping duplicate

Side by side

CriterionMassTransit v8 / v9Wolverine 6RabbitMQ.Client 7
Setup effortLow: consumer, definition, ConfigureEndpointsLow: handler class, a few routing rulesHigh: connection, channels, topology, worker
Learning curveMedium; large feature set and docsMedium; conventions and code generation to learnLow API surface, but you must know AMQP (Advanced Message Queuing Protocol) well
RetriesUseMessageRetry, plus scheduled redeliveryRetryWithCooldown, ScheduleRetry, per-exception rulesHand-written (TTL retry queue here)
Error path_error queue and Fault<T> eventsNative dead-letter queue or database storageHand-written error queue
Idempotency helpInbox inside its consumer outboxDurable inbox, deduplication by envelope idNone
Sagas and workflowsState machines, routing slipsSagasNone
TestingAddMassTransitTestHarnessTracked sessions, InvokeMessageAndWaitAsyncYour own integration tests
Runtime overheadMiddleware pipeline per messageGenerated code per handlerLowest; only your code
.NET supportv8.5 targets .NET 8, 9, and 10.NET 9 and 10 (5.x for .NET 8).NET Standard 2.0 and .NET 8+
License and costv9 commercial subscription; v8 Apache 2.0MIT, freeApache 2.0 or MPL 2.0, free
MaintenanceActive (v9); v8 patches still shippingVery active, frequent releasesActive, official client

How to choose

  1. You run MassTransit sagas or routing slips in production. Price the v9 license first. Rewriting state machines costs more than most subscriptions, and v9 includes the fixes from the v8 codebase.
  2. You use MassTransit only for publish and consume. Wolverine is the shortest move: the handler is smaller than the consumer, and retry rules map almost one to one.
  3. You are still on .NET 8 and cannot upgrade soon. Stay on MassTransit v8 or use Wolverine 5.x; Wolverine 6 will not install.
  4. You have one integration and a team that knows RabbitMQ. Raw RabbitMQ.Client is fine, but budget for the parts this article skipped: recovery logging, metrics, and tests for the retry path.
  5. Your company is small. Massient states that organizations under 1 million US dollars in gross annual revenue may qualify for a full discount on the license (commercial support excluded). Check the current terms before you decide on cost alone.

Switching later

What changes if you stay on MassTransit v8

  • Pin the major version so tooling does not upgrade you by accident:
1
<PackageReference Include="MassTransit.RabbitMQ" Version="[8.5.11,9.0)" />
1
2
3
4
# .github/dependabot.yml (inside the nuget update entry)
ignore:
  - dependency-name: "MassTransit*"
    update-types: ["version-update:semver-major"]
  • Plan for the end of fixes. When v9 was announced in 2025, the plan was security patches and critical fixes for v8 through the end of 2026. After that, assume no fixes and check the MassTransit support page for the current statement.
  • Upgrading to v9 later is mostly mechanical. The v9 upgrade notes list TransactionalBus removal, per-bus JSON settings, AddSendEndpointConvention renamed to AddMessageRoute, and changed outbox tables in 9.1. You also add the license through MT_LICENSE, MT_LICENSE_PATH, cfg.SetLicense(...), or cfg.SetLicenseLocation(...).

MassTransit to Wolverine

  • Move one consumer at a time. Wolverine can read and write MassTransit’s envelope format on dedicated endpoints with .UseMassTransitInterop() on ListenToRabbitQueue(...) and ToRabbitExchange(...).
  • Rename consumers to handlers: Consume(ConsumeContext<T>) becomes Handle(T message, ...dependencies).
  • Queues that MassTransit declared do not have Wolverine’s x-dead-letter-exchange argument, and RabbitMQ refuses a redeclare with different arguments. Use new DeadLetterQueue("name", DeadLetterQueueMode.InteropFriendly) or new queue names.

Raw client to a framework

  • Your messages are plain JSON with no envelope. MassTransit reads them with e.UseRawJsonDeserializer() on the receive endpoint; Wolverine uses .DefaultIncomingMessage<OrderPlaced>() on the listener.
  • Drain the custom .retry queue before you delete it, or messages waiting there will dead-letter into a queue nobody reads.

Common pitfalls

  • Publishing after SaveChangesAsync without an outbox. If the broker is down, the order exists and the event is lost. Use MassTransit’s or Wolverine’s outbox, or the pattern in the playbook guide on the transactional outbox.
  • Retrying permanent errors. A missing product will fail on every attempt. Mark it as permanent (Ignore<T>, MoveToErrorQueue, or the permanent check) so it reaches the error queue in seconds, not minutes.
  • Idempotency keyed on transport ids. MassTransit MessageId and Wolverine envelope ids change when a client publishes twice. Key the dedupe table on the business identity.
  • Wolverine startup cost and missing handlers. Runtime code generation compiles handlers with Roslyn. For production, pre-generate with dotnet run -- codegen write and set opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Static. If a handler is never called, print opts.DescribeHandlerMatch(typeof(OrderPlacedHandler)).
  • Forgetting the v9 license on build agents. The key must be present wherever MassTransit runs, which includes integration tests on continuous integration agents.
  • RabbitMQ vs Kafka vs Azure Service Bus for .NET Teams
  • Wolverine for .NET: Messaging and Command Handling in One Library
  • RabbitMQ Dead Letter Exchanges and Delayed Retries with .NET
  • Lost Messages and the Outbox That Fixed Them
  • The Transactional Outbox Pattern in .NET (in “The .NET Implementation Playbook”)

Tags: dotnet, masstransit, wolverine, rabbitmq, messaging