
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 (
recordtypes). 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
CancellationTokeneverywhere and never createasync voidhandlers 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.
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