Skip to main content
SEMastery

Event-Driven Architecture in .NET with RabbitMQ: A Beginner's Guide

Learn event-driven architecture in .NET with RabbitMQ using simple words, real-life examples, exchanges, queues, and clean async C# code you can copy.

13 min readUpdated December 24, 2025

A tiffin dabba service, not a phone call

Think about how a lunch dabba reaches an office in a big city.

You could imagine the cook personally carrying every dabba to every office, knocking on each door, and waiting until the right person is free to take it. If that person is in a meeting, the cook stands and waits. If ten offices order lunch, the cook runs to all ten, one by one. This is slow, tiring, and if the cook falls sick, lunch stops for everyone.

The real dabba system is smarter. The cook hands the dabba to a dabbawala network. Each dabba has a small code written on the lid. The cook does not need to know which train it takes or who carries it last. The network reads the code, sorts the dabba, and delivers it to the right office. The office picks it up when they are ready to eat.

That sorting-and-delivering network is exactly what RabbitMQ does for your software. Your apps drop a message, write a small "code" on it, and RabbitMQ makes sure it reaches the right place. This way of building software — where apps talk by sending and reacting to messages instead of calling each other directly — is called event-driven architecture.

What is an event?

An event is just a small note that says "something happened". It is written in the past tense.

  • OrderPlaced
  • PaymentReceived
  • UserRegistered

The app that notices the thing happened is called the producer (or publisher). It writes the event and sends it. The apps that care about the event are called consumers (or subscribers). They read the event and do their own work.

The big idea is that the producer does not know or care who reads the event. It just announces it. This is what makes the system loosely coupled.

A producer announces an event; many consumers each react in their own way.

Why not just call the other service directly?

Imagine the Order Service calls the Email Service, Stock Service, and Invoice Service one by one using normal HTTP calls. This is the "cook carries every dabba" approach. It has real problems.

ProblemDirect HTTP callsEvent-driven with RabbitMQ
One service is downThe whole order failsThe message waits in a queue
Adding a new serviceYou must change the Order ServiceNew consumer just subscribes
Slow serviceThe user waits for everyoneThe user gets a fast reply
Traffic spikeServices get hammered at onceThe queue smooths the load

By putting RabbitMQ in the middle, the Order Service finishes quickly. It drops one message and moves on. The other services do their work in the background, at their own speed.

The four building blocks

RabbitMQ has four words you must know. Once these click, everything else is easy.

TermWhat it isDabba analogy
ProducerThe app that sends a messageThe cook handing over the dabba
ExchangeThe sorting desk that routes messagesThe sorting station reading the lid code
QueueThe mailbox that holds messagesThe shelf at the office waiting to be picked
ConsumerThe app that reads and handles messagesThe hungry person who eats the lunch

The most important rule for beginners: a producer never sends a message straight to a queue. It always sends to an exchange. The exchange then decides which queue (or queues) should get a copy.

A message always flows producer to exchange to queue to consumer.

A binding is the link between an exchange and a queue. A routing key is the small code written on the message. Together, the binding and the routing key decide where the message lands. This is just like the dabba lid code deciding which office shelf it reaches.

The journey of one message

Publish
Route
Queue
Deliver
Ack

Steps

1

Publish

Producer sends to an exchange

2

Route

Exchange picks queues by routing key

3

Queue

Message waits safely on a shelf

4

Deliver

Consumer reads the message

5

Ack

Consumer says done; message removed

Each step a message takes from birth to being handled.

The exchange types

The exchange is the brain of RabbitMQ. There are four types, but you will mostly use the first three.

  • Direct — sends a message to queues whose binding key exactly matches the routing key. Good for "send this to that one queue".
  • Fanout — copies the message to every bound queue and ignores the routing key. This is the classic publish/subscribe "tell everyone" pattern.
  • Topic — matches the routing key against a pattern with wildcards. Use * to match one word and # to match zero or more words. Great for flexible routing like order.india.created.
  • Headers — routes using message headers instead of the routing key. Rarely needed by beginners.
A fanout exchange copies one event to every queue, ignoring the routing key.

Here is how a topic routing key gets read. The key order.india.created would be caught by a queue bound with the pattern order.*.created or order.#, but not by payment.#.

Choosing an exchange type

Need
One queue?
Everyone?
Pattern?

Steps

1

Need

What routing do you want?

2

One queue?

Use a direct exchange

3

Everyone?

Use a fanout exchange

4

Pattern?

Use a topic exchange

A quick way to pick the right exchange for the job.

Setting up RabbitMQ on your machine

The easiest way to run RabbitMQ locally is Docker. This image includes the web dashboard, which is lovely for watching messages move.

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

Port 5672 is for your app to connect. Port 15672 is the web dashboard. Open http://localhost:15672 in a browser and log in with guest / guest. You will see exchanges, queues, and live message counts.

Now add the official client to your .NET project. Use the RabbitMQ.Client package. From version 7, the API is fully async, which is the modern and correct way.

dotnet add package RabbitMQ.Client

Writing a producer in C#

Let's send an OrderPlaced event. We will use a fanout exchange so every interested service gets a copy. Notice that every call is awaited — this is the version 7 style.

using System.Text;
using System.Text.Json;
using RabbitMQ.Client;
 
var factory = new ConnectionFactory { HostName = "localhost" };
 
// Open a connection, then a channel. A channel is a lightweight
// virtual connection that you use for all the real work.
await using var connection = await factory.CreateConnectionAsync();
await using var channel = await connection.CreateChannelAsync();
 
// Make sure the exchange exists. Durable means it survives a restart.
await channel.ExchangeDeclareAsync(
    exchange: "orders",
    type: ExchangeType.Fanout,
    durable: true);
 
var order = new { OrderId = 101, Customer = "Aisha", Amount = 499 };
byte[] body = JsonSerializer.SerializeToUtf8Bytes(order);
 
// Persistent makes the message survive a broker restart.
var props = new BasicProperties { Persistent = true };
 
await channel.BasicPublishAsync(
    exchange: "orders",
    routingKey: "",          // fanout ignores the routing key
    mandatory: false,
    basicProperties: props,
    body: body);
 
Console.WriteLine("OrderPlaced event sent.");

A few things to note. We declare the exchange before publishing, so the code is safe even on a fresh broker. We mark the exchange durable and the message Persistent so a RabbitMQ restart does not lose anything. And we serialize the event to JSON bytes, which any language can read later.

Writing a consumer in C#

The consumer listens on its own queue, binds that queue to the orders exchange, and reacts to every message. In version 7 we use AsyncEventingBasicConsumer and handle the ReceivedAsync event.

using System.Text;
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("orders", ExchangeType.Fanout, durable: true);
 
// This service owns its own queue.
await channel.QueueDeclareAsync(
    queue: "email-service",
    durable: true, exclusive: false, autoDelete: false);
 
// Bind the queue to the exchange so it receives the events.
await channel.QueueBindAsync(
    queue: "email-service",
    exchange: "orders",
    routingKey: "");
 
// Only hand me one message at a time until I acknowledge it.
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false);
 
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (sender, ea) =>
{
    var json = Encoding.UTF8.GetString(ea.Body.ToArray());
    Console.WriteLine($"Email service got: {json}");
 
    // ... do the real work here, e.g. send the email ...
 
    // Tell RabbitMQ we are done so it can drop the message.
    await channel.BasicAckAsync(ea.DeliveryTag, multiple: false);
};
 
await channel.BasicConsumeAsync(
    queue: "email-service",
    autoAck: false,           // we ack manually after the work succeeds
    consumer: consumer);
 
Console.WriteLine("Email service is listening. Press Enter to exit.");
Console.ReadLine();

The key safety idea here is manual acknowledgement. We set autoAck: false. The message is only removed once we call BasicAckAsync. If our service crashes in the middle of the work, we never acked, so RabbitMQ keeps the message and gives it to another worker. Nothing is lost.

Scaling with the competing consumers pattern

What if one consumer is too slow for the flood of orders? Just start more copies of the same consumer, all reading the same queue. RabbitMQ shares the messages between them in a round-robin way. This is called the competing consumers pattern, and it is how you scale work horizontally.

Many workers share one queue, so the load spreads automatically.

Because we set BasicQosAsync with a prefetch count of 1, RabbitMQ will not dump ten messages on a slow worker while a free worker sits idle. Each worker takes the next message only after it finishes the current one. This keeps the load fair.

Delivery guarantees and staying safe

RabbitMQ promises at-least-once delivery by default. That word "least" is important. A message can sometimes arrive more than once. This happens when a consumer does the work, then crashes before it sends the ack. RabbitMQ thinks the work failed, so it redelivers.

This means your handler must be idempotent — running it twice should not cause double harm. For example, charging a customer twice would be a disaster. Common ways to stay safe:

  • Give every event a unique MessageId.
  • Before doing the work, check a small store to see if you already handled that id. This is the Inbox Pattern.
  • On the sending side, make sure an event is only published if the database change really happened. This is the Outbox Pattern.

When a message keeps failing again and again, you do not want it stuck forever. RabbitMQ can move such a "poison" message to a dead-letter queue — a side mailbox where bad messages rest so you can inspect and fix them later.

Handling a failed message

Deliver
Fail
Retry
Dead-letter

Steps

1

Deliver

Consumer receives the message

2

Fail

Work throws; consumer nacks

3

Retry

RabbitMQ redelivers a few times

4

Dead-letter

Still failing? Park it safely

What happens when a message cannot be processed cleanly.

A note on libraries: MassTransit and MediatR

You will hear about helper libraries that sit on top of RabbitMQ. Two popular ones are MassTransit and MediatR. They are well-made and can save you a lot of plumbing.

One honest heads-up: both MassTransit and MediatR have moved to a commercial license for their newer versions. For learning, hobby projects, and small teams, the raw RabbitMQ.Client package shown above is free, official, and teaches you what is really happening under the hood. Many teams are happy to stay with the plain client. Reach for a paid library only when you clearly need its extra features and your budget allows it.

Putting it all together

Here is the full picture of a small order system using RabbitMQ. The Order Service publishes once. Three services each react in their own way, and they can scale and fail independently.

A full event-driven order flow with independent consumers.

The Order Service replied to the customer the moment it published the event. The email and stock work happened quietly afterwards. If the Email Service was restarting, its message simply waited on the shelf until it came back. Nothing broke, and the customer never noticed.

When to use event-driven architecture

This style is powerful, but it is not free. Be honest about the trade-offs.

Good times to use it:

  • You have several services that all care about the same event.
  • You want services to keep working even when others are down.
  • You expect traffic spikes that a queue can smooth out.
  • You want to add new features later without touching old code.

Times to think twice:

  • You only have one small app. A message broker is overkill.
  • You need an instant, guaranteed answer in the same request. Events are usually "fire and forget".
  • Your team is new and a simple direct call would be far easier to debug.

Event-driven systems trade simple, immediate calls for decoupling and resilience, at the cost of eventual consistency and a bit more operational work. Use them when that trade is worth it.

Quick recap

  • Event-driven architecture means apps talk by sending and reacting to events, not by calling each other directly.
  • RabbitMQ is the message broker in the middle — like a dabba sorting network for your software.
  • The four building blocks are producer, exchange, queue, consumer. A producer always publishes to an exchange, never straight to a queue.
  • Exchange types: direct (exact match), fanout (tell everyone), topic (pattern match), and headers (rare).
  • Use the official RabbitMQ.Client package. From version 7 it is fully async (CreateConnectionAsync, CreateChannelAsync, BasicPublishAsync, AsyncEventingBasicConsumer).
  • Use manual acknowledgement so a crash never loses a message, and prefetch so slow workers are not overloaded.
  • Delivery is at-least-once, so make consumers idempotent. Pair with the Inbox and Outbox patterns for reliability, and use a dead-letter queue for poison messages.
  • MassTransit and MediatR are now commercially licensed for newer versions; the raw client is free and great for learning.

References and further reading

Related Patterns