RabbitMQ doesn’t actually send messages; it routes them based on rules you define.

Let’s get a .NET app talking to RabbitMQ using the AMQP protocol. This is how you’ll build asynchronous, distributed systems where services can communicate without being directly connected.

First, you need the RabbitMQ server running. For local development, Docker is your friend:

docker run -d --hostname my-rabbit --name some-rabbit -p 5672:5672 -p 15672:15672 rabbitmq:3-management

This spins up a RabbitMQ instance with the management UI on port 15672 and AMQP on port 5672. You can then access the UI at http://localhost:15672 (default user/pass: guest/guest).

In your .NET project, you’ll need the RabbitMQ.Client NuGet package. Add it via the Package Manager Console:

Install-Package RabbitMQ.Client

Now, let’s set up a basic publisher. This code connects to RabbitMQ, declares a queue (if it doesn’t exist), and sends a message.

using RabbitMQ.Client;
using System.Text;

var factory = new ConnectionFactory() { HostName = "localhost" };
using var connection = factory.CreateConnection();
using var channel = connection.CreateModel();

// Declare a queue. If it exists, this is a no-op.
// Durable: The queue will survive broker restarts.
// Exclusive: The queue can only be accessed by the current connection.
// AutoDelete: The queue will be deleted when the last consumer disconnects.
channel.QueueDeclare(queue: "hello",
                     durable: false,
                     exclusive: false,
                     autoDelete: false,
                     arguments: null);

string message = "Hello, RabbitMQ!";
var body = Encoding.UTF8.GetBytes(message);

channel.BasicPublish(exchange: "", // Default exchange
                     routingKey: "hello", // Queue name
                     basicProperties: null,
                     body: body);
Console.WriteLine($" [x] Sent '{message}'");

The ConnectionFactory is your entry point. You configure it with the host where RabbitMQ is running. CreateConnection() establishes the actual TCP connection. A Channel is a lightweight virtual connection within a connection, and it’s where most of the AMQP protocol operations happen.

Declaring a queue (QueueDeclare) is idempotent. If the queue already exists, RabbitMQ just confirms it. The exchange: "" refers to the default exchange, which is a direct exchange that routes messages to the queue whose name matches the routingKey.

For a consumer, you’ll also create a connection and channel, then consume messages from the queue.

using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using System.Text;

var factory = new ConnectionFactory() { HostName = "localhost" };
using var connection = factory.CreateConnection();
using var channel = connection.CreateModel();

channel.QueueDeclare(queue: "hello",
                     durable: false,
                     exclusive: false,
                     autoDelete: false,
                     arguments: null);

// Set up a consumer
var consumer = new EventingBasicConsumer(channel);
consumer.Received += (model, ea) =>
{
    var body = ea.Body.ToArray();
    var message = Encoding.UTF8.GetString(body);
    Console.WriteLine($" [x] Received '{message}'");

    // Acknowledge the message to remove it from the queue
    channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
};

// Start consuming. AutoAck: false means we manually acknowledge.
channel.BasicConsume(queue: "hello",
                     autoAck: false,
                     consumer: consumer);

Console.WriteLine(" Press [enter] to exit.");
Console.ReadLine();

The EventingBasicConsumer is an event-driven approach. When a message arrives, the Received event fires. Inside the handler, we process the message and then call BasicAck. This tells RabbitMQ that the message has been successfully processed and can be removed from the queue. Crucially, autoAck: false is used here. If autoAck were true, RabbitMQ would consider the message delivered and removed before your consumer code even had a chance to run, potentially leading to message loss if your processing fails.

The ea.DeliveryTag is a unique identifier for each message delivery. multiple: false means we are acknowledging only this specific message. If set to true, it would acknowledge this message and all previously unacknowledged messages.

When you run the publisher and then the consumer, you’ll see the message appear in the consumer’s console output. If you stop the consumer without acknowledging the message, it will be redelivered when the consumer restarts, thanks to RabbitMQ’s persistence and the manual acknowledgment.

The most surprising thing about RabbitMQ’s routing is how it separates concerns: producers don’t know or care about consumers, and they don’t even know if there are any consumers. They just publish to an exchange, and RabbitMQ handles the rest.

The mental model here is a message broker acting as a central hub. Producers send messages to exchanges. Exchanges, based on rules (bindings), route messages to queues. Consumers then read messages from queues.

// Example of a more complex routing scenario: direct exchange

// Publisher side:
channel.ExchangeDeclare(exchange: "direct_logs", type: ExchangeType.Direct);
string severity = "info"; // Could be "warning", "error", etc.
string message = $"Log message with severity: {severity}";
var body = Encoding.UTF8.GetBytes(message);

channel.BasicPublish(exchange: "direct_logs",
                     routingKey: severity, // The routing key matches the severity
                     basicProperties: null,
                     body: body);

// Consumer side (for "info" severity):
channel.ExchangeDeclare(exchange: "direct_logs", type: ExchangeType.Direct);
var queueName = channel.QueueDeclare().QueueName; // Auto-generated queue name

// Bind the queue to the exchange with a specific routing key
channel.QueueBind(queue: queueName,
                  exchange: "direct_logs",
                  routingKey: "info"); // This consumer only gets "info" messages

// ... rest of the consumer setup ...

Here, direct_logs is a direct exchange. When a message is published with a routingKey (e.g., "info"), the exchange delivers it to all queues that are bound to that exchange with a matching routingKey. This allows you to send messages to specific types of consumers based on their routingKey.

The one thing most people don’t realize is that the exchange parameter in BasicPublish can be an empty string "". This tells RabbitMQ to use its default exchange, which is a direct exchange. In this default scenario, the routingKey you provide must be the exact name of the queue you want to send the message to. It’s a convenient shortcut for simple point-to-point messaging but hides the underlying exchange mechanism.

Next, you’ll want to explore fanout and topic exchanges for more advanced message distribution patterns.

Want structured learning?

Take the full Amqp course →