# Event-Driven Architecture in .NET Explained

> Event-driven architecture decouples producers from consumers. Instead of calling services directly, you publish events and let interested parties react. Here is how to implement it in .NET.

Published: 2026-10-27. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/event-driven-architecture-dotnet

**Event-driven architecture in .NET** lets services publish facts through a message broker and lets subscribers react independently.
It reduces direct dependencies between services, but introduces duplicate delivery, delayed state, and failure recovery.
Use it when work can finish asynchronously and those costs are acceptable to the business.

Direct calls make the caller responsible for each downstream interaction.
Publishing an event moves those reactions into subscribers, each with its own deployment and failure handling.
That change affects how you save data, retry work, and investigate an order that has stopped progressing.

## What Changes When Services Publish Events?

With direct calls, the order service must know which services to call and decide what to do when each call fails:

![Service A making direct HTTP calls to Service B and Service C, coupling A to both downstream services](https://milanjovanovic.tech/blogs/articles/event-driven-architecture-dotnet/direct-coupling.png)

With events, it publishes a fact such as `OrderPlaced`.
Shipping and notifications subscribe through separate queues:

![Service A publishing an event to a message broker that fans it out to Service B, Service C, and Service D](https://milanjovanovic.tech/blogs/articles/event-driven-architecture-dotnet/event-broker-fanout.png)

A new subscriber can react without changing the publisher, provided the event already contains the data it needs.
The publisher still depends on the broker unless an outbox buffers publication.
Subscribers also depend on the event contract, so removing or changing fields requires coordination.

For the routing choices behind this diagram, start with [**message queue patterns**](https://milanjovanovic.tech/blog/message-queue-patterns-dotnet) and [**publish/subscribe with RabbitMQ**](https://milanjovanovic.tech/blog/pub-sub-pattern-rabbitmq-dotnet).
Multiple copies of the same subscriber can share a queue using [**competing consumers**](https://milanjovanovic.tech/blog/competing-consumers-pattern-dotnet).

## Which Events Cross a Service Boundary?

**Domain events** describe changes inside a bounded context.
An in-process dispatcher can handle them before or after a transaction, depending on the design.
Using a notification library alone does not make its handlers transactional.

**Integration events** cross a service boundary and become public contracts.
Give them stable identifiers and enough data for the intended subscribers to act without calling the producer back.
Keep sensitive or irrelevant fields out of the contract.

A notification containing only an order ID is small, but a consumer that needs the total must fetch it from the producer.
An event carrying the total removes that read dependency at the cost of copying data and maintaining its meaning across versions.

## Run a Publisher and Two Subscribers

This small .NET 10 app demonstrates routing with [**MassTransit**](https://masstransit.massient.com/).
It publishes a sample event and logs it in two consumers.
It does not persist orders or send real email.
For the broader library setup, see the [**MassTransit getting-started guide**](https://milanjovanovic.tech/blog/masstransit-dotnet-getting-started).

The example pins MassTransit 8.5.11.
MassTransit 9 has a different licensing model; review the [**current licensing information**](https://massient.com/) before changing the major version.

Create the app and run a local broker:

```bash
dotnet new web -n EventDemo -f net10.0
cd EventDemo
dotnet add package MassTransit.RabbitMQ --version 8.5.11
docker run --rm --name event-demo-rabbit -p 127.0.0.1:5672:5672 rabbitmq:4-management
```

Run Docker in a separate terminal.
The guest account is for this local example.
The [**RabbitMQ guide**](https://milanjovanovic.tech/blog/rabbitmq-dotnet-beginners-guide) explains the underlying client, acknowledgements, and publisher confirms.
For managed alternatives, compare [**Azure Service Bus**](https://milanjovanovic.tech/blog/azure-service-bus-dotnet) and [**Amazon SQS**](https://milanjovanovic.tech/blog/amazon-sqs-dotnet).

Replace `Program.cs` with the following:

```csharp
using EventDemo;
using MassTransit;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMassTransit(x =>
{
    x.SetKebabCaseEndpointNameFormatter();
    x.AddConsumer<ShippingConsumer>();
    x.AddConsumer<NotificationConsumer>();

    x.UsingRabbitMq((context, cfg) =>
    {
        cfg.Host(new Uri(builder.Configuration["RabbitMq"]
            ?? "rabbitmq://localhost/"));
        cfg.UseMessageRetry(r => r.Intervals(100, 500, 1000));
        cfg.ConfigureEndpoints(context);
    });
});
builder.Services.AddOptions<MassTransitHostOptions>()
    .Configure(options => options.WaitUntilStarted = true);

var app = builder.Build();

app.MapPost("/demo-events", async (
    IPublishEndpoint publisher, CancellationToken ct) =>
{
    var message = new OrderPlaced(
        Guid.NewGuid(), Guid.NewGuid(), 42m);

    await publisher.Publish(message, ct);
    return Results.Accepted(value: new { message.OrderId });
});

app.Run();
```

Put the contract and consumers in `Messages.cs`.
MassTransit contracts need a namespace:

```csharp
using MassTransit;

namespace EventDemo;

public sealed record OrderPlaced(
    Guid OrderId, Guid CustomerId, decimal Total);

public sealed class ShippingConsumer(
    ILogger<ShippingConsumer> logger) : IConsumer<OrderPlaced>
{
    public Task Consume(ConsumeContext<OrderPlaced> context)
    {
        logger.LogInformation("Shipping received {OrderId}",
            context.Message.OrderId);
        return Task.CompletedTask;
    }
}

public sealed class NotificationConsumer(
    ILogger<NotificationConsumer> logger) : IConsumer<OrderPlaced>
{
    public Task Consume(ConsumeContext<OrderPlaced> context)
    {
        logger.LogInformation("Notifications received {OrderId}",
            context.Message.OrderId);
        return Task.CompletedTask;
    }
}
```

Start the API with `dotnet run --urls http://localhost:5080`, then send a request:

```bash
curl -X POST http://localhost:5080/demo-events
```

Both consumers should log the same order ID.
The endpoint formatter gives them separate `shipping` and `notification` queues.
If you deploy two copies of this app, each queue is shared by two competing instances; that does not create two independent shipping subscriptions.

The HTTP `202` response means publication completed, not that shipping or notification processing finished.
If there is no bound subscriber queue when an event is published, it is not retained for a future subscriber automatically.
Deploy the consumer topology before producers start publishing.

## How Do You Save Data and Publish Reliably?

A real order endpoint must save an order as well as announce it.
Calling `SaveChangesAsync` and then `Publish` leaves a failure window: the database can commit before the broker call fails.
Reversing the calls allows consumers to see an event whose database transaction later rolls back.

Use a [**transactional outbox with EF Core**](https://milanjovanovic.tech/blog/transactional-outbox-ef-core) to persist the business change and outgoing event in the same database transaction.
A delivery worker retries publication after that commit.
With MassTransit's bus outbox, publish through the scoped `IPublishEndpoint` before calling `SaveChangesAsync`, and configure the outbox entities and delivery service.

This is a local transaction plus asynchronous delivery.
It does not make updates in every subscribing service atomic.
The [**distributed transactions versus outbox**](https://milanjovanovic.tech/blog/distributed-transactions-vs-outbox) comparison and [**alternatives to two-phase commit**](https://milanjovanovic.tech/blog/two-phase-commit-alternatives) explain that boundary.

If several services must complete a business process, a [**saga**](https://milanjovanovic.tech/blog/saga-pattern-dotnet) tracks progress and requests compensating actions when a step cannot finish.
Compensation is another business operation and can also fail.

## How Do Consumers Handle Duplicates and Ordering?

The broker can redeliver a message after a consumer commits its work but disconnects before acknowledging it.
An outbox can also publish the same event again after an interrupted delivery attempt.
Design the consumer's business effect to be **idempotent**.

A read such as `AnyAsync` followed by an insert does not prevent two concurrent consumers from applying the same effect.
Use a database uniqueness constraint and store the processed message ID in the same transaction as the business change.
External effects such as payments need a stable idempotency key accepted by the receiving service.

For ordering, distinguish full state snapshots from incremental events.
A projection can ignore an older snapshot after atomically applying a newer version.
It cannot discard a missing debit merely because a later debit arrived first.
The [**message ordering guide**](https://milanjovanovic.tech/blog/message-ordering-distributed-systems) covers sequence checks, broker sessions, and recovery for gaps.

MassTransit's partitioner serializes a key within one bus instance.
It does not impose global ordering across several service instances.
For larger workloads, [**data partitioning and sharding**](https://milanjovanovic.tech/blog/data-partitioning-sharding-dotnet) determines which owner handles each key.

Different services will observe changes at different times.
Design the UI and business rules for [**eventual consistency**](https://milanjovanovic.tech/blog/eventual-consistency-dotnet), and use the [**CAP theorem**](https://milanjovanovic.tech/blog/cap-theorem-dotnet-developers) to reason specifically about network partitions.
Timestamps from different machines cannot reliably order events because of [**clock skew**](https://milanjovanovic.tech/blog/clock-skew-distributed-systems).

## What Happens When a Dependency Fails?

Use [**bounded retries with backoff**](https://milanjovanovic.tech/blog/retry-pattern-dotnet-polly) for failures that are safe to repeat.
Keep short in-memory retries separate from delayed redelivery: a sleeping consumer still occupies a processing slot.
When retries are exhausted, inspect the framework's error queue and follow a [**dead-letter triage and replay procedure**](https://milanjovanovic.tech/blog/dead-letter-queues-failed-messages).

A [**circuit breaker**](https://milanjovanovic.tech/blog/circuit-breaker-pattern-dotnet) stops repeated calls to an unhealthy dependency while it recovers.
It does not make a failed event successfully processed.
Let the message retry or move to a recoverable error path instead of acknowledging work that never happened.

Bound local queues and worker concurrency with [**backpressure**](https://milanjovanovic.tech/blog/backpressure-dotnet).
When many instances share a dependency quota, a [**distributed rate limiter**](https://milanjovanovic.tech/blog/distributed-rate-limiting-redis-dotnet) coordinates admission across them.
Use [**leader election**](https://milanjovanovic.tech/blog/leader-election-dotnet) only where a task needs a single active coordinator, and fence writes against expired owners.

## When Is Event-Driven Architecture a Poor Fit?

If the caller needs a result before it can continue, HTTP or gRPC may express the interaction more clearly.
If one database already owns the whole business operation, a local transaction avoids cross-service recovery work.
Strict ordering is possible in event-driven systems, but it limits parallelism and requires explicit coordination.

## Summary

Before adding a broker, identify which operations can complete later and how users will see pending or failed work.
Then make publication recoverable, test duplicate delivery, and assign ownership of error queues.
Those decisions determine whether independent subscribers actually simplify the system.

## Frequently asked questions

### What is event-driven architecture?

An architectural style where services communicate by publishing events to a broker instead of calling each other directly. Producers do not know who consumes their events, which decouples services and lets you add consumers without changing publishers.

### What is the difference between domain events and integration events?

Domain events describe changes within a bounded context and are handled in-process. Whether handlers share a transaction depends on the dispatcher and persistence design. Integration events cross service boundaries and need stable, versioned contracts.

### Should events contain full data or just IDs?

Include the stable data the intended consumers need, while keeping sensitive and irrelevant fields out. An ID-only notification is smaller, but a consumer that needs more data must call back to the producer and depend on its availability.

### How do you guarantee an event is published when data is saved?

Use the outbox pattern: write the event to an outbox table in the same database transaction as the state change, and let a background process publish it to the broker with retries.

### When is event-driven architecture a bad idea?

It is often a poor fit when the caller needs an immediate result or one database can own the entire operation. Strict ordering is possible, but requires coordination and limits concurrency. Async messaging also adds failure recovery and operational work.
