
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
recordtypes 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 avoidNullReferenceExceptionwhen 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.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment