Building Event-Driven Architecture with C# and MediatR
Event-driven architecture (EDA) has become a cornerstone of modern application design. It’s a powerful pattern that promotes decoupling, scalability, and maintainability. If you've ever wondered how to implement this architecture elegantly in C#, you’re in the right place. In this post, we’ll explore how to leverage MediatR, a popular .NET library, to build an event-driven architecture with commands, queries, and domain events. Whether you're building a microservice or a monolithic application, this guide will arm you with the tools and knowledge to succeed.
Why Event-Driven Architecture?
Before we jump into the code, let’s briefly discuss why event-driven architecture is worth considering.
Imagine building an e-commerce application. When a customer places an order, several things need to happen:
- The inventory system needs to update stock levels.
- The payment service needs to process the transaction.
- The notification system needs to send an email confirmation.
Traditionally, you might write tightly coupled code where one service directly calls another. However, this approach can quickly lead to a tangled web of dependencies, making your application brittle and hard to test.
Event-driven architecture solves this by decoupling the components. Instead of directly invoking services, you publish an event, like OrderPlaced
, and let interested parties (subscribers) handle it independently. This makes your application more modular, testable, and scalable.
What is MediatR?
MediatR is a lightweight library that facilitates in-process messaging. It enables the Mediator design pattern, centralizing communication between components. Instead of having objects call each other directly, they communicate through a mediator. This is especially useful for implementing commands, queries, and events in a clean and decoupled way.
With MediatR, you can:
- Send commands to perform specific actions.
- Query data from a handler.
- Publish domain events to notify other parts of your application.
Setting Up the Project
Let’s get started by setting up a simple C# project. Open your favorite IDE (Visual Studio or JetBrains Rider), and create a new .NET Core Console App.
- Install the MediatR NuGet package:
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
- Install a dependency injection container like Microsoft.Extensions.DependencyInjection:
dotnet add package Microsoft.Extensions.DependencyInjection
Here’s how your Program.cs
file might look after setting up basic DI:
using System;
using Microsoft.Extensions.DependencyInjection;
using MediatR;
class Program
{
static void Main(string[] args)
{
var services = new ServiceCollection();
services.AddMediatR(typeof(Program).Assembly);
var serviceProvider = services.BuildServiceProvider();
var mediator = serviceProvider.GetRequiredService<IMediator>();
Console.WriteLine("MediatR is ready to roll!");
}
}
Commands and Queries with MediatR
What Are Commands and Queries?
-
Commands: Represent an action or intent to perform something (e.g.,
CreateOrderCommand
). They generally modify state. -
Queries: Represent a request to fetch data (e.g.,
GetOrderByIdQuery
). They do not modify state.
Implementing a Command
Let’s implement a simple CreateOrderCommand
that processes an order.
Step 1: Define the Command
using MediatR;
public record CreateOrderCommand(string ProductId, int Quantity) : IRequest<string>;
Here, CreateOrderCommand
implements IRequest<string>
, meaning it expects a string response (e.g., an order ID).
Step 2: Create the Command Handler
using MediatR;
using System.Threading;
using System.Threading.Tasks;
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, string>
{
public Task<string> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{
// Simulate creating an order
var orderId = Guid.NewGuid().ToString();
Console.WriteLine($"Order created: {orderId} for Product {request.ProductId}, Quantity {request.Quantity}");
return Task.FromResult(orderId);
}
}
Step 3: Dispatch the Command
Modify your Program.cs
to send the command:
var orderId = await mediator.Send(new CreateOrderCommand("Product123", 2));
Console.WriteLine($"Order ID: {orderId}");
Implementing a Query
Let’s implement a GetOrderByIdQuery
to fetch an order by its ID.
Step 1: Define the Query
using MediatR;
public record GetOrderByIdQuery(string OrderId) : IRequest<Order>;
Step 2: Create the Query Handler
using MediatR;
using System.Threading;
using System.Threading.Tasks;
public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, Order>
{
public Task<Order> Handle(GetOrderByIdQuery request, CancellationToken cancellationToken)
{
// Simulate fetching an order
var order = new Order(request.OrderId, "Product123", 2);
return Task.FromResult(order);
}
}
public record Order(string OrderId, string ProductId, int Quantity);
Step 3: Execute the Query
Add the query to your Program.cs
:
var order = await mediator.Send(new GetOrderByIdQuery(orderId));
Console.WriteLine($"Fetched Order: {order.OrderId}, Product: {order.ProductId}, Quantity: {order.Quantity}");
Domain Events with MediatR
Domain events notify other parts of the system when something significant happens. For example, when an order is placed, we might want to send an email confirmation or update inventory.
Step 1: Define the Event
using MediatR;
public record OrderPlacedEvent(string OrderId, string ProductId, int Quantity) : INotification;
Step 2: Create Event Handlers
We’ll create two handlers: one for sending an email and another for updating inventory.
using MediatR;
using System.Threading;
using System.Threading.Tasks;
public class EmailNotificationHandler : INotificationHandler<OrderPlacedEvent>
{
public Task Handle(OrderPlacedEvent notification, CancellationToken cancellationToken)
{
Console.WriteLine($"Email sent for Order: {notification.OrderId}");
return Task.CompletedTask;
}
}
public class InventoryUpdateHandler : INotificationHandler<OrderPlacedEvent>
{
public Task Handle(OrderPlacedEvent notification, CancellationToken cancellationToken)
{
Console.WriteLine($"Inventory updated for Product: {notification.ProductId}");
return Task.CompletedTask;
}
}
Step 3: Publish the Event
Modify the CreateOrderCommandHandler
to publish the event:
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, string>
{
private readonly IMediator _mediator;
public CreateOrderCommandHandler(IMediator mediator)
{
_mediator = mediator;
}
public async Task<string> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{
var orderId = Guid.NewGuid().ToString();
Console.WriteLine($"Order created: {orderId} for Product {request.ProductId}, Quantity {request.Quantity}");
await _mediator.Publish(new OrderPlacedEvent(orderId, request.ProductId, request.Quantity));
return orderId;
}
}
Common Pitfalls and How to Avoid Them
- Overusing MediatR: Avoid using MediatR for trivial cases. Use it only when decoupling is necessary.
- Ignoring Performance: Publishing many events simultaneously can lead to performance bottlenecks. Consider batching or throttling.
- Lack of Error Handling: Ensure you handle errors in event handlers gracefully. Use try-catch blocks or logging.
- Tight Coupling in Handlers: Keep handlers focused on a single responsibility to maintain modularity.
Key Takeaways and Next Steps
- Event-driven architecture, powered by MediatR, enables decoupled, scalable systems.
- Commands and queries simplify task execution and data retrieval.
- Domain events allow different parts of your application to react to significant occurrences without direct dependencies.
To deepen your understanding, explore:
- CQRS (Command Query Responsibility Segregation) patterns.
- Event Sourcing to persist domain events.
- Advanced MediatR features like pipelines and behaviors.
Implement these patterns in your projects, and you’ll soon experience firsthand how they improve your codebase. Happy coding!
Top comments (0)