Skip to main content

Event-Driven Architecture in C#: Loosely Coupled Systems

Learn event-driven architecture in C# with runnable examples: domain events, MediatR, message brokers, and best practices. Build loosely coupled .NET systems today.

Event-driven architecture in C# is one of the most effective ways to build loosely coupled systems that scale, evolve, and stay maintainable as your codebase grows. Instead of components calling each other directly, they announce that something happened, and any interested component reacts. In this tutorial you will learn what event-driven architecture is, why it matters for .NET developers, and how to implement it step by step: from native C# events, to in-process domain events with MediatR, to cross-service messaging with a message broker like RabbitMQ or Azure Service Bus. Every example is runnable on .NET 8 or later.

What Is Event-Driven Architecture in C#?

Event-driven architecture (EDA) is a design style where the flow of the program is determined by events: immutable facts that something happened in the past. "OrderPlaced", "PaymentReceived", and "UserRegistered" are events. A producer publishes the event without knowing who will consume it. One or more consumers subscribe and react independently.

Compare this to a traditional request-driven approach, where an OrderService directly calls EmailService, InventoryService, and AnalyticsService. Each new requirement means editing the order code again. That is tight coupling, and it is the reason many .NET monoliths become painful to change.

With an event-driven approach the order code publishes a single OrderPlaced event and is done. Adding a loyalty-points feature later means writing a new handler, not touching existing, tested code. This is the Open/Closed Principle applied at the architectural level.

Key Benefits of Loosely Coupled Systems

  • Independent deployability: consumers can be added, removed, or redeployed without changing the producer.
  • Scalability: slow work (sending email, generating PDFs) moves off the request path and can be scaled separately.
  • Resilience: if the email service is down, the order is still placed. The event waits in a queue until the consumer recovers.
  • Auditability: a stream of events is a natural history of everything that happened in the system.

Level 1: Native C# Events and Delegates

C# has had first-class language support for events since version 1.0. The event keyword with EventHandler<T> is the simplest way to decouple a publisher from subscribers inside a single object graph.

public sealed class OrderPlacedEventArgs : EventArgs
{
    public Guid OrderId { get; }
    public decimal Total { get; }

    public OrderPlacedEventArgs(Guid orderId, decimal total)
    {
        OrderId = orderId;
        Total = total;
    }
}

public sealed class OrderService
{
    // Publisher declares the event. Subscribers attach with += and detach with -=.
    public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;

    public Guid PlaceOrder(decimal total)
    {
        var orderId = Guid.NewGuid();
        Console.WriteLine($"Order {orderId} saved.");

        // Raise the event. The ?. guard handles the "no subscribers" case safely.
        OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(orderId, total));
        return orderId;
    }
}

public static class Program
{
    public static void Main()
    {
        var orders = new OrderService();

        // Two independent subscribers; OrderService knows nothing about them.
        orders.OrderPlaced += (_, e) => Console.WriteLine($"Email: order {e.OrderId} confirmed.");
        orders.OrderPlaced += (_, e) => Console.WriteLine($"Analytics: revenue +{e.Total:C}.");

        orders.PlaceOrder(149.99m);
    }
}

Why this works: the publisher only depends on the event contract, never on the concrete subscribers. Why it is not enough: C# events are synchronous, run on the caller's thread, and require the subscriber to hold a reference to the publisher instance. That makes them great for UI and small components, but awkward for application-level workflows with dependency injection.

Common Pitfall: Memory Leaks From Forgotten Unsubscribes

A subscriber that never calls -= keeps itself alive as long as the publisher lives. In long-running services this is a classic leak. Always unsubscribe in Dispose, or prefer the higher-level patterns below where the DI container manages lifetimes for you.

Level 2: In-Process Domain Events With MediatR

For most ASP.NET Core applications, the sweet spot is an in-process mediator. MediatR gives you a INotification abstraction, resolves handlers from dependency injection, and supports async out of the box. This is the pattern used across countless Clean Architecture and DDD (Domain-Driven Design) templates.

Install the package first:

// dotnet add package MediatR
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

// 1. The event. Records are ideal: immutable, value-based equality, concise.
public sealed record OrderPlaced(Guid OrderId, string CustomerEmail, decimal Total) : INotification;

// 2. Handlers. Each one lives in its own class and has a single responsibility.
public sealed class SendConfirmationEmail : INotificationHandler<OrderPlaced>
{
    public Task Handle(OrderPlaced e, CancellationToken ct)
    {
        Console.WriteLine($"[Email] Sending confirmation for {e.OrderId} to {e.CustomerEmail}");
        return Task.CompletedTask;
    }
}

public sealed class ReserveInventory : INotificationHandler<OrderPlaced>
{
    public Task Handle(OrderPlaced e, CancellationToken ct)
    {
        Console.WriteLine($"[Inventory] Reserving stock for {e.OrderId}");
        return Task.CompletedTask;
    }
}

// 3. The producer depends only on IPublisher, never on the handlers.
public sealed class OrderService
{
    private readonly IPublisher _publisher;
    public OrderService(IPublisher publisher) => _publisher = publisher;

    public async Task<Guid> PlaceOrderAsync(string email, decimal total, CancellationToken ct = default)
    {
        var orderId = Guid.NewGuid();
        // ... persist the order here ...
        await _publisher.Publish(new OrderPlaced(orderId, email, total), ct);
        return orderId;
    }
}

public static class Program
{
    public static async Task Main()
    {
        using var host = Host.CreateDefaultBuilder()
            .ConfigureServices(services =>
            {
                services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
                services.AddTransient<OrderService>();
            })
            .Build();

        var orders = host.Services.GetRequiredService<OrderService>();
        await orders.PlaceOrderAsync("dev@example.com", 89.50m);
    }
}

Why this is better than raw C# events: handlers are discovered automatically, get their own dependencies injected (a DbContext, an HttpClient, a logger), and can be unit tested in isolation. Adding a third reaction to OrderPlaced is one new file. Nothing else changes.

Best Practice: Publish Domain Events After the Transaction Commits

A subtle but critical rule: do not publish an event before the data it describes is durable. If you publish OrderPlaced and then the database SaveChanges fails, your email handler has already told the customer about an order that does not exist. The common solution is to collect events on the aggregate and dispatch them from a SaveChangesAsync override or an EF Core interceptor after the commit succeeds.

public abstract class Entity
{
    private readonly List<INotification> _domainEvents = new();
    public IReadOnlyCollection<INotification> DomainEvents => _domainEvents;

    protected void Raise(INotification domainEvent) => _domainEvents.Add(domainEvent);
    public void ClearDomainEvents() => _domainEvents.Clear();
}

public sealed class Order : Entity
{
    public Guid Id { get; } = Guid.NewGuid();
    public decimal Total { get; }

    public Order(string customerEmail, decimal total)
    {
        Total = total;
        Raise(new OrderPlaced(Id, customerEmail, total)); // recorded, not yet dispatched
    }
}

// In your DbContext:
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
    var entities = ChangeTracker.Entries<Entity>()
        .Select(e => e.Entity)
        .Where(e => e.DomainEvents.Count > 0)
        .ToList();

    var events = entities.SelectMany(e => e.DomainEvents).ToList();
    entities.ForEach(e => e.ClearDomainEvents());

    var result = await base.SaveChangesAsync(ct); // commit first

    foreach (var domainEvent in events)
        await _publisher.Publish(domainEvent, ct);   // then notify

    return result;
}

Level 3: Cross-Service Events With a Message Broker

MediatR is in-process only. When consumers live in different services, or when you need the publisher to survive a consumer outage, you need a message broker. Popular choices in the .NET ecosystem are RabbitMQ, Azure Service Bus, Amazon SQS/SNS, and Apache Kafka. Libraries such as MassTransit, NServiceBus, Wolverine, and Rebus abstract over them and handle retries, serialization, and dead-letter queues for you.

The example below uses MassTransit with RabbitMQ. Run RabbitMQ locally with Docker: docker run -d -p 5672:5672 -p 15672:15672 rabbitmq:3-management.

// dotnet add package MassTransit.RabbitMQ
using MassTransit;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

// Integration events are shared contracts. Keep them small and versioned.
public sealed record OrderPlacedIntegrationEvent(Guid OrderId, decimal Total, DateTime OccurredUtc);

// A consumer in a separate microservice (or the same app, for this demo).
public sealed class ShippingConsumer : IConsumer<OrderPlacedIntegrationEvent>
{
    public Task Consume(ConsumeContext<OrderPlacedIntegrationEvent> ctx)
    {
        Console.WriteLine($"[Shipping] Creating shipment for order {ctx.Message.OrderId}");
        return Task.CompletedTask;
    }
}

public static class Program
{
    public static async Task Main()
    {
        using var host = Host.CreateDefaultBuilder()
            .ConfigureServices(services =>
            {
                services.AddMassTransit(x =>
                {
                    x.AddConsumer<ShippingConsumer>();
                    x.UsingRabbitMq((context, cfg) =>
                    {
                        cfg.Host("localhost", "/", h => { h.Username("guest"); h.Password("guest"); });
                        cfg.ReceiveEndpoint("shipping-service", e =>
                        {
                            e.UseMessageRetry(r => r.Interval(3, TimeSpan.FromSeconds(2)));
                            e.ConfigureConsumer<ShippingConsumer>(context);
                        });
                    });
                });
            })
            .Build();

        await host.StartAsync();

        var publisher = host.Services.GetRequiredService<IPublishEndpoint>();
        await publisher.Publish(new OrderPlacedIntegrationEvent(Guid.NewGuid(), 249.00m, DateTime.UtcNow));

        await Task.Delay(1000); // give the consumer a moment in this demo
        await host.StopAsync();
    }
}

Why a broker changes the game: the publisher writes to the queue and returns in milliseconds. If the shipping service is offline for an hour, the message sits in RabbitMQ and is processed when it comes back. Retries, back-off, and dead-lettering are configured declaratively instead of hand-written.

Domain Events vs. Integration Events

Keep these two concepts separate. A domain event is internal to a bounded context and can reference rich domain types. An integration event crosses a service boundary, so it must be a stable, serializable, versioned contract. A common pattern is for a MediatR handler of a domain event to map it into an integration event and publish that to the broker.

Event-Driven Architecture in C#: Best Practices

  • Name events in the past tense. OrderPlaced, not PlaceOrder. An event is a fact, not a command. This one naming rule prevents a lot of design confusion.
  • Make events immutable. C# record types are perfect. A consumer should never be able to mutate what another consumer sees.
  • Design consumers to be idempotent. Brokers guarantee at-least-once delivery, so the same message can arrive twice. Store the processed message ID, or make the operation naturally repeatable (an upsert instead of an insert).
  • Use the Outbox pattern for reliability. Writing to the database and publishing to a broker are two separate operations that cannot share a transaction. The Outbox pattern saves the event in the same database transaction as the business data, and a background process forwards it to the broker. MassTransit and NServiceBus include built-in outbox support.
  • Include correlation IDs. Trace a single order through five services by attaching a correlation ID to every event and logging it. OpenTelemetry integrates with MassTransit and NServiceBus automatically.
  • Version your integration events. Add new optional properties rather than renaming or removing existing ones. Breaking a contract breaks every consumer at once.

Common Pitfalls to Avoid

  • Event chains you cannot follow. When event A triggers B, which triggers C, which triggers A again, you have an invisible loop. Document flows, and consider a process manager or saga for multi-step workflows.
  • Treating events as remote procedure calls. If the publisher waits for a reply from the consumer, it is not loosely coupled. Use a query or a request/response message instead.
  • Swallowing handler exceptions. With MediatR, one failing handler throws and stops the others by default. Decide explicitly whether handlers should be independent (catch and log per handler) or all-or-nothing.
  • Eventual consistency surprises. After the order is placed, the inventory count may lag by a few hundred milliseconds. Design the UI and business rules to tolerate that, or keep the operation synchronous if strong consistency is truly required.
  • Over-engineering small apps. A three-endpoint API does not need Kafka. Start with C# events or MediatR and introduce a broker only when you have a real cross-service or reliability need.

Conclusion: Key Takeaways

Event-driven architecture in C# lets you build loosely coupled systems by replacing direct method calls with published facts. Start with the right level for your problem:

  • Native C# events for small, in-object notifications such as UI or component callbacks.
  • MediatR domain events for in-process, DI-friendly, testable decoupling inside a single application.
  • A message broker with MassTransit, NServiceBus, or Wolverine when events must cross service boundaries or survive failures.

Whichever level you choose, name events in the past tense, keep them immutable, publish after commit, make consumers idempotent, and reach for the Outbox pattern when reliability matters. Follow those rules and your .NET system will stay easy to extend for years, one new handler at a time.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...