Skip to main content

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

Learn event-driven architecture in C# with practical examples. Master events, the mediator pattern, and message queues to build loosely coupled, scalable .NET apps.

Event-driven architecture in C# is one of the most powerful ways to build scalable, maintainable, and loosely coupled systems in .NET. Instead of objects calling each other directly and tangling your codebase into a web of dependencies, components communicate by raising and reacting to events. In this tutorial, you'll learn exactly how event-driven architecture works in C#, why it matters, and how to implement it step by step — from native .NET events to the publish-subscribe pattern, the mediator pattern, and message queues.

Whether you're a beginner searching for how C# events work, an intermediate developer looking for best practices, or a senior engineer designing distributed systems, this guide gives you runnable code and the reasoning behind every decision.

What Is Event-Driven Architecture in C#?

Event-driven architecture (EDA) is a software design pattern where the flow of the program is determined by events — meaningful state changes such as "an order was placed," "a user signed up," or "a payment failed." Components called publishers emit these events, and other components called subscribers react to them. The key benefit is loose coupling: publishers don't know or care who is listening.

Compare this to traditional procedural code. If your OrderService directly calls EmailService, InventoryService, and AnalyticsService, then OrderService is tightly bound to all three. Add a fourth requirement — say, a loyalty-points service — and you must modify OrderService again. This violates the Open/Closed Principle and makes testing painful.

With event-driven architecture in C#, OrderService simply announces "OrderPlaced" and walks away. Any number of subscribers can react, and you can add new behavior without ever touching the order logic.

Why Loose Coupling Matters

  • Extensibility — Add new features by adding new subscribers, not by editing existing classes.
  • Testability — You can test publishers and subscribers in isolation.
  • Scalability — Subscribers can run asynchronously or even on separate services.
  • Resilience — A failure in one subscriber doesn't have to bring down the whole flow.

Getting Started: Native C# Events and Delegates

Before reaching for libraries, understand that C# has first-class language support for events through delegates and the event keyword. This is the foundation of every event-driven C# tutorial, and it's perfect for in-process communication.

The modern idiom uses the built-in EventHandler<TEventArgs> delegate. Here's a complete, runnable example:

using System;

// 1. Define the event payload
public class OrderPlacedEventArgs : EventArgs
{
    public int OrderId { get; init; }
    public decimal Amount { get; init; }
    public string CustomerEmail { get; init; } = string.Empty;
}

// 2. The publisher
public class OrderService
{
    // The event — subscribers attach here
    public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;

    public void PlaceOrder(int orderId, decimal amount, string email)
    {
        Console.WriteLine($"Order {orderId} saved to database.");

        // Raise the event. The ?.Invoke handles the "no subscribers" case safely.
        OrderPlaced?.Invoke(this, new OrderPlacedEventArgs
        {
            OrderId = orderId,
            Amount = amount,
            CustomerEmail = email
        });
    }
}

// 3. Subscribers
public class Program
{
    public static void Main()
    {
        var orderService = new OrderService();

        // Each subscriber reacts independently — no coupling between them
        orderService.OrderPlaced += (sender, e) =>
            Console.WriteLine($"📧 Emailing receipt to {e.CustomerEmail}");

        orderService.OrderPlaced += (sender, e) =>
            Console.WriteLine($"📦 Reserving inventory for order {e.OrderId}");

        orderService.OrderPlaced += (sender, e) =>
            Console.WriteLine($"ðŸ“Å  Recording ${e.Amount} in analytics");

        orderService.PlaceOrder(1001, 49.99m, "jane@example.com");
    }
}

Run this and you'll see all three subscribers react to a single PlaceOrder call. The OrderService has zero knowledge of email, inventory, or analytics. That's loose coupling in action.

The Null-Conditional Invoke Pattern

Notice OrderPlaced?.Invoke(...). The ?. operator ensures that if no subscribers are attached, the event simply does nothing instead of throwing a NullReferenceException. This is the canonical, thread-safe-ish way to raise events in modern C#.

The Publish-Subscribe Pattern with an In-Memory Event Bus

Native events are great, but they couple subscribers to a specific publisher instance. For larger applications, the publish-subscribe (pub/sub) pattern with a central event bus decouples them entirely. Publishers and subscribers only know about the bus and the event types.

using System;
using System.Collections.Generic;

// A marker interface for all domain events
public interface IEvent { }

public record UserRegistered(string Email, DateTime RegisteredAt) : IEvent;

// A minimal, type-safe event bus
public class EventBus
{
    private readonly Dictionary<Type, List<Action<IEvent>>> _handlers = new();

    public void Subscribe<T>(Action<T> handler) where T : IEvent
    {
        var type = typeof(T);
        if (!_handlers.ContainsKey(type))
            _handlers[type] = new List<Action<IEvent>>();

        // Wrap to preserve the strong type
        _handlers[type].Add(e => handler((T)e));
    }

    public void Publish<T>(T @event) where T : IEvent
    {
        if (_handlers.TryGetValue(typeof(T), out var handlers))
        {
            foreach (var handler in handlers)
                handler(@event);
        }
    }
}

public class Demo
{
    public static void Main()
    {
        var bus = new EventBus();

        bus.Subscribe<UserRegistered>(e =>
            Console.WriteLine($"Welcome email sent to {e.Email}"));

        bus.Subscribe<UserRegistered>(e =>
            Console.WriteLine($"CRM updated for {e.Email} at {e.RegisteredAt:t}"));

        bus.Publish(new UserRegistered("alex@example.com", DateTime.UtcNow));
    }
}

Now any part of your application can publish a UserRegistered event, and any number of unrelated modules can subscribe — all without holding a reference to each other. This is the heart of event-driven architecture in C#.

Going Async: The Modern Approach

Real-world subscribers do I/O — sending emails, writing to databases, calling APIs. Blocking the publisher while these run is a recipe for slow, unresponsive apps. Modern event-driven C# uses async handlers so work can happen without blocking.

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

public interface IEvent { }
public record PaymentReceived(int OrderId, decimal Amount) : IEvent;

public class AsyncEventBus
{
    private readonly Dictionary<Type, List<Func<IEvent, Task>>> _handlers = new();

    public void Subscribe<T>(Func<T, Task> handler) where T : IEvent
    {
        var type = typeof(T);
        if (!_handlers.ContainsKey(type))
            _handlers[type] = new List<Func<IEvent, Task>>();

        _handlers[type].Add(e => handler((T)e));
    }

    public async Task PublishAsync<T>(T @event) where T : IEvent
    {
        if (_handlers.TryGetValue(typeof(T), out var handlers))
        {
            // Run all subscribers concurrently and await them together
            await Task.WhenAll(handlers.ConvertAll(h => h(@event)));
        }
    }
}

public class AsyncDemo
{
    public static async Task Main()
    {
        var bus = new AsyncEventBus();

        bus.Subscribe<PaymentReceived>(async e =>
        {
            await Task.Delay(100); // simulate sending a receipt
            Console.WriteLine($"Receipt sent for order {e.OrderId}");
        });

        bus.Subscribe<PaymentReceived>(async e =>
        {
            await Task.Delay(50); // simulate updating ledger
            Console.WriteLine($"Ledger updated: +${e.Amount}");
        });

        await bus.PublishAsync(new PaymentReceived(2002, 199.00m));
    }
}

Using Task.WhenAll, every subscriber runs concurrently, and the publisher only completes when all of them finish. This dramatically improves throughput in I/O-bound systems.

The Mediator Pattern in C# with MediatR

For production .NET applications, you'll often want a battle-tested library rather than a hand-rolled bus. The mediator pattern in C# — popularized by the MediatR library — centralizes communication and integrates cleanly with ASP.NET Core dependency injection.

Install it via NuGet:

// dotnet add package MediatR

using MediatR;

// 1. Define a notification (an event)
public record OrderShipped(int OrderId, string TrackingNumber) : INotification;

// 2. Define one or more handlers
public class NotifyCustomerHandler : INotificationHandler<OrderShipped>
{
    public Task Handle(OrderShipped notification, CancellationToken ct)
    {
        Console.WriteLine($"SMS: Order {notification.OrderId} shipped. " +
                          $"Track: {notification.TrackingNumber}");
        return Task.CompletedTask;
    }
}

public class UpdateWarehouseHandler : INotificationHandler<OrderShipped>
{
    public Task Handle(OrderShipped notification, CancellationToken ct)
    {
        Console.WriteLine($"Warehouse: marking order {notification.OrderId} dispatched.");
        return Task.CompletedTask;
    }
}

// 3. Publish from anywhere that has IMediator injected
public class ShippingService
{
    private readonly IMediator _mediator;
    public ShippingService(IMediator mediator) => _mediator = mediator;

    public async Task ShipAsync(int orderId)
    {
        // ... shipping logic ...
        await _mediator.Publish(new OrderShipped(orderId, "TRK-9281-AU"));
    }
}

MediatR discovers all INotificationHandler implementations automatically through DI, so adding a new reaction is as simple as creating a new handler class. This is why the mediator pattern is a cornerstone of clean architecture and CQRS in .NET.

Scaling Out: Message Queues for Distributed Event-Driven Systems

Everything so far runs in a single process. To build truly distributed, loosely coupled systems across microservices, you move to a message broker such as RabbitMQ, Azure Service Bus, or Apache Kafka. Events become messages on a queue or topic, and services subscribe independently — even if a consumer is offline when the event is published.

Here's a conceptual RabbitMQ publisher using the .NET client to illustrate the shape:

using RabbitMQ.Client;
using System.Text;
using System.Text.Json;

public record InvoiceGenerated(int InvoiceId, decimal Total);

public class InvoicePublisher
{
    public void Publish(InvoiceGenerated evt)
    {
        var factory = new ConnectionFactory { HostName = "localhost" };
        using var connection = factory.CreateConnection();
        using var channel = connection.CreateModel();

        channel.QueueDeclare(queue: "invoices",
                             durable: true,
                             exclusive: false,
                             autoDelete: false,
                             arguments: null);

        var json = JsonSerializer.Serialize(evt);
        var body = Encoding.UTF8.GetBytes(json);

        channel.BasicPublish(exchange: "",
                            routingKey: "invoices",
                            basicProperties: null,
                            body: body);

        Console.WriteLine($"Published invoice {evt.InvoiceId} to the queue.");
    }
}

A separate service — billing, accounting, or notifications — consumes the invoices queue at its own pace. This asynchronous, durable messaging is what lets event-driven architectures scale to millions of events while staying resilient.

Best Practices for Event-Driven Architecture in C#

  • Make events immutable. Use record types or init-only properties so subscribers can't mutate shared data.
  • Name events in the past tense. OrderPlaced, PaymentReceived — they describe facts that already happened, not commands.
  • Keep events small. Carry IDs and essential data; let subscribers fetch the rest if needed.
  • Handle subscriber failures. Wrap handler invocation in try/catch (or use retry/dead-letter queues) so one failing subscriber doesn't break the others.
  • Always unsubscribe. With native C# events, forgetting -= causes memory leaks because the publisher keeps subscribers alive.
  • Prefer interfaces and DI over static buses for testability in ASP.NET Core.

Common Pitfalls to Avoid

  • Hidden control flow. Because events decouple code, it can be hard to trace what runs when. Document your events and use structured logging.
  • Ordering assumptions. Don't assume subscribers run in a guaranteed order — design each to be independent.
  • Swallowed exceptions. An unhandled exception in one handler can stop the rest. Isolate handlers.
  • Over-engineering. Not every app needs Kafka. Start with native events or MediatR; introduce a broker only when you truly need cross-service messaging.

Conclusion: Build Loosely Coupled Systems with Confidence

Event-driven architecture in C# gives you a clear path from tightly coupled spaghetti code to clean, extensible, and scalable systems. You've seen the full progression: native EventHandler events for in-process communication, an in-memory event bus for pub/sub, async handlers for non-blocking I/O, the mediator pattern with MediatR for production .NET apps, and message queues for distributed microservices.

Key takeaways:

  • Event-driven design decouples publishers from subscribers, making code easier to extend and test.
  • Start simple with C# events and delegates; reach for MediatR or a message broker as complexity grows.
  • Immutable, past-tense events and isolated, async handlers are the marks of a robust system.
  • Loose coupling isn't free — invest in logging and documentation to keep event flows understandable.

Now it's your turn: take the runnable examples above, drop them into a .NET console project, and start refactoring one tightly coupled service into an event-driven one. Your future self — and your team — will thank you. Happy coding!

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