Skip to main content

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

Learn event-driven architecture in C# with practical examples. Master events, delegates, and messaging to build loosely coupled, scalable systems. Start now!

If you have ever watched a codebase turn into a tangled mess where changing one class breaks five others, you already understand the problem that event-driven architecture in C# was designed to solve. Event-driven architecture (EDA) lets components communicate through events instead of direct method calls, so your services stay loosely coupled, independently testable, and far easier to scale. In this tutorial you will learn how event-driven architecture works in C#, when to use it, and how to implement it with practical, runnable code — from basic delegates and events all the way to a full-blown event bus and message queues.

Whether you are a beginner searching "how to use events in C#", an intermediate developer looking for publish/subscribe best practices, or a senior engineer designing distributed systems, this guide covers the full spectrum.

What Is Event-Driven Architecture in C#?

Event-driven architecture in C# is a design approach where the flow of the application is determined by events — meaningful changes in state such as "order placed", "user registered", or "payment failed". Instead of calling a method on another object directly, a component publishes an event. Other components subscribe to that event and react to it, without the publisher ever knowing who is listening.

There are three key players in every event-driven system:

  • Producer (Publisher): the component that raises the event.
  • Event/Message: the data describing what happened.
  • Consumer (Subscriber): the component that reacts to the event.

The magic is in the decoupling. The publisher does not hold a reference to the subscriber. This is exactly why event-driven architecture produces loosely coupled systems in C# that are easy to extend — you can add new subscribers without touching the publisher's code.

Why Use Event-Driven Architecture? The "Why" Behind Loose Coupling

Before writing code, it is worth understanding why this pattern is so widely adopted in enterprise .NET applications, microservices, and desktop UIs alike.

  • Loose coupling: Publishers and subscribers evolve independently. Change one without recompiling the other.
  • Scalability: Events can be processed asynchronously and distributed across services or threads.
  • Extensibility: Need to send an SMS when an order is placed? Add a new subscriber. No existing code changes.
  • Single Responsibility: Each handler does one thing, keeping classes small and testable.
  • Resilience: With a message queue, if a consumer is down, messages wait until it recovers.

The trade-off is that event-driven flows can be harder to trace and debug because the call path is indirect. We will cover how to manage that in the pitfalls section.

Getting Started: C# Events and Delegates

At the language level, C# has built-in support for events through delegates and the event keyword. This is the foundation of event-driven programming in C# and the perfect starting point for beginners.

A delegate is a type-safe function pointer. An event is a restricted delegate that only the declaring class can invoke. The modern convention uses EventHandler<TEventArgs>.

using System;

// 1. Define the event data
public class OrderPlacedEventArgs : EventArgs
{
    public int OrderId { get; init; }
    public decimal Amount { get; init; }
}

// 2. The publisher
public class OrderService
{
    // The event other classes can subscribe to
    public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;

    public void PlaceOrder(int orderId, decimal amount)
    {
        Console.WriteLine($"Order {orderId} placed for {amount:C}.");

        // Raise the event — notify all subscribers
        OrderPlaced?.Invoke(this, new OrderPlacedEventArgs
        {
            OrderId = orderId,
            Amount = amount
        });
    }
}

Now any number of subscribers can listen. Notice the OrderService has no idea these classes exist:

public class EmailNotifier
{
    public void OnOrderPlaced(object? sender, OrderPlacedEventArgs e)
        => Console.WriteLine($"Email: Thanks for order #{e.OrderId}!");
}

public class InventoryUpdater
{
    public void OnOrderPlaced(object? sender, OrderPlacedEventArgs e)
        => Console.WriteLine($"Inventory updated for order #{e.OrderId}.");
}

class Program
{
    static void Main()
    {
        var orderService = new OrderService();
        var email = new EmailNotifier();
        var inventory = new InventoryUpdater();

        // Subscribe
        orderService.OrderPlaced += email.OnOrderPlaced;
        orderService.OrderPlaced += inventory.OnOrderPlaced;

        orderService.PlaceOrder(101, 249.99m);
        // Output:
        // Order 101 placed for $249.99.
        // Email: Thanks for order #101!
        // Inventory updated for order #101.
    }
}

This is event-driven architecture in its simplest form. To add a new reaction — say, a loyalty-points service — you write a new class and subscribe. You never modify OrderService. That is the Open/Closed Principle in action.

Building an In-Memory Event Bus in C#

Native C# events are great within a single class, but they couple subscribers to a specific publisher instance. For larger applications you want a central event bus (also called a mediator or message broker) that decouples publishers and subscribers entirely. This is the publish/subscribe pattern in C#.

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

// Marker interface for all events
public interface IEvent { }

// Handler contract
public interface IEventHandler<in TEvent> where TEvent : IEvent
{
    Task HandleAsync(TEvent @event);
}

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

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

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

    public async Task PublishAsync<TEvent>(TEvent @event)
        where TEvent : IEvent
    {
        if (_handlers.TryGetValue(typeof(TEvent), out var handlers))
        {
            foreach (var handler in handlers)
                await handler(@event);
        }
    }
}

Now define an event and some handlers:

public record UserRegistered(string Email) : IEvent;

public class WelcomeEmailHandler : IEventHandler<UserRegistered>
{
    public Task HandleAsync(UserRegistered e)
    {
        Console.WriteLine($"Sending welcome email to {e.Email}");
        return Task.CompletedTask;
    }
}

public class AnalyticsHandler : IEventHandler<UserRegistered>
{
    public Task HandleAsync(UserRegistered e)
    {
        Console.WriteLine($"Tracking signup: {e.Email}");
        return Task.CompletedTask;
    }
}

// Usage
var bus = new EventBus();
bus.Subscribe(new WelcomeEmailHandler());
bus.Subscribe(new AnalyticsHandler());

await bus.PublishAsync(new UserRegistered("dev@csharp-coder.com"));

The publisher only depends on the EventBus abstraction and the event record — never on concrete handlers. This is the pattern that libraries like MediatR formalize for production use.

Using MediatR for Event-Driven Architecture in C#

In real-world .NET applications, most teams reach for MediatR, the most popular library for implementing the mediator and publish/subscribe patterns. It integrates cleanly with ASP.NET Core dependency injection and removes the boilerplate of hand-rolling an event bus.

// Install: dotnet add package MediatR

using MediatR;

// Define a notification (an event)
public record OrderShipped(int OrderId) : INotification;

// Multiple handlers can react to the same notification
public class CustomerNotificationHandler : INotificationHandler<OrderShipped>
{
    public Task Handle(OrderShipped notification, CancellationToken ct)
    {
        Console.WriteLine($"Notifying customer: order {notification.OrderId} shipped.");
        return Task.CompletedTask;
    }
}

public class WarehouseLogHandler : INotificationHandler<OrderShipped>
{
    public Task Handle(OrderShipped notification, CancellationToken ct)
    {
        Console.WriteLine($"Logging shipment for order {notification.OrderId}.");
        return Task.CompletedTask;
    }
}

Register it in Program.cs and publish from anywhere:

builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

// In a controller or service
public class ShippingController(IMediator mediator) : ControllerBase
{
    [HttpPost("ship/{id}")]
    public async Task<IActionResult> Ship(int id)
    {
        await mediator.Publish(new OrderShipped(id));
        return Ok();
    }
}

MediatR discovers every INotificationHandler automatically and invokes them all. Adding a new reaction is as simple as writing a new handler class — no registration code, no publisher changes.

Scaling Out: Message Queues for Distributed Event-Driven Systems

Everything above runs in a single process. To achieve true event-driven architecture across microservices, you need a durable message queue or broker such as RabbitMQ, Azure Service Bus, or Apache Kafka. These persist events so consumers can process them asynchronously, retry on failure, and scale horizontally.

Here is a minimal RabbitMQ publisher in C# to illustrate the concept:

// dotnet add package RabbitMQ.Client
using RabbitMQ.Client;
using System.Text;
using System.Text.Json;

var factory = new ConnectionFactory { HostName = "localhost" };
using var connection = factory.CreateConnection();
using var channel = connection.CreateModel();

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

var message = JsonSerializer.Serialize(new { OrderId = 101, Amount = 249.99 });
var body = Encoding.UTF8.GetBytes(message);

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

Console.WriteLine("Order event published to the queue.");

A separate consumer service reads from the same queue at its own pace. Because the queue persists messages, the producer and consumer never have to be online at the same time — the ultimate form of loose coupling.

Best Practices for Event-Driven Architecture in C#

  • Make events immutable: Use record types so event data cannot be modified after publishing.
  • Name events in past tense: OrderPlaced, PaymentReceived — they describe facts that already happened.
  • Keep handlers idempotent: In distributed systems the same event may be delivered more than once. Design handlers so reprocessing causes no harm.
  • Always null-check before invoking: Use ?.Invoke(...) to avoid NullReferenceException when there are no subscribers.
  • Unsubscribe to prevent memory leaks: Long-lived publishers holding references to subscribers via += keep them alive. Always -= when done, especially in UI apps.
  • Favor async handlers: Use Task-based handlers so slow subscribers don't block the publisher.

Common Pitfalls to Avoid

  • Silent exceptions: If one handler throws, remaining handlers may not run. Wrap each handler in try/catch and log failures.
  • Debugging opacity: Because calls are indirect, add correlation IDs to every event so you can trace a flow across services.
  • Over-engineering: Not every method call needs an event. For simple, synchronous, single-caller logic, a direct method call is clearer.
  • Ordering assumptions: Never assume subscribers run in a guaranteed order unless you explicitly enforce it.
  • Memory leaks from lambdas: Lambda subscriptions are hard to unsubscribe. Prefer named methods when you need to detach later.

Conclusion: Key Takeaways

Event-driven architecture in C# is one of the most powerful tools for building loosely coupled, scalable, and maintainable systems. By letting components communicate through events rather than direct calls, you gain the freedom to extend and evolve your application without a cascade of breaking changes.

Here are the key takeaways:

  • Start with native C# events and delegates for in-class notifications.
  • Introduce an event bus or MediatR for application-wide publish/subscribe decoupling.
  • Use a message queue like RabbitMQ or Azure Service Bus for distributed, resilient microservices.
  • Keep events immutable, handlers idempotent, and always manage subscriptions to avoid memory leaks.
  • Apply event-driven architecture where decoupling adds value — not everywhere.

Master these patterns and you will write C# systems that are cleaner, more testable, and ready to scale. Start small with a single event today, and grow toward a fully event-driven architecture as your application demands it.

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