Event-Driven Architecture in .NET Explained

distributed-systemsdotnetsoftware-architecture
Event-Driven Architecture in .NET Explained

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

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

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 and publish/subscribe with RabbitMQ. Multiple copies of the same subscriber can share a queue using competing consumers.

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

The example pins MassTransit 8.5.11. MassTransit 9 has a different licensing model; review the current licensing information before changing the major version.

Create the app and run a local broker:

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 explains the underlying client, acknowledgements, and publisher confirms. For managed alternatives, compare Azure Service Bus and Amazon SQS.

Replace Program.cs with the following:

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:

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:

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 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 comparison and alternatives to two-phase commit explain that boundary.

If several services must complete a business process, a saga 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 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 determines which owner handles each key.

Different services will observe changes at different times. Design the UI and business rules for eventual consistency, and use the CAP theorem to reason specifically about network partitions. Timestamps from different machines cannot reliably order events because of clock skew.

What Happens When a Dependency Fails?

Use bounded retries with backoff 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.

A circuit breaker 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. When many instances share a dependency quota, a distributed rate limiter coordinates admission across them. Use leader election 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.

  • Amazon SQS With .NET: A Practical Guide

    Amazon SQS removes the broker from your operations burden, but reliable consumers still depend on visibility timeouts, explicit deletes, long polling, and a dead letter policy. This guide builds that baseline in .NET with the AWS SDK, FIFO queues, batch operations, and MassTransit.

  • Azure Service Bus Topics, Subscription Filters, and Dead Letter Queues in .NET

    Go beyond basic queues in Azure Service Bus: publish to topics, route messages with SQL and correlation filters on the broker, and build a dead letter queue handling strategy in .NET.

  • Backpressure in .NET: Handling Producers That Outrun Consumers

    Backpressure slows producers when consumers cannot keep up. Use bounded .NET channels to wait for capacity, drop replaceable updates, or reject excess requests with 503. The examples distinguish admission to memory from durable processing and show how to size a queue for a burst.

Browse all Distributed Systems guides →
Loading comments...

Whenever you're ready, there are 5 ways I can help you:

  1. Pragmatic Self-Hosting: Learn to deploy and run your apps on a VPS with Dokploy and Tailscale. Keep administration private, automate releases, and practice restoring backups, with source code and a runbook you can reuse.
  2. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  5. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

One practical .NET tip, every Saturday.

Join 66,000+ engineers who read it. Free, and you can unsubscribe any time.