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

Messaging with MassTransit: Consumers, Publish/Send and Broker Topology — v9 Licensing and Alternatives

Mert Özen Nov 8, 2026 11 min 5 views
Messaging with MassTransit: Consumers, Publish/Send and Broker Topology — v9 Licensing and Alternatives

MassTransit takes over connections, acks, serialization and topology, reducing a consumer to a single IConsumer<T>; the price is its own exchange conventions, an envelope format and, in v9, a commercial licence. We move Billing to MassTransit 8.5.12, show publish vs send, the resulting RabbitMQ topology and the envelope, see v9 refuse to start without a licence, and build a decision table across v8, Wolverine, Rebus, NServiceBus and Brighter.

Version note: The code in this post was built and run with .NET 10 and MassTransit 8.5.12 (RabbitMQ transport, RabbitMQ 4.4); the licensing behaviour was tried with MassTransit 9.2.5.

Guess First

In the second post of the series, Billing listened on a queue bound to the bookstore.ordering topic exchange with the order.placed routing key. Now we move the Order API (Application Programming Interface) to MassTransit and publish the order with IPublishEndpoint.Publish(new OrderPlaced(...)). Does the old Billing, written with raw RabbitMQ.Client, receive this message? If not, which exchange did the message go to?

Second question: what happens if you add MassTransit's latest version, 9.2.5, to the project and start the application without any extra setting?

Why an Abstraction?

In the previous five posts we used the brokers directly and did the same things by hand every time: keeping the connection a singleton, keeping the consumer alive inside a BackgroundService, deserializing the message, deciding on ack/complete/offset, graceful shutdown, declaring the topology, carrying MessageId and CorrelationId. This is infrastructure code repeated in every consumer. Once retry, dead-lettering, outbox and tests are added, this code becomes larger than Billing itself.

Libraries such as MassTransit, Wolverine, Rebus, NServiceBus and Brighter take over this infrastructure. You write IConsumer<OrderPlaced>; the library handles the connection, consumer lifecycle, serialization, topology and error handling. There is a price too: it hides the broker's subtleties behind a layer, brings its own message format and naming conventions, and adds a dependency (and lately a licence). In this post we move Billing to MassTransit 8 and look at what we gain and what we lose.

Moving Billing to MassTransit

The roughly 60-line OrderPlacedConsumer from the second post becomes this:

public sealed class OrderPlacedConsumer(ILogger<OrderPlacedConsumer> logger) : IConsumer<OrderPlaced>
{
    public async Task Consume(ConsumeContext<OrderPlaced> context)
    {
        OrderPlaced order = context.Message;
        await Task.Delay(TimeSpan.FromMilliseconds(200), context.CancellationToken);
        logger.LogInformation("Invoice created for order {OrderId}: {Total} {Currency} (MessageId {MessageId}, CorrelationId {CorrelationId})",
            order.OrderId, order.Total, order.Currency, context.MessageId, context.CorrelationId);
    }
}

No ack, no channel, no deserialization. If Consume finishes without throwing, MassTransit acks the message; if it throws, the message is moved to the billing-order-placed_error queue. We will discuss the details and retry in two upcoming posts of the series. Registration:

builder.Services.AddMassTransit(x =>
{
    x.AddConsumer<OrderPlacedConsumer>()
        .Endpoint(e => e.Name = "billing-order-placed");
    x.AddConsumer<CreateInvoiceConsumer>()
        .Endpoint(e => e.Name = "billing-create-invoice");

    // MassTransit creates classic queues on RabbitMQ by default; we want quorum
    x.AddConfigureEndpointsCallback((_, _, cfg) =>
    {
        if (cfg is IRabbitMqReceiveEndpointConfigurator rabbit)
        {
            rabbit.SetQuorumQueue();
        }
    });

    // the transport changes in one block, the consumer code stays the same
    if (builder.Configuration["Transport"] == "AzureServiceBus")
    {
        x.UsingAzureServiceBus((context, cfg) =>
        {
            cfg.Host(builder.Configuration.GetConnectionString("ServiceBus"));
            cfg.ConfigureEndpoints(context);
        });
    }
    else
    {
        x.UsingRabbitMq((context, cfg) =>
        {
            cfg.Host(new Uri(builder.Configuration.GetConnectionString("RabbitMq")!));
            cfg.PrefetchCount = 10;
            cfg.ConfigureEndpoints(context);
        });
    }
});

Consumers are registered in the DI (Dependency Injection) container as scoped; a new scope is created for every message. That makes using a DbContext inside a consumer natural: with the raw client, because the BackgroundService was a singleton, we had to create the scope ourselves. ConfigureEndpoints creates a receive endpoint (queue) for every registered consumer. We gave the endpoint name explicitly; otherwise MassTransit would derive it from the consumer's name.

A note about the queue type. On the first attempt MassTransit 8.5.12 created the endpoints as classic queues. In the third post of the series we made quorum queues the default choice for durable business messages; with AddConfigureEndpointsCallback we apply that to all endpoints. Because an existing classic queue's type does not change, we deleted the queues and recreated them:

billing-order-placed	quorum	0
billing-create-invoice	quorum	0

Publish and Send

MassTransit brings the difference between an event and a command into the API. An event is published: the publisher does not know who is listening, and zero or more consumers may receive it. A command is sent: it has a single, specific recipient. The Order API:

app.MapPost("/orders", async (PlaceOrderRequest request, IPublishEndpoint publisher, CancellationToken ct) =>
{
    OrderPlaced message = new(Guid.CreateVersion7(), request.CustomerId,
        request.Items.Sum(i => i.Quantity * i.UnitPrice), "TRY", DateTimeOffset.UtcNow, request.Items);

    // event: publish without knowing who listens
    await publisher.Publish(message, context => context.CorrelationId = message.OrderId, ct);
    return Results.Accepted($"/orders/{message.OrderId}", new { message.OrderId });
});

app.MapPost("/orders/{orderId:guid}/invoice", async (Guid orderId, decimal amount, ISendEndpointProvider sendEndpoints, CancellationToken ct) =>
{
    // command: send to a single, specific recipient
    ISendEndpoint endpoint = await sendEndpoints.GetSendEndpoint(new Uri("queue:billing-create-invoice"));
    await endpoint.Send(new CreateInvoice(orderId, amount, "TRY"), ct);
    return Results.Accepted();
});

Billing's log after the two requests:

info: MassTransit[0]
      Configured endpoint billing-order-placed, Consumer: Bookstore.Billing.MassTransit.OrderPlacedConsumer
info: MassTransit[0]
      Configured endpoint billing-create-invoice, Consumer: Bookstore.Billing.MassTransit.CreateInvoiceConsumer
info: MassTransit[0]
      Bus started: rabbitmq://localhost:17201/
info: Bookstore.Billing.MassTransit.CreateInvoiceConsumer[0]
      Manual invoice requested for order 01a1b8de-6a5a-70fd-8aab-7f9dd66cdbe7: 99.90 TRY
info: Bookstore.Billing.MassTransit.OrderPlacedConsumer[0]
      Invoice created for order 01a1b907-2d51-7348-88d5-65e386490dea: 1220.50 TRY (MessageId dd880000-0000-0000-a768-08df3d41a011, CorrelationId 01a1b907-2d51-7348-88d5-65e386490dea)

MassTransit generated the MessageId itself; we set the CorrelationId to the order id when publishing. We did not write any publisher confirmation setting in the Order API: MassTransit's RabbitMQ transport makes Publish wait until the broker confirms. The distinction between commands and events as message types is in the Messaging and Event-Driven Architecture series post "Message Types: Commands, Events and Queries".

Topology Conventions and the Message Envelope

The answer to the first question is in RabbitMQ. The part MassTransit built, from the output of rabbitmqctl list_exchanges and list_bindings:

Bookstore.Contracts:CreateInvoice	fanout
Bookstore.Contracts:OrderPlaced	fanout
billing-create-invoice	fanout
billing-order-placed	fanout

Bookstore.Contracts:CreateInvoice	billing-create-invoice	exchange
billing-create-invoice	billing-create-invoice	queue
Bookstore.Contracts:OrderPlaced	billing-order-placed	exchange
billing-order-placed	billing-order-placed	queue

MassTransit does not use a topic exchange with routing keys. For each message type it creates a fanout exchange named after the type's full name (Bookstore.Contracts:OrderPlaced), creates another fanout exchange with the same name as the queue for each receive endpoint, and binds the exchanges of the message types the consumer listens to onto it. Publish goes to the message type's exchange. So the answer is no: the raw Billing listens on bookstore.ordering, and MassTransit writes nothing there. This routing makes the message type the contract itself: a new consumer subscribes just by writing IConsumer<OrderPlaced>, no routing key design needed. But it does not fit a broker whose topology was designed by hand; if you use both together, you need to bind the exchanges to each other.

The second difference is in the message itself. A message read from a test queue we bound to the Bookstore.Contracts:OrderPlaced exchange (host section shortened):

content_type: application/vnd.masstransit+json
{
  "messageId": "dd880000-0000-0000-e34e-08df3d41b4fd",
  "requestId": null,
  "correlationId": "01a1b907-b67c-7a10-ac4c-a2820aa6dbc0",
  "conversationId": "dd880000-0000-0000-e47a-08df3d41b4fd",
  "initiatorId": null,
  "sourceAddress": "rabbitmq://localhost:17201/Mac_BookstoreOrderApiMassTransit_bus_5sryyyyyyyyyya9qbdxu4oc9g6?temporary=true",
  "destinationAddress": "rabbitmq://localhost:17201/Bookstore.Contracts:OrderPlaced",
  "responseAddress": null,
  "faultAddress": null,
  "messageType": [
    "urn:message:Bookstore.Contracts:OrderPlaced"
  ],
  "message": {
    "orderId": "01a1b907-b67c-7a10-ac4c-a2820aa6dbc0",
    "customerId": "3f2b8c1e-7d4a-4e9b-9c55-1a2b3c4d5e6f",
    "total": "450.00",
    "currency": "TRY",
    "placedAt": "2026-11-08T02:40:52.860523+00:00",
    "items": [
      {
        "bookId": "0b9d6a4e-1c2f-4a8b-b3d7-5e6f7a8b9c0d",
        "quantity": 1,
        "unitPrice": "450.00"
      }
    ]
  },
  "expirationTime": null,
  "sentTime": "2026-11-08T02:40:52.860603Z",
  "headers": {}
}

The body is inside MassTransit's envelope. OrderPlaced is in the message field; ids, addresses and the message type are at the top level of the envelope. Notice one detail: decimal fields are written as strings, like "450.00". The raw consumers from the earlier posts of the series cannot read this message directly: they do not know the envelope, and they expect total as a number. If you will talk to systems outside MassTransit, you need to enable the raw JSON (JavaScript Object Notation) serializer (UseRawJsonSerializer) for those endpoints; then you read the information the envelope carried from broker headers.

Same Code, Another Transport

MassTransit's promise is that consumer code is independent of the transport: in the Program.cs above, when the Transport setting is AzureServiceBus, UsingAzureServiceBus kicks in and OrderPlacedConsumer runs without a single line changed. On Azure Service Bus, in place of RabbitMQ's exchanges, MassTransit creates topics derived from the message type's name, a queue for each endpoint, and subscriptions that forward from the topic to the queue.

We could not verify this in this post's environment, and we should say so honestly: MassTransit uses Service Bus's administration client to build the topology; the emulator supports that client on a separate HTTP (Hypertext Transfer Protocol) management port (5300 inside the container). In our environment we mapped 17220 for AMQP (Advanced Message Queuing Protocol) and 17221 for management, and a single connection string could not express both ports. Billing went into a retry loop with this error:

info: MassTransit[0]
      Retrying 00:00:06.9222259: ReceiveTransport faulted: sb://localhost:17220/billing-create-invoice
      MassTransit.ServiceBusConnectionException: ReceiveTransport faulted: sb://localhost:17220/billing-create-invoice
       ---> System.AggregateException: Retry failed after 4 tries. Retry settings can be adjusted in ClientOptions.Retry or by configuring a custom retry policy in ClientOptions.RetryPolicy. (An error occurred while sending the request.) (An error occurred while sending the request.) (An error occurred while sending the request.) (An error occurred while sending the request.)
       ---> Azure.RequestFailedException: An error occurred while sending the request.
       ---> System.Net.Http.HttpRequestException: An error occurred while sending the request.
       ---> System.Net.Http.HttpIOException: The response ended prematurely. (ResponseEnded)

Running the emulator on its default ports (5672 and 5300) should avoid this problem, but we did not try it. The general lesson: the "same code runs on every transport" promise holds for consumer code, but topology setup, permissions (MassTransit needs the Manage right on Service Bus to create entities) and the local development environment vary by transport. In production, creating the entities with infrastructure code and turning off MassTransit's topology setup is also a common option.

What You Gain and What You Lose

Putting the two versions of Billing side by side:

TopicRaw clientMassTransit
Consumer codeChannel, ack, deserialization, shutdown by handIConsumer<T>, scoped DI
TopologyYou design it (routing keys, policies)Convention: an exchange/topic per message type
Message formatYour JSON, readable from any languageEnvelope; raw serializer to talk to the outside
Retry, error queue, outboxYou write themReady-made (as middleware)
Changing transportRewriting the consumerOne block in registration
Broker-specific featuresDirect accessSometimes limited or under a different name
Dependency and licenceThe broker clientThe library + a commercial licence in v9

Kafka sits in a separate place in this table. MassTransit supports Kafka as a "rider", meaning Kafka is not the bus's main transport but an add-on running alongside it. In a Kafka-heavy system, using Confluent.Kafka directly as in the fifth post of the series is usually less surprising.

The Licensing Reality: MassTransit v9 and Alternatives

The answer to the second question. With MassTransit 9.2.5 and no licence setting:

fail: Microsoft.Extensions.Hosting.Internal.Host[11]
      Hosting failed to start
      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 application did not start at all. MassTransit v9 was released in January 2026 (9.0.0 on NuGet, 6 January 2026) as a commercial product of the company Massient. As of the time of writing, massient.com says: v9 and later require a licence; there are flat-rate annual subscriptions with no per-message or per-endpoint fees; companies with less than 1 million USD in gross annual revenue, and nonprofits with annual expenses under that threshold, may qualify for a 100% licence discount (commercial support not included). The licence key is supplied through the MT_LICENSE or MT_LICENSE_PATH environment variable, or with SetLicense/SetLicenseLocation on the bus configurator. v8 and earlier stay under their open-source licences (Apache-2.0 on NuGet for 8.5.12), but according to massient.com support for v8 ends on 31 December 2026. Prices and terms can change; check the site yourself before deciding.

Options for a new project, with licence information from NuGet. The MIT (Massachusetts Institute of Technology) licence in the table is, like Apache-2.0, a permissive open-source licence that allows commercial use:

OptionLicenceWhen it makes sense
MassTransit v9Commercial (Massient), conditional free discountTeams that want MassTransit's mature features (sagas, outbox, test harness) and can cover the licence
Staying on MassTransit v8Apache-2.0An existing system with a short-term migration plan; do not expect fixes after support ends
WolverineMITNew projects that want messaging + a handler pipeline + a durable outbox; supports RabbitMQ, Azure Service Bus and Kafka
RebusMITA smaller, simpler service bus; teams that want little magic
NServiceBusCommercial (Particular Software)Organisations that want a support contract and an enterprise tool set
BrighterMITThose who want to combine the command processor approach with messaging
Raw clientThe broker client's licenceOne broker, few consumers, full control over broker features

This table is not a ranking. The questions that decide: how much the team has invested in MassTransit, the state of the licence budget and the revenue threshold, and in which option the features you actually need (sagas, outbox, scheduling) are mature. In the rest of the series we will explain retry, dead-lettering, inbox and outbox first with the raw client and then show the MassTransit and Wolverine counterparts; that way, whichever abstraction you choose, you will know what is underneath. The details of migrating from MassTransit 8 to 9 are in the last post of the series.

Production Notes

Fix the conventions early. Endpoint names and exchange names turn into persistent objects on the broker; changing them later means draining and migrating old queues. Give the endpoint name explicitly instead of tying it to the consumer class's name.

Check the queue type. As we saw, MassTransit 8.5.12 created classic queues on RabbitMQ; if you want quorum, configure it explicitly and verify it on the broker before deploying.

Make the licence part of the deploy pipeline. In v9, if the licence key is missing, the application does not start; supply the key from the secret store as MT_LICENSE and put the subscription's expiry date on the calendar. According to the documentation, when the licence expires MassTransit logs a warning but does not stop running services.

Think about the envelope and serialization at every boundary facing outward. For messages that a consumer written in another language will read, use raw JSON and an explicit contract.

Common Pitfalls

Thinking the abstraction will hide the broker completely. Prefetch, lock duration, queue type and partition decisions are still yours; they are just configured somewhere else.

Mixing MassTransit and the raw client in the same message flow without realising it. Because of different exchanges and the envelope format, messages either never arrive or cannot be read.

Leaving the package version as * or open-ended. A build that automatically moves from v8 to v9 fails at startup for lack of a licence key.

Swallowing exceptions inside a consumer. If Consume returns successfully the message is acked; if you log and swallow the error, MassTransit's retry and error queue mechanisms never kick in.

Postponing the licence decision and starting a new project on v8. The end-of-support date is close; account for the migration cost today.

A Small Experiment

★☆☆ Write a second IConsumer<OrderPlaced> for Notification and register it on the notification-order-placed endpoint. Send an order and find the new binding in the rabbitmqctl list_bindings output.

★★☆ Apply UseRawJsonSerializer to the billing-order-placed endpoint and get a message published by the raw OrderEventPublisher from the second post to the MassTransit consumer. Which exchange did you have to bind to which?

★★★ Write the same Billing with Wolverine: a handler, the RabbitMQ transport and the same OrderPlaced contract. Compare the two applications' lines of code, the topology they create and their message formats.

Billing now consumes messages through a single interface. But what happens when the invoice service is unreachable for a moment? In the next post we cover retry and redelivery in consumers: transient versus permanent errors, immediate, delayed and exponential backoff, and their counterparts in RabbitMQ, Azure Service Bus, Kafka and MassTransit.