Note: this article was drafted with AI assistance and reviewed by Softinbit Technology before publishing. It is part of the series The .NET Field Guide.

Decide whether to license MassTransit v9, stay on v8 for now, move to Wolverine, or write the RabbitMQ code yourself.

Part of "The .NET Field Guide". Section: Comparisons.

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:

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

Add the settings to appsettings.Development.json:

{ "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.

// 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:

// 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); }); 
// 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.

// 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

dotnet add package MassTransit.RabbitMQ --version 8.5.11 

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

// 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); } 
// 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

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.

// 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(); } 
// 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

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.

// 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: ...
Source:https://dev.to/softinbit/masstransit-vs-wolverine-vs-raw-rabbitmqclient-after-the-masstransit-license-change-1b29