Event-driven architecture in a .NET API with RabbitMQ and MassTransit
I refactored the order flow in my Coffee Shop API from a single bloated controller method into an event-driven system using RabbitMQ and MassTransit. This post walks through why I made that change, how EDA compares to REST, and the actual code.
The problem: a controller doing too much
Before the refactor, placing an order meant one method handled everything synchronously:
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest req)
{
// 1. Validate cart, address, card
// 2. Fetch products, check stock
// 3. Build order items
// 4. Deduct stock
// 5. Calculate totals
// 6. Save order to DB
// 7. Award membership points ← side effect
// 8. Send confirmation email ← side effect
// 9. Notify fulfillment ← side effect
return CreatedAtAction(...);
}
Every new feature required editing the same method. The controller had no separation between the core transaction (create the order) and the side effects (points, emails, notifications). Adding a new post-order action meant touching production order logic.
What is event-driven architecture?
Event-driven architecture is a pattern where components communicate by publishing and consuming events rather than calling each other directly.
Instead of: "do X, then Y, then Z" It becomes: "X happened, let whoever cares about it react"
The producer (your API) publishes an event and moves on. Consumers subscribe to that event and handle their own logic independently. Neither side knows about the other.
EDA vs REST: they solve different problems
REST and EDA are not in competition. They operate at different layers and answer different questions.
| REST API | Event-Driven | |
|---|---|---|
| Direction | Client asks server for something | Server tells other systems something happened |
| Who calls who | Frontend calls backend | Backend notifies backend |
| Timing | Synchronous, caller waits | Asynchronous, fire and forget |
| Coupling | Caller must know the endpoint | Producer does not know about consumers |
| Best for | Fetching data, user actions | Side effects, notifications, async workflows |
REST handles the front door: login, list products, place an order, fetch order history. EDA handles everything that happens after the door closes.
In my API, REST still owns all the routes. EDA only kicks in after the main transaction commits.
Before and after: the order flow
Before (synchronous, tightly coupled):
After (event-driven, decoupled):
The controller returns 201 Created before the consumer finishes. The frontend gets its response faster, and the membership update happens in the background.
The implementation
Tech stack: .NET 10, MassTransit 8.4.1, RabbitMQ 3.13 (Docker)
Event contracts
Events are defined as C# records. They are immutable message contracts, nothing more.
// Events/Integration/OrderCreatedIntegrationEvent.cs
public record OrderCreatedIntegrationEvent(
Guid OrderId,
string UserId,
decimal Total,
int ItemCount,
DateTime CreatedAt
);
// Events/Integration/OrderStatusChangedIntegrationEvent.cs
public record OrderStatusChangedIntegrationEvent(
Guid OrderId,
string UserId,
string OldStatus,
string NewStatus,
DateTime ChangedAt
);
Consumers
Each consumer implements IConsumer<T> from MassTransit. One consumer, one responsibility.
// Events/Consumers/OrderCreatedConsumer.cs
public class OrderCreatedConsumer : IConsumer<OrderCreatedIntegrationEvent>
{
private readonly CoffeeShopDbContext _db;
private readonly ILogger<OrderCreatedConsumer> _logger;
public async Task Consume(ConsumeContext<OrderCreatedIntegrationEvent> context)
{
var msg = context.Message;
var membership = await _db.Memberships
.FirstOrDefaultAsync(m => m.UserId == msg.UserId);
if (membership is null) return;
var pointsToAward = (int)Math.Floor(msg.Total);
membership.Points += pointsToAward;
await _db.SaveChangesAsync();
_logger.LogInformation(
"[Points] Awarded {Points} pts to user {UserId} for order {OrderId}",
pointsToAward, msg.UserId, msg.OrderId);
}
}
// Events/Consumers/OrderStatusChangedConsumer.cs
public class OrderStatusChangedConsumer : IConsumer<OrderStatusChangedIntegrationEvent>
{
private readonly ILogger<OrderStatusChangedConsumer> _logger;
public async Task Consume(ConsumeContext<OrderStatusChangedIntegrationEvent> context)
{
var msg = context.Message;
_logger.LogInformation(
"[Notification] Order {OrderId}: {OldStatus} -> {NewStatus}",
msg.OrderId, msg.OldStatus, msg.NewStatus);
// Swap in a real email service here without touching any other code
}
}
Publishing from the controller
The controller injects IPublishEndpoint and publishes after the database write commits.
public class OrdersController : ControllerBase
{
private readonly CoffeeShopDbContext _db;
private readonly IPublishEndpoint _publishEndpoint;
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest req)
{
// ... validate, build order, deduct stock ...
_db.Orders.Add(order);
await _db.SaveChangesAsync(); // transaction commits here
// publish after commit, consumers handle the rest
await _publishEndpoint.Publish(new OrderCreatedIntegrationEvent(
OrderId: order.Id,
UserId: userId,
Total: order.Total,
ItemCount: orderItems.Count,
CreatedAt: order.CreatedAt
));
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, MapToDto(order));
}
}
Registering MassTransit
// Program.cs
builder.Services.AddMassTransit(x =>
{
x.AddConsumer<OrderCreatedConsumer>();
x.AddConsumer<OrderStatusChangedConsumer>();
x.UsingRabbitMq((ctx, cfg) =>
{
cfg.Host(builder.Configuration["RabbitMQ:Host"] ?? "localhost", "/", h =>
{
h.Username(builder.Configuration["RabbitMQ:Username"] ?? "guest");
h.Password(builder.Configuration["RabbitMQ:Password"] ?? "guest");
});
cfg.ConfigureEndpoints(ctx); // auto-creates queues and exchanges
});
});
ConfigureEndpoints reads the registered consumers and creates the exchanges and queues automatically. On startup you get:
Admin order status endpoint
I also added an admin-only endpoint that updates order status and publishes an event. This is where OrderStatusChangedConsumer gets triggered.
PATCH /api/admin/orders/{id}/status
Body: { "status": "Shipped" }
Valid transitions:
ProcessingtoShippedShippedtoDelivered- Anything to
Cancelled
The controller validates the transition, saves to DB, then publishes OrderStatusChangedIntegrationEvent.
Observing message flow with RabbitMQ tracing
RabbitMQ ships with a management UI at http://localhost:15672. You can also enable the tracing plugin to capture every message:
docker exec coffeeshop-rabbitmq rabbitmq-plugins enable rabbitmq_tracing
Then under Admin > Tracing, add a trace and place an order. The trace log shows exactly what happened and when:
2026-05-25T10:41:13.026: Message published
Exchange: CoffeeShopApi.Events.Integration:OrderCreatedIntegrationEvent
Routed queues: [OrderCreated]
Payload: {
"message": {
"orderId": "8b77acc7-...",
"userId": "24be6d51-...",
"total": "45.0",
"itemCount": 1
}
}
2026-05-25T10:41:13.028: Message received
Queue: OrderCreated
Two milliseconds from publish to delivery.
What I learned
The controller should only care about the transaction. Anything that does not need to happen before the response is a side effect, and side effects belong in consumers.
Adding a new feature no longer means touching order logic. If I want to send a confirmation email, I add a new consumer. The controller does not change.
MassTransit vs the raw RabbitMQ client. MassTransit adds a lot of convention on top: automatic exchange/queue creation, typed message routing, consumer lifecycle management, and retry policies. For a project this size it is the right choice over writing all of that manually.
This same pattern transfers to SQS or Azure Service Bus. Swapping the transport in MassTransit is a one-line change in Program.cs. The event contracts and consumers stay exactly the same.