# Event-Driven Architecture with .NET and RabbitMQ

The shift from calling services directly to communicating through events is one of the harder architectural jumps to make, not because the code is complicated, but because it requires unlearning a mental model most of us start with: that a service should know who it's talking to. On systems where I've introduced event-driven patterns, the payoff has been real, but so has the learning curve for teams making that shift for the first time.

### What actually changes

In a request-response world, Service A calls Service B directly, waits for a response, and Service B has to be up and responsive for that to work. In an event-driven model, Service A publishes "OrderPlaced" and moves on. Whoever cares about that event, inventory, notifications, billing, subscribes independently, and Service A never knows or cares who's listening.

csharp

```csharp
public async Task PlaceOrderAsync(Order order)
{
    await _repository.SaveAsync(order);
    await _publisher.PublishAsync(new OrderPlacedEvent(order.Id, order.CustomerId, order.Total));
}
```

The publishing service is now decoupled from every downstream consumer. You can add a new consumer, say, a fraud check that runs on every new order, without touching the order service at all. That's the real value: change isolation, not just async processing for its own sake.

### Setting up RabbitMQ in .NET

MassTransit has become my default abstraction over raw RabbitMQ client code, because it handles a lot of the operational plumbing, retries, dead-lettering, serialization, that you'd otherwise hand-roll and get subtly wrong.

csharp

```csharp
builder.Services.AddMassTransit(x =>
{
    x.AddConsumer<OrderPlacedConsumer>();
    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.Host("rabbitmq://localhost");
        cfg.ConfigureEndpoints(context);
    });
});
```

csharp

```csharp
public class OrderPlacedConsumer : IConsumer<OrderPlacedEvent>
{
    public async Task Consume(ConsumeContext<OrderPlacedEvent> context)
    {
        var order = context.Message;
        await _inventoryService.ReserveStockAsync(order.OrderId);
    }
}
```

### The question everyone underestimates: what happens when a message fails

This is where event-driven systems separate teams that have done it before from teams doing it for the first time. A message that fails processing shouldn't just vanish, and it shouldn't infinitely retry and block the queue either. Dead-letter queues, where a message that's failed a defined number of times gets routed somewhere for inspection rather than lost or endlessly retried, are not optional in production.

csharp

```csharp
cfg.ReceiveEndpoint("order-placed-queue", e =>
{
    e.UseMessageRetry(r => r.Interval(3, TimeSpan.FromSeconds(5)));
    e.ConfigureConsumer<OrderPlacedConsumer>(context);
});
```

I treat the dead-letter queue as something a human actually monitors, not a place messages go to be forgotten. A growing dead-letter queue is an early warning sign of a bug or a downstream outage, and it's easy to miss if nobody's watching it.

### Idempotent consumers, always

Message brokers generally guarantee at-least-once delivery, not exactly-once. That means your consumer will, eventually, receive the same message twice, whether from a network blip, a retry, or a redelivery after a crash before acknowledgment. If processing that event twice reserves stock twice or charges a customer twice, that's a bug in your consumer, not a broker malfunction.

csharp

```csharp
public async Task Consume(ConsumeContext<OrderPlacedEvent> context)
{
    if (await _repository.AlreadyProcessedAsync(context.MessageId)) return;

    await _inventoryService.ReserveStockAsync(context.Message.OrderId);
    await _repository.MarkProcessedAsync(context.MessageId);
}
```

### Eventual consistency is a real tradeoff, not a footnote

The moment you go event-driven, you're accepting that different parts of your system will briefly disagree about the state of the world. Inventory might show a reservation a few hundred milliseconds before billing has processed the corresponding charge. For most business processes, that's completely fine. For anything where a customer could act on stale information in that gap, this needs to be a deliberate design decision, not something discovered after a customer complaint.

### When I don't reach for this pattern

Not every interaction needs to be an event. If Service A genuinely needs a response before it can proceed, and that response needs to be synchronous, forcing that into an event-driven shape adds complexity without adding value. I reach for events specifically where the interaction is naturally "notify and move on," not for every service-to-service call by default.

### The real payoff

The decoupling event-driven architecture buys you is real, but it comes with genuine operational complexity: monitoring dead-letter queues, designing idempotent consumers, and accepting eventual consistency where it doesn't hurt the business. Teams that adopt the pattern without budgeting for that complexity usually end up with a system that's harder to debug than the tightly coupled one it replaced.
