Refactoring A Modular Monolith Without MediatR in .NET
Anton Martyniuk

Anton Martyniuk @antonmartyniuk

About: Microsoft MVP | Helping Software Engineers improve their Skills in .NET and Architecture, and Craft Better Software from my Newsletter (with source code) and by daily posts on LinkedIn and X (Twitter)

Location:
Ukraine
Joined:
Apr 19, 2024

Refactoring A Modular Monolith Without MediatR in .NET

Publish Date: Aug 12
0 0

MediatR is one of my favourite libraries in .NET, I have been using it for 5+ years in production.

I used MediatR to separate my read and write commands and queries, encapsulating them in separate classes.
It made my Presentation (API layer) very simple and elegant, and it reduces coupling between the handlers.

Eventually, I moved to a Vertical Slice Architecture, and MediatR remained the core of it.

But recently, in the new project, I decided to remove MediatR.

Over the last couple of months, I've thought that there's a better way to organize my use cases.
This was a feeling I had after I finished working on the project in late 2024.

This project had complex use cases, and my team ended up calling one MediatR Handler from another.
As you know, MediatR decouples the Command/Query from their Handlers, and this made code navigation and debugging much more difficult in our project.

After talking to my friend who had a similar problem with his projects, and after recent news that MediatR is going commercial, this was a turning point.
I will not be using MediatR in my next project.

Yes, there is a fairly generous Community license for MediatR that you can use for free, and I will continue to use MediatR in my existing projects.
But in the new projects, I will go without MediatR.

What about MediatR alternatives? One of the most popular is Wolverine.
Also, many developers have already created their custom MediatR libraries, and some of them are much faster than MediatR, such as MitMediator.

But I won't be using either of them.

Today, I want to show you how to refactor a Modular Monolith application from MediatR to manual handlers.
They are the best replacement for MediatR: easy code navigation and debugging, no reflection and decorators.

In this post, we will explore:

  • Initial implementation of a Modular Monolith with MediatR
  • Replacing MediatR with manual handlers
  • How to implement auto-registration of manual handlers
  • How to replace MediatR notifications
  • Managing cross-cutting concerns without MediatR

Let's dive in!

P.S.: I publish all these blogs on my own website: Read original post here
Subscribe to my newsletter to improve your .NET skills.
Download the source code for this newsletter for free.

Initial implementation of a Modular Monolith with MediatR

I have built a Modular Monolith with three modules: Shipments, Carriers, and Stocks.

Each module is built using a combination of Vertical Slice Architecture and Clean Architecture, which structures an application by features.
Each slice encapsulates all aspects of a specific feature, including the API, business logic, and data access.

Here is a project's structure:

Screenshot_1

Let's explore the CreateShipment use case of the Shipments module:

public class CreateShipmentEndpoint : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapPost("/api/shipments", Handle);
    }

    private static async Task<IResult> Handle(
        [FromBody] CreateShipmentRequest request,
        IValidator<CreateShipmentRequest> validator,
        IMediator mediator,
        CancellationToken cancellationToken)
    {
        var validationResult = await validator.ValidateAsync(request, cancellationToken);
        if (!validationResult.IsValid)
        {
            return Results.ValidationProblem(validationResult.ToDictionary());
        }

        var command = request.MapToCommand();

        var response = await mediator.Send(command, cancellationToken);
        if (response.IsError)
        {
            return response.Errors.ToProblem();
        }

        return Results.Ok(response.Value);
    }
}
Enter fullscreen mode Exit fullscreen mode

I use Carter library for structuring Minimal APIs and MediatR for encapsulating application logic in command handlers:

public async Task<ErrorOr<ShipmentResponse>> Handle(
    CreateShipmentCommand request,
    CancellationToken cancellationToken)
{
    // 1. Check if the shipment already exists
    var shipmentExists = await context.Shipments.AnyAsync(x => x.OrderId == request.OrderId, cancellationToken);
    if (shipmentExists)
    {
        logger.LogInformation("Shipment for order '{OrderId}' already exists", request.OrderId);
        return Error.Conflict($"Shipment for order '{request.OrderId}' already exists");
    }

    // 2. Check stock levels
    var stockRequest = CreateStockRequest(request);
    var stockResponse = await stockApi.CheckStockAsync(stockRequest, cancellationToken);

    if (!stockResponse.IsSuccess)
    {
        logger.LogInformation("Stock check failed: {ErrorMessage}", stockResponse.ErrorMessage);
        return Error.Validation("ProductsNotAvailableInStock", stockResponse.ErrorMessage ?? "Products not available in stock");
    }

    // 3. Save the shipment in the database
    var shipment = request.MapToShipment();

    await context.Shipments.AddAsync(shipment, cancellationToken);
    await context.SaveChangesAsync(cancellationToken);

    logger.LogInformation("Created shipment: {@Shipment}", shipment);

    // 4. Call the Carrier Module to create a shipment
    var carrierRequest = CreateCarrierRequest(request);
    await carrierApi.CreateShipmentAsync(carrierRequest, cancellationToken);

    // 5. Update stock levels
    var updateRequest = CreateUpdateStockRequest(shipment);
    var response = await stockApi.UpdateStockAsync(updateRequest, cancellationToken);

    if (!response.IsSuccess)
    {
        return Error.Failure("StockUpdateFailed", "Failed to update stock");
    }

    return shipment.MapToResponse();
}
Enter fullscreen mode Exit fullscreen mode

MediatR is a great fit for Vertical Slice Architecture. It has the following advantages:

  • Code decoupling
  • Easy implementation of Command/Query Handler-per-Class
  • Built-in notifications with zero or many handlers
  • Built-in pipelines (middlewares) for implementing cross-cutting concerns

But it comes with the following drawbacks:

  • Harder code navigation
  • Harder to debug: you can't directly step inside a handler, you need to put breakpoints in each Handler
  • Slower performance because of reflection
  • Paid license for companies with revenue > 5M $ per year. Learn more here.

Honestly, these drawbacks are subjective:

  • You can put Command and Handler in the same file (and you should)
  • Performance penalty is not a thing for most projects

MediatR has never been a bad thing in the codebases of my team.
However, I have seen many terrible codebases where developers made a mess with MediatR.

And how can I take the best from MediatR and build something on my own to make my projects even better?

And here is where I got a brilliant idea: what if I build all the handlers manually?

Let's see how we can do this.

Replacing MediatR with Manual Handlers

I have created the following handlers for the Shipments module:

  • ICreateShipmentHandler, CreateShipmentHandler
  • IUpdateShipmentStatusHandler, UpdateShipmentStatusHandler
  • IGetShipmentByNumberHandler, GetShipmentByNumberHandler

Here is the ICreateShipmentHandler:

internal interface ICreateShipmentHandler
{
    Task<ErrorOr<ShipmentResponse>> HandleAsync(
        CreateShipmentRequest request,
        CancellationToken cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

And the implementation:

internal sealed class CreateShipmentHandler(
    ShipmentsDbContext context,
    IStockModuleApi stockApi,
    IEventPublisher eventPublisher,
    ILogger<CreateShipmentHandler> logger)
    : ICreateShipmentHandler
{
    public async Task<ErrorOr<ShipmentResponse>> HandleAsync(
        CreateShipmentRequest request,
        CancellationToken cancellationToken)
    {
        // All the code remains the same
    }
}
Enter fullscreen mode Exit fullscreen mode

In the API endpoint, I replaced IMediator with ICreateShipmentHandler:

public class CreateShipmentEndpoint : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapPost("/api/shipments", Handle);
    }

    private static async Task<IResult> Handle(
        [FromBody] CreateShipmentRequest request,
        IValidator<CreateShipmentRequest> validator,
        ICreateShipmentHandler handler,
        CancellationToken cancellationToken)
    {
        var validationResult = await validator.ValidateAsync(request, cancellationToken);
        if (!validationResult.IsValid)
        {
            return Results.ValidationProblem(validationResult.ToDictionary());
        }

        var response = await handler.HandleAsync(request, cancellationToken);
        if (response.IsError)
        {
            return response.Errors.ToProblem();
        }

        return Results.Ok(response.Value);
    }
}
Enter fullscreen mode Exit fullscreen mode

And that's it.

This approach has the following benefits:

  • We are injecting an exact interface, no guesses about the handlers
  • Easier navigation and debugging: you can step into the handler implementation
  • No reflection and other magic

Of course, you can register every handler manually in the DI container:

builder.Services.AddScoped<ICreateShipmentHandler, CreateShipmentHandler>();
Enter fullscreen mode Exit fullscreen mode

But it's tiresome.
Let's explore a better option.

Implementing Handler Auto-Registration in DI

It's better to implement a simple auto-registration of our Handlers and their implementations, just like in MediatR.

First, we need a market interface that handlers will inherit:

/// <summary>
/// Marker interface for all handlers in the application
/// </summary>
public interface IHandler;

// Inheriting from the marker inteface
internal interface ICreateShipmentHandler : IHandler
{
    Task<ErrorOr<ShipmentResponse>> HandleAsync(
        CreateShipmentRequest request,
        CancellationToken cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

Note: we don't need to make any changes in the CreateShipmentHandler implementation.

Next, we need to make the assembly scanning and some reflection (but we only do this once on the application startup) to find and register our handlers:

public static class HandlerRegistrationExtensions
{
    /// <summary>
    /// Registers all handlers from the assembly containing the specified type
    /// </summary>
    /// <param name="services">The service collection</param>
    /// <param name="marker">A type from the assembly where handlers are located</param>
    /// <returns>The service collection for chaining</returns>
    public static IServiceCollection RegisterHandlersFromAssemblyContaining(
        this IServiceCollection services, Type marker)
    {
        RegisterCommandHandlers(services, marker.Assembly);
    }

    private static void RegisterCommandHandlers(IServiceCollection services, Assembly assembly)
    {
        var handlerTypes = assembly.GetTypes()
            .Where(t => t is { IsClass: true, IsAbstract: false }
                && t.IsAssignableTo(typeof(IHandler))
            .ToList();

        foreach (var implementationType in handlerTypes)
        {
            var interfaceType = implementationType.GetInterfaces()
                .FirstOrDefault(i => i != typeof(IHandler) && i.IsAssignableTo(typeof(IHandler)));

            if (interfaceType is not null)
            {
                services.AddScoped(interfaceType, implementationType);
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

Now we can call the RegisterHandlersFromAssemblyContaining method and pass the type in the assembly:

builder.Services.RegisterHandlersFromAssemblyContaining(typeof(CreateShipmentHandler));
Enter fullscreen mode Exit fullscreen mode

Replacing MediatR Notification Handlers

One of the best features in MediatR is the ability to have zero or many handlers for notifications.

Let's see how we can build this ourselves.

First, we need a couple of marker interfaces for the Event and EventHandler:

/// <summary>
/// Marker interface for all events in the application
/// </summary>
public interface IEvent;

/// <summary>
/// Marker interface for all event handlers in the application
/// </summary>
public interface IEventHandler;

/// <summary>
/// Base interface for typed event handlers
/// </summary>
/// <typeparam name="TEvent">Type of event handled by this handler</typeparam>
public interface IEventHandler<in TEvent> : IEventHandler where TEvent : IEvent
{
    Task HandleAsync(TEvent @event, CancellationToken cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

And here is our event publisher:

/// <summary>
/// Interface for publishing events
/// </summary>
public interface IEventPublisher
{
    /// <summary>
    /// Publishes an event to all registered handlers
    /// </summary>
    /// <typeparam name="TEvent">The type of event to publish</typeparam>
    /// <param name="event">The event instance</param>
    /// <param name="cancellationToken">Cancellation token</param>
    /// <returns>A task representing the asynchronous operation</returns>
    Task PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken)
        where TEvent : IEvent;
}

/// <summary>
/// Implementation of IEventPublisher that resolves all handlers for an event
/// and executes them in parallel
/// </summary>
public class EventPublisher(
    IServiceProvider serviceProvider)
    : IEventPublisher
{
    /// <inheritdoc />
    public async Task PublishAsync<TEvent>(
        TEvent @event,
        CancellationToken cancellationToken)
        where TEvent : IEvent
    {
        var eventType = @event.GetType();

        try
        {
            // Resolve all handlers for this event type
            var handlers = serviceProvider.GetServices<IEventHandler<TEvent>>().ToArray();
            if (handlers.Length == 0)
            {
                return;
            }

            // Execute all handlers and collect results
            var handlerTasks = handlers.Select(handler =>
                    ExecuteHandlerAsync(handler, @event, cancellationToken)
                ).ToList();

            await Task.WhenAll(handlerTasks);
        }
        catch (Exception ex)
        {
            throw;
        }
    }

    private async Task<Exception?> ExecuteHandlerAsync<TEvent>(
        IEventHandler<TEvent> handler, 
        TEvent @event, 
        CancellationToken cancellationToken) where TEvent : IEvent
    {
        await handler.HandleAsync(@event, cancellationToken);
    }
}
Enter fullscreen mode Exit fullscreen mode

The benefit of our implementation is that we have complete freedom to make any changes.
We can call all the handlers in parallel, make a commit of the database transaction, throw an aggregate exception, use a result pattern, and so on.

await Task.WhenAll(handlerTasks);
Enter fullscreen mode Exit fullscreen mode

Now we can use events to call the Carrier and Stock module that react to ShipmentCreatedEvent:

/// <summary>
/// Event that is raised when a shipment is created
/// </summary>
public sealed record ShipmentCreatedEvent(Shipment Shipment) : IEvent;

internal sealed class CreateShipmentHandler(
    ShipmentsDbContext context,
    IStockModuleApi stockApi,
    IEventPublisher eventPublisher,
    ILogger<CreateShipmentHandler> logger)
    : ICreateShipmentHandler
{
    public async Task<ErrorOr<ShipmentResponse>> HandleAsync(
        CreateShipmentRequest request,
        CancellationToken cancellationToken)
    {
        // Code remains the same as in the previous example

        try
        {
            var shipmentCreatedEvent = new ShipmentCreatedEvent(shipment);
            await eventPublisher.PublishAsync(shipmentCreatedEvent, cancellationToken);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Failed to process shipment events for order {OrderId}", request.OrderId);
            return Error.Failure("ShipmentProcessingFailed", "Failed to process shipment");
        }

        return shipment.MapToResponse();
    }
}
Enter fullscreen mode Exit fullscreen mode

Here is what our event handlers look like:

/// <summary>
/// Event handler that creates a carrier shipment when a shipment is created
/// </summary>
public sealed class CreateCarrierEventHandler(
    ICarrierModuleApi carrierApi,
    ILogger<CreateCarrierEventHandler> logger)
    : IEventHandler<ShipmentCreatedEvent>
{
}

/// <summary>
/// Event handler that updates stock when a shipment is created
/// </summary>
public sealed class UpdateStockEventHandler(
    IStockModuleApi stockApi,
    ILogger<UpdateStockEventHandler> logger)
    : IEventHandler<ShipmentCreatedEvent>
{
}
Enter fullscreen mode Exit fullscreen mode

We can implement event handler auto-registration the way we did for our Handlers.

Managing cross-cutting concerns without MediatR

Now it's time to address the final part of MediatR - pipeline behaviours (middlewares).
Developers used them to implement:

  • Validation
  • Mapping
  • Logging
  • Caching
  • Exception handling

Honestly, I won't reimplement this logic myself. Cross-cutting concerns can be addressed in the Presentation (API/transport level).

Here is how we can address cross-cutting concerns in the API layer in ASP .NET Core:

  1. Validation: manually calling validators inside Controllers or Minimal API endpoints.
    Alternatives: create a middleware or use the FastEndpoints library, which has FluentValidation built in.

  2. Mapping: inject an API request directly into the Handler without creating an additional layer of models (Request and Entity, no Command here).
    I recommend using manual mapping.

  3. Logging: use built-in HttpLogging for API requests.

  4. Caching: use Output caching or IMemory/IDistributed/HybridCache/FusionCache

  5. Exception handling: use the built-in Global Exception Handler in .NET 8+

Here is our Minimal API endpoint for creating a shipment:

public class CreateShipmentEndpoint : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapPost("/api/shipments", Handle);
    }

    private static async Task<IResult> Handle(
        [FromBody] CreateShipmentRequest request,
        IValidator<CreateShipmentRequest> validator,
        ICreateShipmentHandler handler,
        CancellationToken cancellationToken)
    {
        var validationResult = await validator.ValidateAsync(request, cancellationToken);
        if (!validationResult.IsValid)
        {
            return Results.ValidationProblem(validationResult.ToDictionary());
        }

        var response = await handler.HandleAsync(request, cancellationToken);
        if (response.IsError)
        {
            return response.Errors.ToProblem();
        }

        return Results.Ok(response.Value);
    }
}
Enter fullscreen mode Exit fullscreen mode

Here we manually call the validator and pass the API request model into the Handler.
I am not creating another layer of models, like I passed a Command in the MediatR implementation.

Summary

In this post, I've shown you what I consider the best alternative to MediatR.
From now on, I will be using manual handlers in the new projects.

They provide the following benefits when compared to MediatR:

  • Easier code navigation and debugging
  • Easier to understand code
  • No reflection and better performance
  • You have complete control over the implementation
  • Easier unit testing of the code that calls the Handlers
  • No licensing concerns

I believe that implementing cross-cutting concerns can be achieved with built-in ASP.NET Core features.

Don't get me wrong, MediatR is still an excellent library.
So, only you can decide what works best for your project.

P.S.: I publish all these blogs on my own website: Read original post here
Subscribe to my newsletter to improve your .NET skills.
Download the source code for this newsletter for free.

Comments 0 total

    Add comment