Steven's Knowledge

Messaging

Asynchronous messaging in .NET with MassTransit over RabbitMQ and Azure Service Bus -- consumers, retries, sagas, and the transactional outbox

Messaging

Asynchronous messaging decouples services in time. A producer publishes an event and moves on -- it does not wait for anyone to handle it, and it does not care who does. Consumers process that message independently, on their own schedule, with the broker sitting in the middle to absorb load spikes and to hold messages safely while a consumer is down or redeploying. This is the opposite of a synchronous call, where the caller blocks until the callee answers and a downstream outage becomes the caller's outage too.

In .NET you can talk to a broker directly through its SDK -- RabbitMQ.Client for RabbitMQ, Azure.Messaging.ServiceBus for Azure Service Bus -- but most teams reach for MassTransit instead. MassTransit is an abstraction that sits on top of those transports and handles serialization, routing, retries, and a pile of conventions for you, supporting RabbitMQ, Azure Service Bus, and Amazon SQS behind one programming model. You write transport-agnostic consumers, and you can swap the underlying broker through configuration rather than a rewrite. The rest of this page is the .NET-specific how-to, centered on MassTransit because it is the dominant framework in this space.

Commands vs Events

MassTransit pushes you to model two distinct intents, and getting the distinction right shapes your whole topology. A command is sent to exactly one consumer -- it expresses "do this," is named imperatively (SubmitOrder), and the sender knows which endpoint should handle it. An event is published to all interested subscribers -- it expresses "this happened," is named in the past tense (OrderSubmitted), and the publisher neither knows nor cares who is listening. Send versus publish is the routing decision: a send targets one queue, a publish fans out to every subscriber bound to that message type.

Defining Messages and a Consumer

Messages are plain records with no base class and no attributes -- their shape is the contract. A consumer implements IConsumer<T> for the message type it handles, and MassTransit routes a deserialized message into its Consume method.

public record OrderSubmitted(Guid OrderId, string CustomerId, decimal Amount);

public class OrderSubmittedConsumer(IEmailSender email) : IConsumer<OrderSubmitted>
{
    public async Task Consume(ConsumeContext<OrderSubmitted> context)
    {
        var msg = context.Message;
        await email.SendAsync(msg.CustomerId, $"Order {msg.OrderId} received",
            context.CancellationToken);
    }
}

The ConsumeContext<T> is more than a wrapper around the payload. It carries the message itself on .Message, the transport headers, a CancellationToken tied to the consumer's lifetime, and the ability to publish further events or respond to the sender from inside the handler. MassTransit dispatches by message type -- it deserializes the incoming envelope, matches its type to your registered IConsumer<T>, and resolves the consumer (and its dependencies, like IEmailSender) from the DI container per message, with scoped lifetimes behaving exactly as they do in a web request.

Registration and Configuration

You wire MassTransit into the generic host the same way you wire everything else, then pick a transport. Registering RabbitMQ looks like this:

builder.Services.AddMassTransit(x =>
{
    x.AddConsumer<OrderSubmittedConsumer>();
    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.Host("rabbitmq://localhost");
        cfg.ConfigureEndpoints(context);
    });
});

ConfigureEndpoints(context) is doing real work: it walks every registered consumer and, by convention, creates a receive endpoint -- a queue -- for each one, names it from the consumer (kebab-cased), and binds the message types it handles. You get a sensible topology without hand-declaring queues and exchanges. The payoff of the abstraction shows up when you change transports: switching to Azure Service Bus is UsingAzureServiceBus(...) with a connection string, and the consumers themselves do not change a line. On the producing side you publish through IPublishEndpoint.Publish(...) for events, send through ISendEndpoint for commands, and IBus exposes both when you are outside a consumer.

Retries, Redelivery, and the Error Queue

Reliability is the entire reason you took on a broker, so the retry configuration is where the real engineering lives. MassTransit distinguishes immediate retries -- a few fast attempts in-process for transient blips like a momentary connection drop -- from second-level redelivery, which puts the message back on the queue with a delay so a downstream service has time to recover. You configure these per receive endpoint:

cfg.ReceiveEndpoint("order-submitted", e =>
{
    e.UseMessageRetry(r => r.Exponential(5,
        TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5)));
    e.ConfigureConsumer<OrderSubmittedConsumer>(context);
});

When the retry budget is exhausted, MassTransit does not drop the message on the floor -- it moves it to a companion _error queue (here order-submitted_error). This is the dead-letter pattern: the failure is preserved, inspectable, and replayable once you have fixed the cause, rather than lost. The hard constraint this imposes is that your consumers must be idempotent. Brokers deliver at-least-once, so a retry, a redelivery, or a duplicate from a network hiccup means your Consume method will run more than once for some messages. Processing the same OrderSubmitted twice must not charge the customer twice or send two emails -- deduplicate on the message ID or make the side effect naturally idempotent. This is the same idempotency discipline covered language-agnostically elsewhere; here it is non-negotiable rather than aspirational.

Sagas: Stateful Workflows

Some business processes are not a single message -- they unfold over time across several. An order that must be submitted, then paid, then shipped is a long-running conversation, and nothing in a stateless consumer remembers where you are in it. A saga is the answer: a persistent state machine that receives the relevant events, advances through defined states, and survives process restarts because its state lives in a database rather than in memory. MassTransit models this with MassTransitStateMachine<TState>, where you declare the states, the events that drive transitions, and the behavior on each.

public class OrderState : SagaStateMachineInstance
{
    public Guid CorrelationId { get; set; }
    public string CurrentState { get; set; } = null!;
}

public class OrderStateMachine : MassTransitStateMachine<OrderState>
{
    public State Submitted { get; private set; } = null!;
    public State Paid { get; private set; } = null!;

    public Event<OrderSubmitted> Submitted_ { get; private set; } = null!;
    public Event<PaymentReceived> Paid_ { get; private set; } = null!;

    public OrderStateMachine()
    {
        InstanceState(x => x.CurrentState);

        Initially(
            When(Submitted_)
                .TransitionTo(Submitted));

        During(Submitted,
            When(Paid_)
                .TransitionTo(Paid));
    }
}

The state is persisted through a saga repository -- EF Core, Redis, or MongoDB are the common choices -- so when the payment event arrives an hour later, MassTransit loads the instance by correlation ID, replays it into the right state, and applies the transition. That persistence is what makes the workflow reliable across restarts and deploys. This is a deliberately abbreviated sketch; real sagas add timeouts, compensation paths, and the Finalize step that removes a completed instance, but the shape above is the core you build on.

The Transactional Outbox

There is a subtle failure mode at the seam between your database and your broker: the dual-write problem. If a request both saves an entity and publishes an event as two separate operations, a crash in the gap between them leaves you inconsistent -- the order is saved but the OrderSubmitted event never went out, or the event went out but the transaction rolled back so the order does not exist. No amount of retrying fixes this, because the two systems do not share a transaction.

The transactional outbox closes the gap. Instead of publishing directly, you write the outgoing messages into an outbox table inside the same database transaction as your state change, so they commit or roll back together. A background dispatcher then reads the outbox and publishes the messages to the broker, marking them sent. MassTransit ships this as a first-class EF Core feature:

builder.Services.AddMassTransit(x =>
{
    x.AddEntityFrameworkOutbox<AppDbContext>(o =>
    {
        o.UsePostgres();
        o.UseBusOutbox();
    });
    x.AddConsumer<OrderSubmittedConsumer>();
    x.UsingRabbitMq((context, cfg) => cfg.ConfigureEndpoints(context));
});

Enabling the outbox is the correct default for any service that both writes data and publishes events -- which is most of them. It also gives you inbox-side deduplication, closing the at-least-once loop from the other end. The cost is a couple of extra tables and a dispatch loop; the benefit is that "saved but not published" stops being a class of production incident you have to reason about.

When to Use Messaging

Messaging is a tool with a real operational footprint, so be honest about when it earns its keep. Reach for async messaging when you need to decouple services so one can deploy or fail without dragging the other down, when you want load-leveling so a traffic spike queues up instead of melting a downstream, when you need fan-out so one event drives many independent reactions, and when downstream work is long-running or unreliable and you do not want the caller waiting on it.

Do not reach for it when the caller genuinely needs an immediate, synchronous answer -- "is this username taken" wants a REST or gRPC call, not a round trip through a queue. And do not adopt a broker when the operational complexity it adds -- another piece of infrastructure to run, monitor, secure, and reason about -- is not justified by the decoupling you actually need. In NZ enterprise and Azure-centric shops, the well-trodden path is Azure Service Bus with MassTransit: it is a common, well-supported combination, and most teams here will not regret standardizing on it.

Sharp Edges

The recurring pain is concentrated in a few places. At-least-once delivery is not a corner case -- it is the contract -- so non-idempotent consumers are a latent data-corruption bug waiting for the first redelivery. Message versioning is the slow-burn problem: schemas evolve, so make changes additive and ensure consumers tolerate unknown fields, because a producer and consumer are almost never deployed at the same instant. Poison messages -- ones that fail every retry -- pile up in the _error queue, and an unmonitored error queue is an outage nobody noticed; alert on its depth. Ordering is not guaranteed: if you need messages processed in sequence, you need partitioning or Service Bus sessions, not hope. And sagas introduce state contention -- concurrent events for the same instance can collide, so lean on the repository's optimistic concurrency rather than assuming serialized access. Finally, observability ties in cleanly: MassTransit emits OpenTelemetry spans for publish and consume, so a message stays part of the distributed trace and you can follow a request as it crosses the broker instead of losing the thread at the queue boundary.

On this page