Back to Blog
dotnetRabbitMQEvent-Driven ArchitectureMassTransit

Event-driven architecture in a .NET API with RabbitMQ and MassTransit

·6 min read

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:

CSHARP
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 APIEvent-Driven
DirectionClient asks server for somethingServer tells other systems something happened
Who calls whoFrontend calls backendBackend notifies backend
TimingSynchronous, caller waitsAsynchronous, fire and forget
CouplingCaller must know the endpointProducer does not know about consumers
Best forFetching data, user actionsSide 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.

CSHARP
// 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.

CSHARP
// 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);
    }
}
CSHARP
// 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.

CSHARP
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

CSHARP
// 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.

CODE
PATCH /api/admin/orders/{id}/status
Body: { "status": "Shipped" }

Valid transitions:

  • Processing to Shipped
  • Shipped to Delivered
  • 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:

Bash
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:

CODE
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.