Learn event-driven architecture in C# with runnable examples: domain events, MediatR, message brokers, and best practices. Build loosely coupled .NET systems today.
Event-driven architecture in C# is one of the most effective ways to build loosely coupled systems that scale, evolve, and stay maintainable as your codebase grows. Instead of components calling each other directly, they announce that something happened, and any interested component reacts. In this tutorial you will learn what event-driven architecture is, why it matters for .NET developers, and how to implement it step by step: from native C# events, to in-process domain events with MediatR, to cross-service messaging with a message broker like RabbitMQ or Azure Service Bus. Every example is runnable on .NET 8 or later.
What Is Event-Driven Architecture in C#?
Event-driven architecture (EDA) is a design style where the flow of the program is determined by events: immutable facts that something happened in the past. "OrderPlaced", "PaymentReceived", and "UserRegistered" are events. A producer publishes the event without knowing who will consume it. One or more consumers subscribe and react independently.
Compare this to a traditional request-driven approach, where an OrderService directly calls EmailService, InventoryService, and AnalyticsService. Each new requirement means editing the order code again. That is tight coupling, and it is the reason many .NET monoliths become painful to change.
With an event-driven approach the order code publishes a single OrderPlaced event and is done. Adding a loyalty-points feature later means writing a new handler, not touching existing, tested code. This is the Open/Closed Principle applied at the architectural level.
Key Benefits of Loosely Coupled Systems
- Independent deployability: consumers can be added, removed, or redeployed without changing the producer.
- Scalability: slow work (sending email, generating PDFs) moves off the request path and can be scaled separately.
- Resilience: if the email service is down, the order is still placed. The event waits in a queue until the consumer recovers.
- Auditability: a stream of events is a natural history of everything that happened in the system.
Level 1: Native C# Events and Delegates
C# has had first-class language support for events since version 1.0. The event keyword with EventHandler<T> is the simplest way to decouple a publisher from subscribers inside a single object graph.
public sealed class OrderPlacedEventArgs : EventArgs
{
public Guid OrderId { get; }
public decimal Total { get; }
public OrderPlacedEventArgs(Guid orderId, decimal total)
{
OrderId = orderId;
Total = total;
}
}
public sealed class OrderService
{
// Publisher declares the event. Subscribers attach with += and detach with -=.
public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;
public Guid PlaceOrder(decimal total)
{
var orderId = Guid.NewGuid();
Console.WriteLine($"Order {orderId} saved.");
// Raise the event. The ?. guard handles the "no subscribers" case safely.
OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(orderId, total));
return orderId;
}
}
public static class Program
{
public static void Main()
{
var orders = new OrderService();
// Two independent subscribers; OrderService knows nothing about them.
orders.OrderPlaced += (_, e) => Console.WriteLine($"Email: order {e.OrderId} confirmed.");
orders.OrderPlaced += (_, e) => Console.WriteLine($"Analytics: revenue +{e.Total:C}.");
orders.PlaceOrder(149.99m);
}
}
Why this works: the publisher only depends on the event contract, never on the concrete subscribers. Why it is not enough: C# events are synchronous, run on the caller's thread, and require the subscriber to hold a reference to the publisher instance. That makes them great for UI and small components, but awkward for application-level workflows with dependency injection.
Common Pitfall: Memory Leaks From Forgotten Unsubscribes
A subscriber that never calls -= keeps itself alive as long as the publisher lives. In long-running services this is a classic leak. Always unsubscribe in Dispose, or prefer the higher-level patterns below where the DI container manages lifetimes for you.
Level 2: In-Process Domain Events With MediatR
For most ASP.NET Core applications, the sweet spot is an in-process mediator. MediatR gives you a INotification abstraction, resolves handlers from dependency injection, and supports async out of the box. This is the pattern used across countless Clean Architecture and DDD (Domain-Driven Design) templates.
Install the package first:
// dotnet add package MediatR
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
// 1. The event. Records are ideal: immutable, value-based equality, concise.
public sealed record OrderPlaced(Guid OrderId, string CustomerEmail, decimal Total) : INotification;
// 2. Handlers. Each one lives in its own class and has a single responsibility.
public sealed class SendConfirmationEmail : INotificationHandler<OrderPlaced>
{
public Task Handle(OrderPlaced e, CancellationToken ct)
{
Console.WriteLine($"[Email] Sending confirmation for {e.OrderId} to {e.CustomerEmail}");
return Task.CompletedTask;
}
}
public sealed class ReserveInventory : INotificationHandler<OrderPlaced>
{
public Task Handle(OrderPlaced e, CancellationToken ct)
{
Console.WriteLine($"[Inventory] Reserving stock for {e.OrderId}");
return Task.CompletedTask;
}
}
// 3. The producer depends only on IPublisher, never on the handlers.
public sealed class OrderService
{
private readonly IPublisher _publisher;
public OrderService(IPublisher publisher) => _publisher = publisher;
public async Task<Guid> PlaceOrderAsync(string email, decimal total, CancellationToken ct = default)
{
var orderId = Guid.NewGuid();
// ... persist the order here ...
await _publisher.Publish(new OrderPlaced(orderId, email, total), ct);
return orderId;
}
}
public static class Program
{
public static async Task Main()
{
using var host = Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
services.AddTransient<OrderService>();
})
.Build();
var orders = host.Services.GetRequiredService<OrderService>();
await orders.PlaceOrderAsync("dev@example.com", 89.50m);
}
}
Why this is better than raw C# events: handlers are discovered automatically, get their own dependencies injected (a DbContext, an HttpClient, a logger), and can be unit tested in isolation. Adding a third reaction to OrderPlaced is one new file. Nothing else changes.
Best Practice: Publish Domain Events After the Transaction Commits
A subtle but critical rule: do not publish an event before the data it describes is durable. If you publish OrderPlaced and then the database SaveChanges fails, your email handler has already told the customer about an order that does not exist. The common solution is to collect events on the aggregate and dispatch them from a SaveChangesAsync override or an EF Core interceptor after the commit succeeds.
public abstract class Entity
{
private readonly List<INotification> _domainEvents = new();
public IReadOnlyCollection<INotification> DomainEvents => _domainEvents;
protected void Raise(INotification domainEvent) => _domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
}
public sealed class Order : Entity
{
public Guid Id { get; } = Guid.NewGuid();
public decimal Total { get; }
public Order(string customerEmail, decimal total)
{
Total = total;
Raise(new OrderPlaced(Id, customerEmail, total)); // recorded, not yet dispatched
}
}
// In your 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.ClearDomainEvents());
var result = await base.SaveChangesAsync(ct); // commit first
foreach (var domainEvent in events)
await _publisher.Publish(domainEvent, ct); // then notify
return result;
}
Level 3: Cross-Service Events With a Message Broker
MediatR is in-process only. When consumers live in different services, or when you need the publisher to survive a consumer outage, you need a message broker. Popular choices in the .NET ecosystem are RabbitMQ, Azure Service Bus, Amazon SQS/SNS, and Apache Kafka. Libraries such as MassTransit, NServiceBus, Wolverine, and Rebus abstract over them and handle retries, serialization, and dead-letter queues for you.
The example below uses MassTransit with RabbitMQ. Run RabbitMQ locally with Docker: docker run -d -p 5672:5672 -p 15672:15672 rabbitmq:3-management.
// dotnet add package MassTransit.RabbitMQ
using MassTransit;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
// Integration events are shared contracts. Keep them small and versioned.
public sealed record OrderPlacedIntegrationEvent(Guid OrderId, decimal Total, DateTime OccurredUtc);
// A consumer in a separate microservice (or the same app, for this demo).
public sealed class ShippingConsumer : IConsumer<OrderPlacedIntegrationEvent>
{
public Task Consume(ConsumeContext<OrderPlacedIntegrationEvent> ctx)
{
Console.WriteLine($"[Shipping] Creating shipment for order {ctx.Message.OrderId}");
return Task.CompletedTask;
}
}
public static class Program
{
public static async Task Main()
{
using var host = Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddMassTransit(x =>
{
x.AddConsumer<ShippingConsumer>();
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("localhost", "/", h => { h.Username("guest"); h.Password("guest"); });
cfg.ReceiveEndpoint("shipping-service", e =>
{
e.UseMessageRetry(r => r.Interval(3, TimeSpan.FromSeconds(2)));
e.ConfigureConsumer<ShippingConsumer>(context);
});
});
});
})
.Build();
await host.StartAsync();
var publisher = host.Services.GetRequiredService<IPublishEndpoint>();
await publisher.Publish(new OrderPlacedIntegrationEvent(Guid.NewGuid(), 249.00m, DateTime.UtcNow));
await Task.Delay(1000); // give the consumer a moment in this demo
await host.StopAsync();
}
}
Why a broker changes the game: the publisher writes to the queue and returns in milliseconds. If the shipping service is offline for an hour, the message sits in RabbitMQ and is processed when it comes back. Retries, back-off, and dead-lettering are configured declaratively instead of hand-written.
Domain Events vs. Integration Events
Keep these two concepts separate. A domain event is internal to a bounded context and can reference rich domain types. An integration event crosses a service boundary, so it must be a stable, serializable, versioned contract. A common pattern is for a MediatR handler of a domain event to map it into an integration event and publish that to the broker.
Event-Driven Architecture in C#: Best Practices
- Name events in the past tense.
OrderPlaced, notPlaceOrder. An event is a fact, not a command. This one naming rule prevents a lot of design confusion. - Make events immutable. C#
recordtypes are perfect. A consumer should never be able to mutate what another consumer sees. - Design consumers to be idempotent. Brokers guarantee at-least-once delivery, so the same message can arrive twice. Store the processed message ID, or make the operation naturally repeatable (an upsert instead of an insert).
- Use the Outbox pattern for reliability. Writing to the database and publishing to a broker are two separate operations that cannot share a transaction. The Outbox pattern saves the event in the same database transaction as the business data, and a background process forwards it to the broker. MassTransit and NServiceBus include built-in outbox support.
- Include correlation IDs. Trace a single order through five services by attaching a correlation ID to every event and logging it. OpenTelemetry integrates with MassTransit and NServiceBus automatically.
- Version your integration events. Add new optional properties rather than renaming or removing existing ones. Breaking a contract breaks every consumer at once.
Common Pitfalls to Avoid
- Event chains you cannot follow. When event A triggers B, which triggers C, which triggers A again, you have an invisible loop. Document flows, and consider a process manager or saga for multi-step workflows.
- Treating events as remote procedure calls. If the publisher waits for a reply from the consumer, it is not loosely coupled. Use a query or a request/response message instead.
- Swallowing handler exceptions. With MediatR, one failing handler throws and stops the others by default. Decide explicitly whether handlers should be independent (catch and log per handler) or all-or-nothing.
- Eventual consistency surprises. After the order is placed, the inventory count may lag by a few hundred milliseconds. Design the UI and business rules to tolerate that, or keep the operation synchronous if strong consistency is truly required.
- Over-engineering small apps. A three-endpoint API does not need Kafka. Start with C# events or MediatR and introduce a broker only when you have a real cross-service or reliability need.
Conclusion: Key Takeaways
Event-driven architecture in C# lets you build loosely coupled systems by replacing direct method calls with published facts. Start with the right level for your problem:
- Native C# events for small, in-object notifications such as UI or component callbacks.
- MediatR domain events for in-process, DI-friendly, testable decoupling inside a single application.
- A message broker with MassTransit, NServiceBus, or Wolverine when events must cross service boundaries or survive failures.
Whichever level you choose, name events in the past tense, keep them immutable, publish after commit, make consumers idempotent, and reach for the Outbox pattern when reliability matters. Follow those rules and your .NET system will stay easy to extend for years, one new handler at a time.
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