![]()
One of the key characteristics of a microservices architecture is that each service owns its own database. Rather than sharing a single database, every microservice is responsible for managing and persisting its own data.
Let’s use my e-commerce demo project as an example. (If you’re new to the project, you may want to read my previous article,Understanding Microservices with .NET Through an E-Commerce Example, for an overview of the architecture.)
In this project, the Customer, Order, Inventory, and Payment services each have their own independent databases.
Now imagine the finance team requests an order report containing the following information:
Payment ID
Customer Name
Payment Date
![]()
In a traditional monolithic application, where all tables reside in the same database, generating this report is straightforward. A simple SQL JOIN is sufficient:
SELECT DISTINCT P.Id, C.CustomerName, P.CreatedDate
FROM Payments P
INNER JOIN Customers C
ON P.CustomerID = C.CustomerID
However, this approach no longer works in a microservices architecture.
Since the Payments and Customers data are stored in separate databases owned by different services, the database engine cannot perform a cross-service JOIN.
API Composition
One straightforward solution is API Composition.
Instead of querying the Customer database directly, the Order Service makes an HTTP request to the Customer Service to retrieve the customer information.
public async Task<OrderDetailDto> GetPaymentDetail(Guid id)
{
var payment = await _paymentRepository.Get(id);
var customer =
await _customerClient.GetCustomer(payment.CustomerId);
return new paymentDetailDto
{
PaymentId = payment.Id,
CustomerName = customer.Name,
CreatedDate = payment.CreatedDate
};
}
This approach works well for retrieving a small amount of data. However, it becomes inefficient when generating reports containing thousands of orders, or need to query info from different services.
Each order may require additional HTTP calls to other services, such as Customer, Inventory, or Payment services. This creates a large number of network requests, increases latency, and can slow down the system significantly.
Read Model
So, developers came up with another solution: create a separate database optimized for querying. This is commonly known as a Read Model in CQRS.
For this example, instead of joining data from the Customer and Order databases every time we generate a report, we can create a dedicated table:
PaymentID | CustomerName | CreatedDate
This table is designed specifically for read operations, allowing the reporting system to retrieve data quickly without calling multiple services.
Whenever a customer successfully places an order, the Customer Service publishes an CustomerCreated event through RabbitMQ. A separate consumer listens to this event and updates the Read Model database with the required information. The reporting application can then query this optimized table directly.
Asynchronous and RabbitMQ Message Broker
In this architecture, RabbitMQ plays an important role as the message broker that handles event publishing and subscription asynchronously.
Instead of directly calling the Read Model updater from the Customer Service, the Customer Service publishes an CustomerCreated event to RabbitMQ. The consumer can process this event in the background and update the Read Model without blocking the main order processing flow.
This asynchronous approach allows our microservices to remain loosely coupled. The Customer Service can complete its core business operation, while the process of synchronizing data to the Read Model happens independently in the background.
RabbitMQ Installation
Before we implement RabbitMQ into our e-commerce microservices architecture, let’s first start with a simple console application to demonstrate the basic concepts of RabbitMQ.
This will help us understand how message publishing, queueing, and consumption work before applying these concepts to a real-world microservices scenario.
First, install the RabbitMQ.Client NuGet package in your console application.
![]()
Next, run the following Docker command to download and start a RabbitMQ server on your local machine:
docker run -d --hostname rabbitmq ^
--name rabbitmq ^
-p 5672:5672 ^
-p 15672:15672 ^
rabbitmq:4-management
Once the container is running, you should see it in Docker Desktop.
![]()
Open your browser and navigate to http://localhost:15672/. Log in using the default credentials (guest / guest), and you will be greeted with the RabbitMQ Management UI.
![]()
Topology Of RabbitMQ
This is the topology of the RabbitMQ message broker.
![]()
After establishing a connection, a publisher sends a message through a channel to an exchange. Along with the message, the publisher specifies a routing key.
The exchange uses the routing key to determine which queue (or queues) should receive the message. An exchange can route messages to multiple queues depending on its configuration.
Consumers subscribe to one or more queues. Whenever a message arrives in a subscribed queue, the consumer retrieves and processes it.
In a real-world application, we typically organize RabbitMQ resources by business domain. For our e-commerce system, we might have separate exchanges and queues for Customer, Order, Inventory, and Payment events, allowing each service to process only the messages that are relevant to it.
RabbitMQ Code Demo
Let’s create the channel , exchange, routing key and queue for the RabbitMQ
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using System.Text;
Console.WriteLine("RabbitMQ Demo");
var factory = new ConnectionFactory() { HostName = "localhost" };
using var connection = await factory.CreateConnectionAsync();
using var channel = await connection.CreateChannelAsync();
await channel.ExchangeDeclareAsync("my_exchange", ExchangeType.Direct);
// Create a queue
await channel.QueueDeclareAsync("my_queue", true, false, false, null);
// Create a binding between the exchange and the queue
await channel.QueueBindAsync("my_queue", "my_exchange", "my_routing_key", null);
Console.WriteLine("Infrastructure setup complete.");
The code are pretty straightforward, you can see I already create a connection and channel, then declare an exchange named my_exchange.
I create a queue named ‘my_queue’.
Then I create a binding between my_queue and my_exchange using the routing key my_routing_key.
This mean, next time if publisher publish a message with routing key ‘my_routing_key’, the exchange will route the message to my_queue because the routing key matches the binding key.
We now already complete setup the infrastructure of our message broker, next, we try to publish a message.
var message = "Hello, RabbitMQ!";
var body = Encoding.UTF8.GetBytes(message);
await channel.BasicPublishAsync(
exchange: "my_exchange",
routingKey: "my_routing_key",
mandatory: false,
basicProperties: new BasicProperties(),
body: body
);
Console.WriteLine("Message Published.");
The code of publish also very straightforward. We use BasicPublishAsync method to publish our message, by providing the exchange name and routing key.
The mandatory property controls what happens when RabbitMQ cannot route the message to any queue. If it is set to false, the message will be silently discarded when no matching queue exists. If it is set to true, RabbitMQ will return the message back to the publisher.
The basicProperties contains additional metadata for the message, such as delivery mode, headers, content type, priority, correlation ID, and expiration settings.
If we run the code and navigate to http://localhost:15672/#/queues , we will see something like this under the queue tab, Get Message(s).
![]()
We can now see that the message has been successfully routed to my_queue through my_exchange using the routing key my_routing_key.
We create an asynchronous consumer and associate it with our channel. The consumer will later subscribe to a queue using BasicConsumeAsync().
string queueName = "my_queue";
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += HandleReceivedMessage;
await channel.BasicConsumeAsync(queueName, autoAck: true, consumer: consumer);
static async Task HandleReceivedMessage(object sender, BasicDeliverEventArgs ea)
{
// You can perform awaitable tasks inside here if needed
var body = ea.Body.ToArray();
var message = Encoding.UTF8.GetString(body);
Console.WriteLine($"Method HandleReceivedMessage activate! Received message: {message}");
await Task.CompletedTask;
}
Let’s walkthrough the code
var consumer = new AsyncEventingBasicConsumer(channel);
This line means we create a consumer that consume our channel, by passing our variable channel.
Method HandleReceivedMessage is our method that will read the queue message.
consumer.ReceivedAsync += HandleReceivedMessage;
This line registers our HandleReceivedMessage method as an event handler for the ReceivedAsync event. Whenever RabbitMQ delivers a message to this consumer, this method will be executed.
Technically, C# events support multiple subscribers. However, in RabbitMQ consumers, it is common to have one message handler and delegate different processing tasks inside that handler.
For example, after receiving a message, we may need to perform three tasks:
Print the message
Save the message into a database for future analysis
Send an email notification to HR
Although technically we can create three separate methods and register them to the ReceivedAsync event:
consumer.ReceivedAsync += PrintMessage;
consumer.ReceivedAsync += SaveToDatabase;
consumer.ReceivedAsync += SendEmail;
This approach can become difficult to manage in a production system.
So, the proper design is we create three queue that receive same message, and register these methods to different queue. Each queue receives its own copy of the message through separate bindings.
Now, if the database consumer fails, it can handle the failure independently by retrying or rejecting the message without affecting the email consumer or print consumer.
await channel.BasicConsumeAsync(queueName, autoAck: true, consumer: consumer);
This is the line that creates the subscription.
It tells RabbitMQ: “Start delivering messages from my_queue to this consumer.”
The autoAck parameter controls message acknowledgement behavior
Setting autoAck to true means RabbitMQ automatically acknowledges and removes the message once it is delivered to the consumer. For production systems, autoAck:false is commonly preferred because it prevents message loss when a consumer fails during processing.
Now we run our console
![]()
We can see we able to read the message via HandleReceivedMessage method.
using var channel = await connection.CreateChannelAsync();
using var channel2 = await connection.CreateChannelAsync();
using var channel3 = await connection.CreateChannelAsync();
await channel.ExchangeDeclareAsync("my_exchange", ExchangeType.Direct);
await channel2.ExchangeDeclareAsync("my_exchange2", ExchangeType.Direct);
await channel3.ExchangeDeclareAsync("my_exchange3", ExchangeType.Direct);
await channel.QueueDeclareAsync("my_queue", true, false, false, null);
await channel2.QueueDeclareAsync("my_queue2", true, false, false, null);
await channel3.QueueDeclareAsync("my_queue3", true, false, false, null);
We can also create different channel, exchange and queue. This will be apply to my e-commerce website, since we need to isolate channel for different API services.
E-commerce Microservice
Now, let’s apply RabbitMQ to our e-commerce microservices system.
The idea is that our existing microservices will become event publishers. Whenever an important business action happens, such as creating a customer, creating a product, or updating inventory, the service will publish an event to RabbitMQ.
The published event contains the necessary information about the action:
{
"eventType": "CustomerCreated",
"customerId": 1001,
"customerName": "John"
}
Other services that are interested in this event can subscribe to it by creating their own consumers.
For example, we can create a new OrderReadService that subscribes to the CustomerCreated event from CustomerService.
When a new customer is created:
The PaymentReadService receives the event and updates its own local read model:
OrderRead Database
CustomerId | CustomerName
-------------------------
1001 | John
Later, when generating an order report that requires both order information and customer information, the reporting service can retrieve the customer name from its own read database instead of directly calling CustomerService or accessing the CustomerService database.
This reduces coupling between services and allows each service to maintain its own optimized data model for its specific purpose.
RabbitMQ In E-commerce Microservice
![]()
The following solution demonstrates how RabbitMQ can be integrated into our e-commerce microservices system.
The Ecommerce.Messaging project contains the shared RabbitMQ infrastructure, including the messaging architecture used by both publishers and consumers. We will go through its design in detail later.
The OrderReadService.API project maintains a read model and subscribes to events published by our microservices. It stores the data required for order reporting, such as customer names and order numbers. Any reporting page that requires this information can query the OrderReadService.API instead of directly accessing other microservices.
Similarly, the PaymentReadService.API project maintains its own read model and subscribes to the relevant events published by our microservices. It stores the data required for payment-related reports, such as product names and order numbers. Reporting pages that need this information can query the PaymentReadService.API.
![]()
The Events folder contains the event contracts that are exchanged between our microservices. These events define the data that will be published by the producer service and consumed by the read services.
For example, take a look of CustomerEvents.cs
namespace Ecommerce.Messaging.Events
{
public sealed record CustomerCreatedEvent(Guid CustomerId, string FirstName, string LastName);
public sealed record CustomerUpdatedEvent(Guid CustomerId, string FirstName, string LastName);
}
Notice that they only contain the information required by downstream services — in this case, the customer ID, first name, and last name.
public static class EventRoutingKeys
{
public const string CustomerCreated = "customer.created";
public const string CustomerUpdated = "customer.updated";
public const string ProductCreated = "product.created";
public const string ProductUpdated = "product.updated";
public const string OrderCreated = "order.created";
public const string PaymentCreated = "payment.created";
}
}
EventRoutingKeys is a collection of routing key.
This naming convention makes the events easy to understand and organize.
If we use a Topic Exchange, RabbitMQ also supports wildcard routing. For example, a consumer can bind to the routing key:
customer.*
to receive all customer-related events, such as customer.created and customer.updated, without needing to bind to each routing key individually.
public async Task PublishAsync<TEvent>(string routingKey, TEvent @event, CancellationToken cancellationToken = default)
{
var connection = await GetConnectionAsync(cancellationToken);
var channel = await connection.CreateChannelAsync(cancellationToken: cancellationToken);
await using (channel.ConfigureAwait(false))
{
await channel.ExchangeDeclareAsync(
_options.ExchangeName,
ExchangeType.Topic,
durable: true,
autoDelete: false,
cancellationToken: cancellationToken);
var body = JsonSerializer.SerializeToUtf8Bytes(@event);
var properties = new BasicProperties
{
Persistent = true,
ContentType = "application/json"
};
await channel.BasicPublishAsync(
_options.ExchangeName,
routingKey,
mandatory: false,
basicProperties: properties,
body: body,
cancellationToken: cancellationToken);
}
}
In RabbitMqEventPublisher.cs, the PublishAsync() method encapsulates the RabbitMQ publishing logic that we implemented earlier in our console application. Instead of hardcoding the exchange name, routing key, and event type every time we publish a message, we have extracted the common logic into a reusable method.
Now, callers only need to provide the routing key and the event object, while the publisher takes care of serializing the event and publishing it to RabbitMQ.
We now return to our e-commerce microservices implementation. In CustomerService.API, we can see how an event is published after updating customer information.
public async Task<Customer> CreateCustomer(CreateCustomerRequest request)
{
var newCustomer = new Customer
{
FirstName = request.FirstName,
//...
};
_db.Customers.Add(newCustomer);
_db.SaveChanges();
await PublishSafeAsync(EventRoutingKeys.CustomerCreated,
new CustomerCreatedEvent(newCustomer.Id, newCustomer.FirstName, newCustomer.LastName));
return newCustomer;
}
private async Task PublishSafeAsync<TEvent>(string routingKey, TEvent @event)
{
try
{
await _eventPublisher.PublishAsync(routingKey, @event);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to publish {routingKey} event: {ex.Message}");
}
}
After the customer information is successfully saved into the database, the service publishes a CustomerUpdatedEvent through the PublishAsync() method.
new CustomerCreatedEvent(newCustomer.Id,
newCustomer.FirstName,
newCustomer.LastName));
The event contains the routing key and the customer information required by downstream consumers.
Now let’s return to our RabbitMQ solution and see how the consumer subscribes to the events published by our e-commerce microservices.
![]()
In this example, we use PaymentEventConsumer.cs because our payment detail report requires customer information such as customer name.
Inside HandleMessageAsync, we use a switch statement to handle different types of events:
protected override async Task HandleMessageAsync( //...
{
case EventRoutingKeys.CustomerCreated:
case EventRoutingKeys.CustomerUpdated:
await HandleCustomerEventAsync(db, jsonBody, cancellationToken);
break;
case EventRoutingKeys.ProductCreated:
case EventRoutingKeys.ProductUpdated:
await HandleProductEventAsync(db, jsonBody, cancellationToken);
break;
For customer-related events, we call HandleCustomerEventAsync().
Inside this method, the event message is deserialized back into a C# object and then stored in the local read database.
private static async Task HandleCustomerEventAsync(PaymentReadDbContext db, string jsonBody, CancellationToken cancellationToken)
{
var evt = JsonSerializer.Deserialize<CustomerCreatedEvent>(jsonBody);
if (evt == null)
{
return;
}
var customer = await db.Customers.FindAsync(new object[] { evt.CustomerId }, cancellationToken);
if (customer == null)
{
customer = new CustomerCache { CustomerId = evt.CustomerId };
db.Customers.Add(customer);
}
customer.FirstName = evt.FirstName;
customer.LastName = evt.LastName;
customer.UpdatedDate = DateTime.UtcNow;
await db.Sav
So, when I create a new customer from my existing e-commerce website
![]()
The event will be created, and later pick up by PaymentEventConsumer.cs, eventually you can see the new created customer was also being inserted in the database that belongs to PaymentEvent. So in future, we can generate a payment detailed report with customer ‘s name from here.
![]()
This reinforces the CQRS concept: the report is not “borrowing” CustomerService data; it is querying its own optimized read model.
Conclusion
In this article, we explored how RabbitMQ can be integrated into an e-commerce microservices architecture.
The biggest benefit of this approach is loose coupling. Each microservice only needs to know about the events it publishes or consumes, without needing to understand the internal implementation of other services.
The examples demonstrated in this article only cover the basic concepts of RabbitMQ and event-driven architecture. RabbitMQ is a powerful messaging platform with many more concepts and patterns. The topics covered here are only the tip of the iceberg in understanding what RabbitMQ can achieve in a distributed system.
You can download the complete demo project from my GitHub repository.