Messaging in .NET connects services through a durable, asynchronous channel instead of a direct call, so a slow or unavailable consumer never blocks a producer. This guide is for engineers who already build synchronous APIs and now need to pick and operate a broker: Azure Service Bus, RabbitMQ or Kafka, driven by the official .NET clients or a higher-level framework such as MassTransit, NServiceBus, Wolverine or Brighter. You will learn what delivery guarantee each option actually gives you, how to keep consumers idempotent, how the transactional outbox and inbox close the gap between a database write and a published message, how ordering and dead-lettering work, and how to choose between the three brokers with a clear head instead of by reputation.

What Is Messaging in .NET?#

Messaging is communication through an intermediary, the broker, rather than a direct network call between two services. A producer publishes a message and moves on; the broker stores it durably and delivers it to one or more consumers on their own schedule. That single design choice buys temporal decoupling (the consumer does not need to be online when the message is sent), load leveling (a burst of writes becomes a steady stream of work for the consumer), and failure isolation (a consumer outage delays processing instead of cascading a failure back to the caller, unlike a synchronous gRPC or REST call).

.NET has first-class clients for every major broker, plus a layer of frameworks that add routing, retries, sagas and outboxes on top: Azure.Messaging.ServiceBus for Azure Service Bus, RabbitMQ.Client for RabbitMQ, and Confluent.Kafka for Apache Kafka, with MassTransit, NServiceBus, Wolverine and Brighter providing a broker-agnostic programming model. A message in this guide covers both commands (an instruction sent to exactly one handler, such as "ChargeCard") and events (a fact broadcast to whoever is interested, such as "OrderPlaced"); the distinction shapes whether you reach for a queue or a topic, covered next.

How Messaging Works: Queues, Topics and Delivery Guarantees#

Every broker in this guide gives you two delivery shapes, named differently but conceptually the same:

  • Point-to-point (a queue). One message is delivered to exactly one consumer, even with several consumer instances competing for work. Use it for commands and work items. Service Bus and RabbitMQ call this a queue directly; Kafka approximates it with a single-partition topic and one consumer group.
  • Publish/subscribe (a topic). Each independent subscriber gets its own copy of every message. Use it for events that several parts of the system care about. Service Bus implements this as a topic with one or more subscriptions; RabbitMQ as an exchange bound to several queues; Kafka as a topic read by several consumer groups, each tracking its own offset.

Delivery guarantees are the part people get wrong most often, so name them precisely:

GuaranteeWhat it meansHow you get itWhat still breaks
At-most-onceA message is delivered zero or one timesFire-and-forget send, auto-ack on receiveSilent message loss on any crash
At-least-onceA message is delivered one or more timesAck only after processing succeeds (the default posture for all three brokers)Duplicate delivery on crash or timeout; consumers must be idempotent
Effectively-onceDuplicates are suppressed so the effect happens onceAt-least-once delivery plus deduplication (an inbox, a Kafka transaction, Service Bus duplicate detection)Only covers the messaging layer; a side effect outside that scope can still duplicate

None of the three brokers gives you true exactly-once delivery to an arbitrary consumer out of the box. Kafka's idempotent producer and read-committed transactions give exactly-once within a Kafka-to-Kafka pipeline; Service Bus's duplicate detection window catches resends of the same MessageId for a limited time. For everything else, at-least-once plus an idempotent consumer is the guarantee you should design for, and it is enough.

Getting Started: Sending and Receiving with Azure Service Bus#

Azure.Messaging.ServiceBus is the current SDK for Azure's managed broker. ServiceBusClient, its senders, receivers and processors are safe to hold as singletons for the life of the application. This example authenticates with DefaultAzureCredential (no connection string to leak) and processes messages with a ServiceBusProcessor, settling each one explicitly:

C#
using Azure.Identity;
using Azure.Messaging.ServiceBus;

var fullyQualifiedNamespace = "contoso.servicebus.windows.net";
await using var client = new ServiceBusClient(fullyQualifiedNamespace, new DefaultAzureCredential());

var sender = client.CreateSender("orders");
await sender.SendMessageAsync(new ServiceBusMessage("OrderPlaced:42") { MessageId = "order-42" });

var options = new ServiceBusProcessorOptions
{
    AutoCompleteMessages = false, // settle explicitly so failures can abandon or dead-letter
    MaxConcurrentCalls = 8
};

await using var processor = client.CreateProcessor("orders", options);
processor.ProcessMessageAsync += async args =>
{
    try
    {
        await HandleAsync(args.Message.Body.ToString(), args.CancellationToken);
        await args.CompleteMessageAsync(args.Message, args.CancellationToken);
    }
    catch (Exception ex)
    {
        await args.DeadLetterMessageAsync(args.Message, ex.GetType().Name, ex.Message);
    }
};
processor.ProcessErrorAsync += args =>
{
    Console.Error.WriteLine($"{args.ErrorSource}: {args.Exception}");
    return Task.CompletedTask;
};

await processor.StartProcessingAsync();

static Task HandleAsync(string body, CancellationToken ct) => Task.CompletedTask;

Queues give point-to-point delivery; add a topic with subscriptions (each with its own SQL or correlation filter) when several independent consumers need their own copy of the same event.

RabbitMQ.Client: Exchanges, Queues and the Async API#

RabbitMQ separates routing from storage: producers publish to an exchange, and bindings copy matching messages into one or more queues. RabbitMQ.Client 7 rewrote the API around Task-based asynchronous I/O; IModel from earlier versions is gone, replaced by IChannel. RabbitMQ 4.0 also removed classic mirrored queues in favor of quorum queues, a Raft-replicated queue type with built-in poison-message handling, and it now speaks AMQP 1.0 as its core protocol alongside the classic AMQP 0-9-1 API this client uses.

C#
using RabbitMQ.Client;
using RabbitMQ.Client.Events;

var factory = new ConnectionFactory { HostName = "localhost" };
await using var connection = await factory.CreateConnectionAsync();

// Publisher confirms turn "BasicPublishAsync" into an acknowledged, at-least-once send.
var channelOptions = new CreateChannelOptions(
    publisherConfirmationsEnabled: true, publisherConfirmationTrackingEnabled: true);
await using var channel = await connection.CreateChannelAsync(channelOptions);

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

var body = System.Text.Encoding.UTF8.GetBytes("""{"orderId":42}""");
await channel.BasicPublishAsync("orders.events", "order.placed", mandatory: true, body: body);

var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (_, ea) =>
{
    // ea.Body is only valid for the lifetime of this handler; copy it if you need to keep it.
    var message = System.Text.Encoding.UTF8.GetString(ea.Body.Span);
    await ProcessAsync(message);
    await channel.BasicAckAsync(ea.DeliveryTag, multiple: false);
};
await channel.BasicConsumeAsync("orders.fulfillment", autoAck: false, consumer);

static Task ProcessAsync(string message) => Task.CompletedTask;

A topic exchange with routing keys such as order.placed and order.cancelled gives you selective pub/sub without Service Bus's SQL filter syntax; a fanout exchange is the equivalent of broadcasting to every bound queue unconditionally.

Apache Kafka with Confluent.Kafka#

Kafka's model differs from a traditional broker in one important way: it does not delete messages on consumption. A topic is an append-only, partitioned log; consumers track their own position (the offset) and can replay history. Ordering is guaranteed only within a partition, so events that must stay in order, such as everything for one order ID, need the same partition key.

C#
using Confluent.Kafka;

var producerConfig = new ProducerConfig { BootstrapServers = "localhost:9092" };
using var producer = new ProducerBuilder<string, string>(producerConfig).Build();

var result = await producer.ProduceAsync("order-events",
    new Message<string, string> { Key = "order-42", Value = """{"status":"placed"}""" });
Console.WriteLine($"Wrote to {result.TopicPartitionOffset}");

var consumerConfig = new ConsumerConfig
{
    BootstrapServers = "localhost:9092",
    GroupId = "fulfillment-service",
    AutoOffsetReset = AutoOffsetReset.Earliest,
    EnableAutoCommit = false // commit only after a message is fully processed
};
using var consumer = new ConsumerBuilder<string, string>(consumerConfig).Build();
consumer.Subscribe("order-events");

while (!cancellationToken.IsCancellationRequested)
{
    var result2 = consumer.Consume(cancellationToken);
    await ProcessAsync(result2.Message.Key, result2.Message.Value);
    consumer.Commit(result2);
}

static Task ProcessAsync(string key, string value) => Task.CompletedTask;

Two defaults are worth overriding deliberately. EnableAutoCommit defaults to true with a five-second interval, which can advance past a message your consumer never finished processing; turn it off and commit only after work completes, as above. EnableIdempotence defaults to false; turning it on caps in-flight requests and lets the broker deduplicate retried writes, so a producer retry cannot silently create a duplicate record in the log. Host the poll loop in a BackgroundService; since .NET 10, ExecuteAsync runs entirely on a background thread from the start, so a long-running Consume loop no longer delays the rest of the host's startup the way it could when only the code after the first await ran in the background.

Ordering and Sessions#

Scaling consumers out and keeping messages in order pull in opposite directions, so each broker offers a narrower guarantee than "everything is ordered." Service Bus uses sessions: messages that share a SessionId are grouped and locked to one receiver at a time, so a session-enabled queue behaves like many independent FIFO queues multiplexed onto one entity. Sessions require the standard or premium tier; the basic tier does not support them.

C#
var message = new ServiceBusMessage("OrderPlaced:42") { SessionId = "order-42" };
await sender.SendMessageAsync(message);

await using var sessionProcessor = client.CreateSessionProcessor("orders-ordered");
sessionProcessor.ProcessMessageAsync += async args =>
{
    await HandleAsync(args.Message.Body.ToString(), args.CancellationToken);
    await args.CompleteMessageAsync(args.Message);
};
await sessionProcessor.StartProcessingAsync();

static Task HandleAsync(string body, CancellationToken ct) => Task.CompletedTask;

Kafka reaches the same result through partition keys: messages with the same key always land on the same partition, and a partition is read by exactly one consumer within a group, so per-key order falls out of the partitioning scheme for free (this is why the Kafka sample above keyed every message by order-42). RabbitMQ keeps a single queue strictly ordered by default, since one queue normally has one active consumer per message; a single active consumer queue keeps that guarantee even with several consumer instances connected, failing over to the next one only when the active consumer disconnects.

Idempotent Consumers and the Inbox Pattern#

Because at-least-once is the realistic guarantee, every consumer that causes a side effect must tolerate processing the same message twice. Two techniques cover almost every case. Natural idempotency makes the operation safe to repeat by construction: an upsert keyed by business ID, a SET status = 'shipped' that is a no-op the second time, or a payment gateway call made with the same idempotency key on every retry. The inbox pattern covers everything else: record each processed message ID in the same local transaction as its side effect, and skip messages already recorded.

C#
public sealed class OrderEventHandler(AppDbContext db)
{
    public async Task HandleAsync(string messageId, OrderPlaced evt, CancellationToken ct)
    {
        await using var transaction = await db.Database.BeginTransactionAsync(ct);

        if (await db.ProcessedMessages.AnyAsync(m => m.MessageId == messageId, ct))
        {
            return; // already handled; the broker redelivered it
        }

        db.Orders.Add(new OrderRecord(evt.OrderId, evt.Total));
        db.ProcessedMessages.Add(new ProcessedMessage(messageId, DateTimeOffset.UtcNow));
        await db.SaveChangesAsync(ct);
        await transaction.CommitAsync(ct);
    }
}

// A unique index on ProcessedMessages.MessageId turns a race between two redeliveries
// into a constraint violation on the loser instead of two inserted orders.

The Transactional Outbox#

Publishing and writing to a database are two systems that cannot share one transaction, so a process that crashes between them either loses the event or announces a change that never committed. The microservices guide walks through building this outbox by hand with a relay BackgroundService; in day-to-day work it is usually simpler to let a framework own it. Brighter, for example, builds the outbox into its command processor: DepositPost writes the message to an outbox table inside your existing transaction, and ClearOutbox dispatches it once that transaction has committed.

C#
await using var transaction = await connection.BeginTransactionAsync(ct);

db.Orders.Add(order);
await db.SaveChangesAsync(ct);

var messageId = await commandProcessor.DepositPostAsync(
    new OrderPlacedEvent(order.Id, order.Total), transaction, cancellationToken: ct);

await transaction.CommitAsync(ct);
await commandProcessor.ClearOutboxAsync([messageId], cancellationToken: ct);

Wolverine takes a different route to the same guarantee: point it at a durable message store with opts.PersistMessagesWithSqlServer(connectionString) and enable EF Core transaction enlistment, and every outgoing message from a handler is written to that store in the same transaction as the handler's database work, with no separate outbox table to design.

Dead-Lettering and Retry Policies#

A message that keeps failing has to go somewhere other than back to the front of the queue forever. Each broker handles this differently:

BrokerRedelivery triggerDead-letter mechanismDefault limit
Azure Service BusLock expiry, AbandonMessageAsync, or an unhandled exception in a processorAutomatic: moves to a built-in $DeadLetterQueue subqueueMaxDeliveryCount = 10
RabbitMQ (quorum queue)Nack, requeue, or consumer cancellationOpt-in: set x-dead-letter-exchange (and optionally a routing key) as a queue argumentDelivery limit = 20
KafkaNo broker-level redelivery; the consumer controls retryNone built in: applications publish to a *.DLQ topic or a backoff "retry topic" themselvesNot applicable

Read a dead-lettered Service Bus message with a receiver scoped to the subqueue:

C#
var dlqReceiver = client.CreateReceiver("orders", new ServiceBusReceiverOptions
{
    SubQueue = SubQueue.DeadLetter
});
var dlqMessage = await dlqReceiver.ReceiveMessageAsync();
Console.WriteLine($"{dlqMessage.DeadLetterReason}: {dlqMessage.DeadLetterErrorDescription}");

Whichever broker you use, prefer a short run of immediate retries for transient faults (a lock timeout, a dropped connection) backed by a longer, delayed redelivery for faults that need time to clear (a downstream outage), and reserve the dead letter for messages a human needs to look at. Pairing broker-level retry with the resilience patterns from Polly for the calls a handler makes to other services covers both layers of failure.

Choosing a Messaging Framework: MassTransit, NServiceBus, Wolverine and Brighter#

The raw clients above are enough for a handful of message types; most systems eventually want routing conventions, saga state machines, and a built-in outbox, which is what these frameworks add on top of the same brokers.

FrameworkLicenseStyleBuilt-in outboxNotable transports
MassTransit9.x is commercial (Massient); the 8.x line stays Apache-2.0Bus with conventions, consumers, sagasYes (EF Core, and others)RabbitMQ, Azure Service Bus, Kafka rider, Amazon SQS
NServiceBusCommercial (Particular Software), with a free tier for small teamsEndpoint-centric, message handlers, sagasYes (via outbox feature)RabbitMQ, Azure Service Bus, Amazon SQS, SQL Transport
Wolverine (JasperFx)Open source (MIT)Handlers by naming convention, in-process mediator and busYes (durable SQL Server/Postgres-backed storage)RabbitMQ, Azure Service Bus, Kafka, local/in-memory queues
Brighter (Paramore)Open source (MIT)Command dispatcher/processor, middleware pipeline via attributesYes (DepositPost/ClearOutbox)RabbitMQ, Kafka, Amazon SQS/SNS, Azure Service Bus, Redis

Verify the current MassTransit license before you commit to it for a new project: the 9.x line ships as a commercial product that must be licensed through Massient, while the last Apache-2.0 releases stay on the 8.x line and remain fully usable, including on .NET 10. A minimal MassTransit consumer over RabbitMQ, with an immediate retry for transient failures, looks like this:

C#
builder.Services.AddMassTransit(x =>
{
    x.AddConsumer<OrderPlacedConsumer>();
    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.Host("localhost");
        cfg.UseMessageRetry(r => r.Immediate(3));
        cfg.ConfigureEndpoints(context);
    });
});

public sealed class OrderPlacedConsumer : IConsumer<OrderPlaced>
{
    public Task Consume(ConsumeContext<OrderPlaced> context) =>
        Task.CompletedTask; // context.Message, context.Publish, context.RedeliveryCount are available here
}

For local development, .NET Aspire can stand up any of these brokers as a container and wire the connection into your services without touching configuration files by hand, using hosting integrations such as AddRabbitMQ, AddAzureServiceBus().RunAsEmulator() and Kafka's producer and consumer client integrations. Wolverine's message store can also be scoped per module rather than per service, which pairs naturally with in-process messaging inside a modular monolith before you have split anything into separate deployables.

Best Practices#

  • Design consumers to be idempotent before you design them to be fast. Redelivery is not an edge case with at-least-once messaging; it is the normal case under load and after any deployment.
  • Make messages small and self-contained. Send the data a consumer needs, not a pointer that requires calling back into the producer, and keep large payloads (files, images) out of the message body.
  • Version message contracts deliberately. Add optional fields, never repurpose or remove one a consumer might still read, and keep producers and consumers deployable in any order.
  • Use the transactional outbox for every message that follows a database write. A dual write without one is a bug that only shows up in production under load.
  • Set a short immediate retry and a longer delayed retry, not one giant retry loop. Distinguish faults that clear in milliseconds from ones that need minutes.
  • Monitor queue depth and dead-letter counts as first-class metrics, not as something you check only when someone complains.
  • Prefer a session or partition key over a single global queue when you need order for a subset of messages; do not serialize the whole system to get order for one entity.

Common Pitfalls#

Treating a queue as free reliability. A message sitting in a queue is not processed until a consumer acknowledges it; an idle or crash-looping consumer silently grows a backlog that paging usually catches too late.

Auto-committing Kafka offsets around slow work. With EnableAutoCommit at its five-second default, a consumer can commit an offset for a message it has not finished handling, then crash and skip it on restart.

Building a saga without a pivot point. A multi-step workflow needs a clear step after which it only moves forward; compensating a step that already has irreversible side effects (an email sent, a shipment dispatched) is not possible.

Assuming FIFO by default. None of these three brokers is globally ordered; ordering is a guarantee you opt into (a session, a partition key, a single active consumer) for the subset of messages that need it.

Skipping the dead letter queue. Messages that fail forever and get silently discarded, or endlessly redelivered without a limit, both hide the same underlying bug from the team that needs to see it.

Service Bus vs RabbitMQ vs Kafka: Choosing a Broker#

CriterionAzure Service BusRabbitMQKafka
ModelManaged queue/topic brokerSelf-hosted or managed AMQP brokerDistributed, partitioned commit log
OrderingPer sessionPer queue (or single active consumer)Per partition
Replay historyNo (messages are consumed and removed)NoYes, by resetting consumer offsets
RoutingQueues, topics, SQL/correlation filtersExchanges (direct, topic, fanout, headers)Topic and partition key only
Dead-letteringAutomatic, built inOpt-in via queue argumentsNot built in
Best forEnterprise integration, Azure-native workloads, ordered workflows via sessionsFlexible routing, on-premises or multi-cloud, moderate throughputEvent streaming, high throughput, replay and stream processing
Operational modelFully managedYou operate the cluster (or use a managed offering)You operate the cluster (or use a managed offering, such as Confluent Cloud)

Reach for Service Bus first inside Azure when you want a managed service with sessions, filters and dead-lettering out of the box. Reach for RabbitMQ when you need flexible, protocol-level routing or are not committed to one cloud. Reach for Kafka when the workload is genuinely about a stream of events that several systems need to read, replay, or process at very high throughput, not just a work queue.

Frequently Asked Questions#

Should I use a queue or a topic for a given message?#

Use a queue when exactly one handler should process each message, such as a command like "charge this card." Use a topic (or, on Kafka, several consumer groups reading the same topic) when the message is a fact that more than one independent part of the system needs to react to, such as "order placed" triggering billing, fulfillment and analytics separately.

Does Kafka guarantee message ordering?#

Only within a partition. Kafka guarantees that messages with the same partition key are appended and read in the order they were produced, but it makes no ordering promise across partitions. Design your partition key around the entity that needs order, such as an order ID or a customer ID, and accept that unrelated entities can be processed out of order relative to each other.

How do I make a message consumer idempotent?#

Prefer natural idempotency, such as an upsert keyed by business ID, wherever the operation allows it. When the side effect cannot be made naturally repeatable, record each processed message ID in an inbox table inside the same local transaction as the side effect, and skip any message whose ID you have already recorded.

Is MassTransit still free to use?#

The 8.x line of MassTransit remains under the Apache-2.0 license and continues to work on current .NET versions. Starting with the 9.x line, MassTransit is a commercial product that must be licensed through Massient; check the license terms on the package before adopting the latest version for a new project.

What's the difference between Service Bus sessions and Kafka partition keys?#

Both group related messages so one worker processes them in order, but the mechanics differ: a Service Bus session locks an entire session's message stream to one receiver at a time on a session-enabled queue or subscription, while a Kafka partition key deterministically routes messages to one partition, which is then read by exactly one consumer within its consumer group.

Summary#

  • Messaging trades immediate consistency for temporal decoupling, load leveling and failure isolation; pick it when a synchronous call is the wrong shape for the problem.
  • At-least-once delivery is the realistic default across Service Bus, RabbitMQ and Kafka, so idempotent consumers and an inbox are not optional extras.
  • Pair every database write that must also publish a message with a transactional outbox, whether hand-rolled or provided by your framework.
  • Order messages deliberately with sessions, partition keys or a single active consumer; none of these brokers is globally ordered by default.
  • Choose Service Bus for managed Azure integration, RabbitMQ for flexible routing, and Kafka for high-throughput event streams with replay.
  • MassTransit's 9.x line is now commercial; Wolverine and Brighter remain fully open source (MIT) with built-in outbox support.

Further Reading#