
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
recordtypes orinit-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!
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