Skip to main content

Event-Driven Architecture in C#: Build Loosely Coupled Apps

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

Event-driven architecture in C# is one of the most effective ways to build systems that are easy to extend, test, and scale. Instead of one class calling another directly, components announce that something happened and let any interested party react. The result is loosely coupled code: the order service does not need to know that an email service, an inventory service, and an analytics pipeline all care about a new order. In this tutorial you will learn event-driven architecture in C# from the ground up: plain event keywords, in-process domain events with MediatR, and message-broker-based integration events for distributed .NET systems, with runnable code and the pitfalls to avoid.

What Is Event-Driven Architecture in C#?

An event is an immutable record of something that already happened: OrderPlaced, PaymentFailed, UserRegistered. Note the past tense. A command ("PlaceOrder") asks for something to be done and can be rejected; an event is a fact and cannot be. Event-driven architecture (EDA) organizes an application around producing and consuming these facts.

Three roles show up in every event-driven system:

  • Producer (publisher) – detects a state change and raises an event.
  • Broker / dispatcher – routes the event to consumers. In-process this can be a simple list of delegates; across services it is a message queue such as RabbitMQ, Azure Service Bus, or Kafka.
  • Consumer (subscriber / handler) – reacts to the event. Multiple consumers can handle the same event independently.

Why this matters: the producer has zero compile-time knowledge of its consumers. You can add a new reaction (say, a fraud check) by adding a new handler, without touching the code that raised the event. That is the Open/Closed Principle in practice, and it is why loosely coupled architecture is so much cheaper to change.

Level 1: C# Events and Delegates

The language has EDA built in. The event keyword wraps a multicast delegate so external code can only subscribe (+=) or unsubscribe (-=), never invoke or overwrite the invocation list.

public sealed class OrderPlacedEventArgs : EventArgs
{
    public OrderPlacedEventArgs(Guid orderId, decimal total)
    {
        OrderId = orderId;
        Total = total;
    }

    public Guid OrderId { get; }
    public decimal Total { get; }
}

public class OrderService
{
    // EventHandler<T> is the idiomatic delegate type for .NET events
    public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;

    public Guid PlaceOrder(decimal total)
    {
        var orderId = Guid.NewGuid();
        // ... persist the order ...

        // Thread-safe raise: copy to a local, then null-check
        OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(orderId, total));
        return orderId;
    }
}

public class EmailNotifier
{
    public void OnOrderPlaced(object? sender, OrderPlacedEventArgs e)
        => Console.WriteLine($"Emailing receipt for order {e.OrderId} (${e.Total})");
}

// Wiring it up
var orders = new OrderService();
var email = new EmailNotifier();
orders.OrderPlaced += email.OnOrderPlaced;
orders.OrderPlaced += (_, e) => Console.WriteLine($"Analytics: order {e.OrderId} recorded");

orders.PlaceOrder(149.99m);

OrderService knows nothing about email or analytics. That is the whole idea. However, plain C# events have limits that matter in real applications:

  • Subscribers run synchronously and in order on the publisher's thread. If one handler throws, later handlers never run.
  • The subscription is a strong reference. A long-lived publisher holding a delegate to a short-lived subscriber is the classic .NET memory leak; always unsubscribe with -= or use weak event patterns.
  • Async handlers become async void, which cannot be awaited and swallows exceptions.

Plain events are perfect for UI components and small libraries. For application and domain logic, move up a level.

Level 2: In-Process Domain Events with MediatR

Most ASP.NET Core teams use MediatR for in-process publish/subscribe. It resolves handlers from dependency injection, supports async/await properly, and lets you keep the "raise" and "react" sides in different projects. Install it with dotnet add package MediatR.

using MediatR;

// 1. The event (a "notification" in MediatR terms). Records are ideal: immutable and value-based.
public sealed record OrderPlaced(Guid OrderId, string CustomerEmail, decimal Total) : INotification;

// 2. Handlers — as many as you like, each independently registered
public sealed class SendReceiptHandler(IEmailSender email) : INotificationHandler<OrderPlaced>
{
    public Task Handle(OrderPlaced e, CancellationToken ct)
        => email.SendAsync(e.CustomerEmail, $"Thanks! Order {e.OrderId} total {e.Total:C}", ct);
}

public sealed class ReserveInventoryHandler(IInventory inventory) : INotificationHandler<OrderPlaced>
{
    public Task Handle(OrderPlaced e, CancellationToken ct)
        => inventory.ReserveForOrderAsync(e.OrderId, ct);
}

// 3. The publisher
public sealed class OrderService(IOrderRepository repo, IPublisher publisher)
{
    public async Task<Guid> PlaceOrderAsync(string email, decimal total, CancellationToken ct)
    {
        var order = Order.Create(email, total);
        await repo.AddAsync(order, ct);
        await publisher.Publish(new OrderPlaced(order.Id, email, total), ct);
        return order.Id;
    }
}

// 4. Registration in Program.cs
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(OrderPlaced).Assembly));

Adding a loyalty-points feature next month means adding AwardPointsHandler. OrderService is untouched, its tests stay green, and code review is a single new file.

Collecting Domain Events on the Aggregate

A cleaner variant, popular in DDD-style .NET projects, is to let entities record events and dispatch them after the database transaction commits. This guarantees you never notify consumers about an order that failed to save.

public abstract class Entity
{
    private readonly List<INotification> _events = new();
    public IReadOnlyList<INotification> DomainEvents => _events;
    protected void Raise(INotification e) => _events.Add(e);
    public void ClearEvents() => _events.Clear();
}

public sealed class Order : Entity
{
    public Guid Id { get; private set; }
    public decimal Total { get; private set; }

    public static Order Create(string email, decimal total)
    {
        var order = new Order { Id = Guid.NewGuid(), Total = total };
        order.Raise(new OrderPlaced(order.Id, email, total));
        return order;
    }
}

// In your EF Core 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.ClearEvents());

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

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

    return result;
}

Level 3: Integration Events Across Services

MediatR lives inside one process. When the email service is a separate microservice, you need a message broker. The producer publishes to a topic; each consuming service has its own queue and processes messages at its own pace. If the email service is down, messages wait in the queue instead of failing the checkout. Below is a minimal, dependency-light example using the built-in System.Threading.Channels to show the shape; in production swap the channel for RabbitMQ (via MassTransit or the RabbitMQ.Client package), Azure Service Bus, or Kafka.

using System.Threading.Channels;

public sealed record IntegrationEvent(string Type, string PayloadJson, DateTimeOffset OccurredAt);

// A tiny in-memory "broker" — replace with a real one in production
public sealed class EventBus
{
    private readonly Channel<IntegrationEvent> _channel =
        Channel.CreateUnbounded<IntegrationEvent>();

    public ValueTask PublishAsync<T>(T evt, CancellationToken ct) =>
        _channel.Writer.WriteAsync(new IntegrationEvent(
            typeof(T).Name,
            System.Text.Json.JsonSerializer.Serialize(evt),
            DateTimeOffset.UtcNow), ct);

    public IAsyncEnumerable<IntegrationEvent> ReadAllAsync(CancellationToken ct) =>
        _channel.Reader.ReadAllAsync(ct);
}

// A BackgroundService consumer, as you would write in any .NET worker
public sealed class EmailConsumer(EventBus bus, ILogger<EmailConsumer> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await foreach (var msg in bus.ReadAllAsync(ct))
        {
            if (msg.Type != nameof(OrderPlaced)) continue;

            var e = System.Text.Json.JsonSerializer.Deserialize<OrderPlaced>(msg.PayloadJson)!;
            try
            {
                log.LogInformation("Sending receipt for {OrderId}", e.OrderId);
                // await email.SendAsync(...)
            }
            catch (Exception ex)
            {
                // Real brokers: nack + retry, then dead-letter
                log.LogError(ex, "Failed to handle {Type}", msg.Type);
            }
        }
    }
}

The contract between services is now the serialized event schema, not a C# interface. Version it carefully: add fields, never rename or remove them, and put the events in a shared contracts package.

Event-Driven Architecture in C#: Best Practices

  • Name events in the past tense and keep them immutable (record types). An event is history.
  • Carry enough data to act. A consumer should rarely need to call back into the producer. Include IDs plus the fields most consumers need, but avoid dumping the entire aggregate.
  • Publish after commit. For cross-service events, use the Transactional Outbox pattern: write the event to an outbox table in the same transaction as the business data, and let a background process relay it to the broker. This eliminates the "saved but never published" and "published but never saved" failure modes.
  • Make handlers idempotent. Brokers deliver at-least-once. Store processed message IDs or design the operation so running it twice is harmless.
  • Keep handlers small and independent. One handler should not depend on another having run first. If ordering matters, that is a workflow (saga), not two events.
  • Use CancellationToken everywhere and never create async void handlers outside UI code.
  • Observe the flow. Propagate a correlation ID through every event and log it; distributed tracing with OpenTelemetry turns a tangle of events into a readable timeline.

Common Pitfalls

  • Hidden coupling through shared state. If two handlers mutate the same entity, you have re-created the coupling you tried to remove. Give each handler its own data or use commands.
  • Event storms. A handler that publishes an event that triggers a handler that publishes another event quickly becomes untraceable. Limit the depth of event chains and document them.
  • Using events for request/response. If the caller needs the result right now, that is a method call or a MediatR IRequest, not an event.
  • Memory leaks with C# event. Forgetting to unsubscribe keeps the subscriber alive for as long as the publisher lives.
  • Swallowing exceptions in consumers. Catch, log, retry with backoff, then dead-letter. Silent catch {} blocks hide data loss.
  • Over-engineering. A small CRUD app does not need Kafka. Start with C# events or MediatR and introduce a broker only when you have separate deployables that must stay decoupled.

Conclusion: Key Takeaways

Event-driven architecture in C# is a spectrum, not a single tool. Plain event declarations give you decoupling inside a class library; MediatR notifications give you clean, testable, DI-friendly domain events inside an ASP.NET Core application; and message brokers extend the same idea across services so each one can fail, scale, and deploy independently.

  • Events are immutable facts named in the past tense; commands are requests that may fail.
  • Producers never reference consumers, so new behavior means new handlers, not modified code.
  • Publish after commit, ideally through an outbox, and make every handler idempotent.
  • Choose the lightest level that solves your problem, and add correlation IDs and tracing from day one.

Pick one workflow in your current .NET project, such as user registration or order checkout, and refactor its side effects into event handlers. You will feel the difference the first time a new requirement lands and the core service does not have to change.

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