When I first started working with microservices, I made the same mistake most people make. I split the application into small services, and then I made them call each other over HTTP all day long. It looked clean on the architecture diagram. In reality, it was a chain of services waiting on each other, and when one of them went down, everything behind it went down too.
That's when I got properly introduced to message brokers, and RabbitMQ in particular. This article walks through what it is, why it helps, and how to use it in a .NET Core application. I'll keep it simple, and we'll build a small working example at the end.
The Problem with Direct Service-to-Service Calls
Say you have an e-commerce app with an Order Service and an Email Service. When a customer places an order, you want to send a confirmation email.
The obvious approach is for the Order Service to call the Email Service through an HTTP API. It works, but think about what happens when:
• The Email Service is down for maintenance. Does the order fail?
• The email provider is slow today. Does the customer stare at a loading spinner?
• You later add an Inventory Service, a Loyalty Points Service and an Analytics Service. Does the Order Service now call all of them one by one?
The Order Service starts knowing too much about everyone else. Its job is to place orders, not to babysit five other services. This is called tight coupling, and it's the thing microservices are supposed to help us avoid.
What Is RabbitMQ?
RabbitMQ is a message broker. It sits in the middle and passes messages between services.
The easiest way to think about it is the post office. You don't walk to your friend's house and hand them the letter. You drop it in a postbox and trust that it reaches the right person. Your friend might be out when the letter arrives, and that's fine. It waits for them.
With RabbitMQ:
• The Order Service drops a message saying "Order #1001 was placed" and moves on.
• RabbitMQ holds that message safely.
• The Email Service picks it up whenever it's ready.
The Order Service doesn't know the Email Service exists, and doesn't care. That's what asynchronous, loosely coupled communication looks like.
The Four Words You Need to Know
RabbitMQ has a few terms that sound confusing at first, but they're simple once you attach a picture to them.
Producer (Publisher): The service that sends a message. In our case, the Order Service.
Exchange: The sorting desk at the post office. The producer never sends a message straight to a queue. It hands it to an exchange, and the exchange decides where it goes.
Queue: The mailbox. Messages sit here until someone picks them up. Each interested service typically has its own queue.
Consumer: The service that reads messages from a queue. In our case, the Email Service.
Then there's the routing key, which works like the address label on the envelope (for example order.placed). The exchange looks at it and decides which queues should receive the message. The link between an exchange and a queue is called a binding.
So the full journey is:
Producer → Exchange → (routing key decides) → Queue → Consumer
One important point: an exchange can send the same message to multiple queues. That's how one "order placed" event can reach the Email Service, the Inventory Service and the Analytics Service, with the Order Service sending it only once.
Types of Exchanges (Quick Look)
You'll mostly come across these three:
• Direct: The routing key must match exactly. Simple and predictable.
• Topic: Supports patterns. A queue bound to order.* receives order.placed, order.cancelled and so on. This is the one I use most in microservices.
• Fanout: Ignores routing keys and sends a copy to every bound queue. Good for broadcasts.
Setting Up RabbitMQ Locally
You don't need to install RabbitMQ the old-fashioned way. If you have Docker, one command is enough:
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:4-management
Port 5672 is where your applications connect. Port 15672 is the management dashboard.
Open http://localhost:15672 in your browser and log in with guest / guest. This dashboard is very handy while learning, because you can watch exchanges, queues and messages appear in real time. (The guest account only works from localhost, so don't use it in production.)
Next, create two console apps, or two projects in one solution, one for the publisher and one for the consumer. Add the client library to both:
dotnet add package RabbitMQ.Client
Step 1: Define the Message
First, a small class describing what happened. Both projects need to agree on this shape. In a real solution you'd keep it in a shared class library.
public record OrderPlacedEvent(
Guid OrderId,
string CustomerName,
string CustomerEmail,
decimal TotalAmount);
Keep events small. Send only what the other services actually need, not your whole database row.
Step 2: The Publisher (Order Service)
using System.Text;
using System.Text.Json;
using RabbitMQ.Client;
var factory = new ConnectionFactory { HostName = "localhost" };
await using var connection = await factory.CreateConnectionAsync();
await using var channel = await connection.CreateChannelAsync();
// The exchange is where messages are sent
await channel.ExchangeDeclareAsync(
exchange: "shop.events",
type: ExchangeType.Topic,
durable: true);
var orderEvent = new OrderPlacedEvent(
Guid.NewGuid(), "Priya", "[email protected]", 2499.00m);
var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(orderEvent));
var properties = new BasicProperties
{
Persistent = true, // survive a broker restart
ContentType = "application/json"
};
await channel.BasicPublishAsync(
exchange: "shop.events",
routingKey: "order.placed",
mandatory: false,
basicProperties: properties,
body: body);
Console.WriteLine("Order event published.");
What's happening here, in plain words:
1. We connect to RabbitMQ and open a channel. Think of the connection as the phone line and the channel as one conversation on it.
2. We declare the exchange. If it already exists, RabbitMQ simply leaves it alone.
3. We turn our event into JSON, then into bytes, because RabbitMQ only deals in bytes.
4. We publish it with the routing key order.placed.
Notice that the publisher never mentions the Email Service. That's the whole point.
Step 3: The Consumer (Email Service)
using System.Text;
using System.Text.Json;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
var factory = new ConnectionFactory { HostName = "localhost" };
await using var connection = await factory.CreateConnectionAsync();
await using var channel = await connection.CreateChannelAsync();
await channel.ExchangeDeclareAsync("shop.events", ExchangeType.Topic, durable: true);
// This service's own mailbox
await channel.QueueDeclareAsync(
queue: "email.queue",
durable: true,
exclusive: false,
autoDelete: false);
// Tell the exchange: "send me anything with order.placed"
await channel.QueueBindAsync("email.queue", "shop.events", "order.placed");
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (sender, ea) =>
{
try
{
var json = Encoding.UTF8.GetString(ea.Body.ToArray());
var order = JsonSerializer.Deserialize<OrderPlacedEvent>(json);
Console.WriteLine($"Sending confirmation email to {order!.CustomerEmail}...");
// real email-sending logic would go here
// Tell RabbitMQ we handled it successfully
await channel.BasicAckAsync(ea.DeliveryTag, multiple: false);
}
catch (Exception ex)
{
Console.WriteLine($"Something went wrong: {ex.Message}");
// Put the message back so it can be retried
await channel.BasicNackAsync(ea.DeliveryTag, multiple: false, requeue: true);
}
};
await channel.BasicConsumeAsync("email.queue", autoAck: false, consumer: consumer);
Console.WriteLine("Waiting for messages. Press Enter to exit.");
Console.ReadLine();
A few things worth understanding here.
Why does the consumer also declare the exchange? So it doesn't matter which app starts first. Whoever runs first creates it, and everyone else reuses it.
What is autoAck: false? This one matters. If it's true, RabbitMQ deletes the message the moment it hands it over. If your service crashes halfway through sending the email, that message is gone forever. With false, RabbitMQ waits for you to confirm with BasicAckAsync. If you never confirm, it knows something went wrong and can deliver the message again. For anything important, keep it false.
Ack and Nack are just "got it, thanks" and "couldn't handle it, try again".
Try It Out
Start the consumer first and leave it running. Then run the publisher. You should see the consumer print the "Sending confirmation email" line almost instantly.
Now try something interesting. Stop the consumer, run the publisher two or three times, then open the dashboard and go to the Queues tab. You'll see email.queue holding those messages, patiently waiting. Start the consumer again and it processes all of them.
That's exactly the scenario we worried about earlier. The Email Service was down, and the Order Service didn't even notice.
Adding More Services Later
Say next month someone asks for stock to be reduced when an order is placed. In the old HTTP-call approach, you'd open the Order Service code and add another call.
Here, you create a new Inventory Service with its own queue (say inventory.queue), bind it to order.placed, and you're done. The Order Service doesn't change at all. RabbitMQ delivers a copy of the same message to both queues.
That's what people mean when they say event-driven architecture is easy to extend.
Things to Keep in Mind
RabbitMQ is not magic, and a few habits will save you pain:
• Design for duplicates. Messages can occasionally be delivered more than once (after a retry, for example). Your consumer should handle the same message twice without sending two emails or deducting stock twice.
• Use durable queues and persistent messages for anything you can't afford to lose.
• Watch for poison messages. A message that keeps failing and gets requeued forever will jam your queue. Look into dead-letter queues so these get parked somewhere for inspection.
• Don't use messaging for everything. If the caller needs an answer right now (like "is this username available?"), a normal HTTP call is the better fit. Messaging suits things that can happen in the background.
• Keep connections long-lived. Opening a new connection for every message, like in our demo, is fine for learning but wasteful in production. Create it once and reuse it, typically as a singleton registered in dependency injection.
Wrapping Up
If I had to sum up RabbitMQ in one line, it's this: it lets your services stop worrying about each other. The producer announces what happened, the consumers decide what to do about it, and the broker makes sure nothing gets lost in between.
We covered the post-office idea, the four building blocks (producer, exchange, queue, consumer), and a working publisher and consumer in .NET. The demo is small, but the same pattern scales to real systems. You'd typically wrap the publishing code into a reusable service, share your event classes through a common library, and run consumers as background services.
My suggestion is to run this example on your own machine and play with the dashboard. Stop the consumer, send messages, restart it, and watch the queue fill and drain. Seeing it happen makes it click faster than any article can.
If you get stuck anywhere, drop a comment and I'll try to help.
Join the conversation! Your thoughts help the community grow.