DEV Community

Maria
Maria

Posted on

Building Event-Driven Architecture with C# and MediatR

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.

  1. Install the MediatR NuGet package:
   dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
Enter fullscreen mode Exit fullscreen mode
  1. Install a dependency injection container like Microsoft.Extensions.DependencyInjection:
   dotnet add package Microsoft.Extensions.DependencyInjection
Enter fullscreen mode Exit fullscreen mode

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!");
    }
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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}");
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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}");
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls and How to Avoid Them

  1. Overusing MediatR: Avoid using MediatR for trivial cases. Use it only when decoupling is necessary.
  2. Ignoring Performance: Publishing many events simultaneously can lead to performance bottlenecks. Consider batching or throttling.
  3. Lack of Error Handling: Ensure you handle errors in event handlers gracefully. Use try-catch blocks or logging.
  4. 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)