# RabbitMQ in .NET: A Beginner's Guide

> RabbitMQ decouples request handling from work that can happen later, but acknowledgments, durability, routing, and poison-message behavior decide whether the system is reliable. This guide builds the core model in .NET before adding MassTransit.

Published: 2026-10-27. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/rabbitmq-dotnet-beginners-guide

**RabbitMQ** routes messages from exchanges into queues so .NET services can process work asynchronously.
The RabbitMQ.Client 7 API supports async publishing and consumption, including publisher confirms and manual acknowledgments.
Reliable processing also needs durable topology, bounded retries, and idempotent handlers because failures can cause redelivery.

## What Is RabbitMQ?

RabbitMQ is a message broker, a middleman that accepts messages from producers and delivers them to consumers.
Instead of services calling each other directly over HTTP, they communicate through messages.
This decouples services and lets work happen asynchronously.

RabbitMQ supports [**message queue patterns**](https://milanjovanovic.tech/blog/message-queue-patterns-dotnet) without the complexity of cloud-specific services.
It can run in Docker, Kubernetes, or on a virtual machine.

Its model of exchanges, queues, and bindings teaches fundamental messaging concepts that apply to any broker.

## Core Concepts

Before writing code, understand these building blocks:

- **Producer**: Sends messages to an exchange
- **Exchange**: Routes messages to queues based on rules (bindings)
- **Queue**: Stores messages until a consumer processes them
- **Consumer**: Receives and processes messages from a queue
- **Binding**: A rule connecting an exchange to a queue

Think of it as a mail system: the producer drops mail at the post office (exchange), the post office routes it to the right mailbox (queue), and the recipient (consumer) picks it up.

![A producer publishing to an exchange that routes messages through bindings into two queues, each read by its own consumer](https://milanjovanovic.tech/blogs/articles/rabbitmq-dotnet-beginners-guide/exchange-queue-flow.png)

## Setting Up RabbitMQ

Run RabbitMQ locally with Docker:

```bash
docker run -d --name rabbitmq \
  -p 5672:5672 \
  -p 15672:15672 \
  rabbitmq:4-management
```

The management UI is available at `http://localhost:15672` (guest/guest).
Keep it open while you experiment.
Watching queues fill and drain is the fastest way to build intuition.

Install the .NET client:

```bash
dotnet new console -n RabbitDemo --framework net10.0
cd RabbitDemo
dotnet add package RabbitMQ.Client --version 7.2.2
```

Version 7 of `RabbitMQ.Client` is fully async, which is what you'll see in the examples below.

## Publishing Messages

Put the following publishing and consuming blocks in `Program.cs`, in that order.
The [**RabbitMQ .NET client guide**](https://www.rabbitmq.com/client-libraries/dotnet-api-guide) documents the async API and channel concurrency rules:

```csharp
using System.Text.Json;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;

var factory = new ConnectionFactory
{
    HostName = "localhost",
    UserName = "guest",
    Password = "guest"
};

await using var connection = await factory.CreateConnectionAsync();
await using var channel = await connection.CreateChannelAsync(
    new CreateChannelOptions(
        publisherConfirmationsEnabled: true,
        publisherConfirmationTrackingEnabled: true));

await channel.ExchangeDeclareAsync("orders-dlx", ExchangeType.Direct, durable: true);
await channel.QueueDeclareAsync("orders-dlq", true, false, false);
await channel.QueueBindAsync("orders-dlq", "orders-dlx", "orders");

await channel.QueueDeclareAsync(
    queue: "orders",
    durable: true,
    exclusive: false,
    autoDelete: false,
    arguments: new Dictionary<string, object?>
    {
        ["x-dead-letter-exchange"] = "orders-dlx",
        ["x-dead-letter-routing-key"] = "orders"
    });

var order = new OrderCreated(Guid.NewGuid(), "Widget", 2, 29.99m);
var body = JsonSerializer.SerializeToUtf8Bytes(order);

var properties = new BasicProperties
{
    Persistent = true,
    ContentType = "application/json",
    MessageId = order.OrderId.ToString()
};

await channel.BasicPublishAsync(
    exchange: string.Empty,
    routingKey: "orders",
    mandatory: true,
    basicProperties: properties,
    body: body);
```

Publishing to the empty (default) exchange with the queue name as the routing key delivers straight to that queue.
Durable queues and persistent messages allow recovery after a normal broker restart.
The confirm-enabled channel waits for broker acceptance; `mandatory: true` also reports unroutable publications as failures.
A connection failure can still leave the producer uncertain whether a publish succeeded, so retry with a stable message ID and make consumers idempotent.
The dead-letter topology preserves rejected messages for inspection in this local demo; production dead-letter safety depends on queue type and broker configuration.

## Consuming Messages

Set up a consumer to process messages:

```csharp
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 10, global: false);

var consumer = new AsyncEventingBasicConsumer(channel);

consumer.ReceivedAsync += async (sender, ea) =>
{
    try
    {
        var order = JsonSerializer.Deserialize<OrderCreated>(ea.Body.Span)
            ?? throw new JsonException("Expected an order object.");

        Console.WriteLine($"Processing order: {order?.OrderId}");

        // This demo logs only. Commit business work before acknowledging.

        await channel.BasicAckAsync(ea.DeliveryTag, multiple: false);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"Error: {ex.Message}");

        // Reject without requeue - see the warning below
        await channel.BasicNackAsync(ea.DeliveryTag, multiple: false, requeue: false);
    }
};

await channel.BasicConsumeAsync(
    queue: "orders",
    autoAck: false,
    consumer: consumer);

Console.WriteLine("Press Enter to stop.");
Console.ReadLine();

public sealed record OrderCreated(
    Guid OrderId, string Product, int Quantity, decimal UnitPrice);
```

In this consumer:

- `BasicQos` limits how many unacknowledged messages a consumer receives (prefetch)
- `autoAck: false` means you manually acknowledge messages after processing
- `BasicNack` with `requeue: false` discards the failed message (or dead-letters it, if configured)

**Watch out for the requeue trap.**
It's tempting to call `BasicNackAsync` with `requeue: true` so failed messages get retried.
But if the message itself is broken (say, invalid JSON), it will fail forever, and RabbitMQ will redeliver it in a hot loop that pegs your CPU and floods your logs.
Route failures to a [**dead letter queue**](https://milanjovanovic.tech/blog/dead-letter-queues-failed-messages) instead, and requeue only errors you know are transient.

## Exchange Types

RabbitMQ has four exchange types for different routing patterns:

The following alternatives assume their named queues have already been declared as durable queues.
They are separate topology examples, not additions required by the orders demo.

**Direct Exchange**: Routes to queues where the binding key exactly matches the routing key.

```csharp
await channel.ExchangeDeclareAsync("order-events", ExchangeType.Direct, durable: true);

await channel.QueueBindAsync("order-processing", "order-events", "order.created");
await channel.QueueBindAsync("notification-queue", "order-events", "order.completed");
```

**Fanout Exchange**: Broadcasts to all bound queues (like [**pub/sub**](https://milanjovanovic.tech/blog/pub-sub-pattern-rabbitmq-dotnet)).

```csharp
await channel.ExchangeDeclareAsync("notifications", ExchangeType.Fanout, durable: true);

// All bound queues receive every message
await channel.QueueBindAsync("email-queue", "notifications", "");
await channel.QueueBindAsync("sms-queue", "notifications", "");
await channel.QueueBindAsync("push-queue", "notifications", "");
```

**Topic Exchange**: Routes based on pattern matching with wildcards.

```csharp
await channel.ExchangeDeclareAsync("logs", ExchangeType.Topic, durable: true);

// * matches one word, # matches zero or more words
await channel.QueueBindAsync("all-errors", "logs", "*.error");
await channel.QueueBindAsync("order-logs", "logs", "orders.#");
```

**Headers Exchange**: Routes based on message header attributes instead of routing keys.
You'll rarely need this one in practice.

## Hosting Consumers in ASP.NET Core

Keep a connection for the service lifetime and give each independently publishing task or consumer its own channel.
Concurrent publishing on one channel requires synchronization.
The console demo keeps its connection alive with `Console.ReadLine`; a hosted service must stay active until shutdown and dispose its channel and connection afterward.

When a handler depends on a scoped `DbContext`, create an async DI scope for each delivery.
Stop accepting new deliveries during shutdown, wait for in-flight work within the shutdown deadline, and leave unfinished messages unacknowledged for redelivery.
Do not return from `ExecuteAsync` immediately after registering a callback and assume the host will supervise those callbacks.

For hosted applications, [**MassTransit**](https://milanjovanovic.tech/blog/masstransit-dotnet-getting-started) supplies connection management, receive endpoints, scoped consumers, and error queues.
Configure retry middleware explicitly; automatic message retries are not enabled by default.
Its v8 examples and v9 licensing considerations are covered in that guide.
The [**competing consumers guide**](https://milanjovanovic.tech/blog/competing-consumers-pattern-dotnet) shows a complete worker registration.

## Common Beginner Mistakes

A few things that bite almost everyone the first time:

- **Creating a connection per message.** Connections are heavyweight TCP connections. Create one and reuse it.
- **Forgetting durability on one side.** A persistent message in a non-durable queue still disappears on broker restart.
- **Using `autoAck: true` for important work.** If your process crashes mid-processing, the message is gone.
- **Requeueing poison messages forever.** Use dead letter queues for messages that can't be processed.
- **Assuming exactly-once delivery.** RabbitMQ is at-least-once. Duplicate deliveries happen, so make your handlers idempotent.

## Summary

Choose the exchange from the routing semantics, then bind a durable queue for every independently operated consumer.
Use publisher confirms, persistent messages, manual acknowledgments, bounded prefetch, and a DLQ instead of endlessly requeueing poison messages.
RabbitMQ.Client exposes those mechanics directly; MassTransit is a useful higher-level option when retry, outbox, and lifecycle conventions should be standardized.

## Frequently asked questions

### What is RabbitMQ used for in .NET applications?

RabbitMQ is a message broker that lets .NET services communicate asynchronously instead of calling each other directly over HTTP. Common uses are background processing, event-driven communication between services, and buffering work during traffic spikes.

### Should I use RabbitMQ.Client or MassTransit?

Use RabbitMQ.Client to learn the fundamentals or for very simple scenarios. For production systems, MassTransit is usually the better choice because it handles serialization, retries, error queues, and consumer lifecycle for you.

### What is the difference between an exchange and a queue in RabbitMQ?

Producers publish to exchanges, never directly to queues. An exchange routes each message to zero or more queues based on bindings, and consumers read from queues. This indirection is what enables routing patterns like fanout and topic.

### How do I prevent losing messages in RabbitMQ?

Declare queues as durable, publish messages as persistent, and use manual acknowledgments so a message is only removed after successful processing. Use publisher confirms to detect broker acceptance, and an outbox when publication must agree with a database transaction. Consumers still need idempotency.

### Is RabbitMQ free?

Yes. RabbitMQ is open source under the Mozilla Public License, and you can run it anywhere with Docker. Commercial support and extra features are available through Broadcom (VMware Tanzu).
