TL;DR
- MediatR 13 and later need a license (RPL 1.5 or commercial, possibly the free Community tier), and 12.5.0 stays Apache 2.0 but gets no more fixes.
- The same order endpoints, with FluentValidation and timing in one pipeline step, built three ways: licensed MediatR, the source-generated Mediator library (MIT), and plain handlers with Scrutor decorators.
- A side-by-side table and migration paths show what each choice costs: missing-handler errors at build time or at runtime, Native AOT support, behavior order, and what you maintain yourself.
Level: Intermediate · Prerequisites: An ASP.NET Core Web API with controllers and dependency injection; Replacing AutoMapper and MediatR After the License Change shows a full migration · Reading time: 13 min
Someone bumped MediatR from 12.5.0 to 14.3.0 in Store.Api, and after the deploy a new warning shows up in the production log under the category LuckyPennySoftware.MediatR.License: “You do not have a valid license key for the Lucky Penny software MediatR.” The app still works. Your manager asks the question that matters: do we pay, pin the old version, or get rid of it?
The honest answer depends on how much of MediatR you use. Most APIs use it for two things: send a command or query to one handler, and wrap every call in a pipeline behavior for validation and logging. This article implements exactly that in three ways so you can see the cost of each.
The short answer#
- Choose MediatR 13 or later with a license when you want the original library with vendor support, you rely on something Mediator lacks (such as generic requests), and the license fits (it may be free under the Community license).
- Choose MediatR 12.5.0 only as a holding position. It stays Apache 2.0, but its owner states it gets no support or security updates.
- Choose Mediator (the
Mediator.SourceGenerator package) when you like the MediatR programming model and want an MIT license, compile-time handler discovery, and Native AOT (ahead-of-time compilation) support. - Choose plain handler classes with Scrutor decorators when you have a handful of cross-cutting behaviors, want “Go to Definition” to land on the handler, and prefer no mediator dependency at all.
What is being compared#
MediatR is the original in-process mediator for .NET by Jimmy Bogard, now published by Lucky Penny Software. The current version is 14.3.0. Versions 13.0.0 and later are dual-licensed under the Reciprocal Public License 1.5 or a commercial license with a license key; versions before 13.0.0 were Apache 2.0, and 12.5.0 was the last of them.
Mediator by Martin Othamar is a MediatR-style library built on a source generator: it writes the IMediator implementation and the dependency injection (DI) registrations at compile time. The current version is 3.0.2, under the MIT license. Handlers return ValueTask, and the default lifetime is singleton.
Plain handler classes are your own IHandler<TRequest, TResult> interface, injected directly into controllers. Cross-cutting behavior comes from decorators registered with Scrutor (7.0.0, MIT license, maintained by Kristian Hellang), which adds assembly scanning and Decorate to IServiceCollection.
The same task in each option#
The task: POST /api/orders sends a PlaceOrderCommand, GET /api/orders/{id} sends a GetOrderByIdQuery. Every call passes through one behavior that runs FluentValidation validators and logs how long the handler took. Invalid input returns a 400 ProblemDetails response.
Shared code used by all three options#
1
2
| dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
|
1
2
3
4
5
6
7
8
9
10
11
12
| // File: Orders/Placing/OrderContracts.cs
namespace Store.Api.Orders.Placing;
public sealed record PlaceOrderLine(int ProductId, int Quantity);
public sealed record PlaceOrderRequest(List<PlaceOrderLine> Lines);
public sealed record PlaceOrderResult(int OrderId, decimal Total);
public sealed record OrderLineDto(int ProductId, int Quantity, decimal UnitPrice);
public sealed record OrderDto(int Id, OrderStatus Status, decimal Total, DateTime CreatedAtUtc, List<OrderLineDto> Lines);
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| // File: Orders/Placing/OrderWorkflow.cs
using Microsoft.EntityFrameworkCore;
using Store.Api.Data;
namespace Store.Api.Orders.Placing;
public sealed class OrderWorkflow(AppDbContext db, TimeProvider time)
{
public async Task<PlaceOrderResult> PlaceAsync(string userId, IReadOnlyList<PlaceOrderLine> lines, CancellationToken ct)
{
var ids = lines.Select(l => l.ProductId).ToList();
var prices = await db.Products.Where(p => ids.Contains(p.Id)).ToDictionaryAsync(p => p.Id, p => p.Price, ct);
var order = new Order { UserId = userId, CreatedAtUtc = time.GetUtcNow().UtcDateTime, Status = OrderStatus.Pending };
foreach (var line in lines)
order.Lines.Add(new OrderLine { ProductId = line.ProductId, Quantity = line.Quantity, UnitPrice = prices[line.ProductId] });
order.Total = order.Lines.Sum(l => l.Quantity * l.UnitPrice);
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return new PlaceOrderResult(order.Id, order.Total);
}
public Task<OrderDto?> GetAsync(int id, CancellationToken ct) =>
db.Orders.AsNoTracking()
.Where(o => o.Id == id)
.Select(o => new OrderDto(o.Id, o.Status, o.Total, o.CreatedAtUtc,
o.Lines.Select(l => new OrderLineDto(l.ProductId, l.Quantity, l.UnitPrice)).ToList()))
.FirstOrDefaultAsync(ct);
}
|
The business logic lives in OrderWorkflow, so the handlers in each option are one-liners and the comparison stays about dispatch. Order, OrderLine, and Product are the shared Store.Api entities.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // File: Orders/Placing/PlaceOrderCommandValidator.cs
using FluentValidation;
using Microsoft.EntityFrameworkCore;
using Store.Api.Data;
namespace Store.Api.Orders.Placing;
public sealed class PlaceOrderCommandValidator : AbstractValidator<PlaceOrderCommand>
{
public PlaceOrderCommandValidator(AppDbContext db)
{
RuleFor(c => c.UserId).NotEmpty();
RuleFor(c => c.Lines).NotEmpty();
RuleForEach(c => c.Lines).ChildRules(line =>
{
line.RuleFor(l => l.ProductId).GreaterThan(0);
line.RuleFor(l => l.Quantity).InclusiveBetween(1, 100);
});
RuleFor(c => c.Lines)
.MustAsync(async (lines, ct) =>
{
var ids = lines.Select(l => l.ProductId).Distinct().ToList();
return await db.Products.CountAsync(p => ids.Contains(p.Id), ct) == ids.Count;
})
.WithMessage("One or more products do not exist.")
.When(c => c.Lines is { Count: > 0 });
}
}
|
PlaceOrderCommand itself is declared in each option below, because each library wants its own marker interface. The validator does not care.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // File: Infrastructure/Pipeline/RequestValidation.cs
using FluentValidation;
using FluentValidation.Results;
namespace Store.Api.Infrastructure.Pipeline;
public static class RequestValidation
{
public static async Task ValidateAsync<T>(T request, IEnumerable<IValidator<T>> validators, CancellationToken ct)
{
var failures = new List<ValidationFailure>();
foreach (var validator in validators)
{
var result = await validator.ValidateAsync(request, ct);
failures.AddRange(result.Errors);
}
if (failures.Count > 0) throw new ValidationException(failures);
}
}
|
Validators run one after another on purpose: they share the scoped AppDbContext, which does not support parallel queries.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| // File: Infrastructure/ValidationExceptionHandler.cs
using FluentValidation;
using Microsoft.AspNetCore.Diagnostics;
namespace Store.Api.Infrastructure;
public sealed class ValidationExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
if (exception is not ValidationException validation) return false;
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new HttpValidationProblemDetails(errors)
{
Status = StatusCodes.Status400BadRequest,
Title = "One or more validation errors occurred."
}
});
}
}
|
1
2
3
4
5
6
7
8
9
| // Program.cs, before builder.Build()
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<OrderWorkflow>();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<ValidationExceptionHandler>();
// Program.cs, after builder.Build()
app.UseExceptionHandler();
|
The controllers use ICurrentUser from the playbook guide Issuing and Validating JWT Access Tokens in ASP.NET Core so the user id comes from the token, not the request body.
1
| dotnet add package MediatR
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // File: Orders/Placing/OrderRequests.cs
using MediatR;
namespace Store.Api.Orders.Placing;
public sealed record PlaceOrderCommand(string UserId, IReadOnlyList<PlaceOrderLine> Lines) : IRequest<PlaceOrderResult>;
public sealed record GetOrderByIdQuery(int Id) : IRequest<OrderDto?>;
public sealed class PlaceOrderHandler(OrderWorkflow orders) : IRequestHandler<PlaceOrderCommand, PlaceOrderResult>
{
public Task<PlaceOrderResult> Handle(PlaceOrderCommand request, CancellationToken cancellationToken) =>
orders.PlaceAsync(request.UserId, request.Lines, cancellationToken);
}
public sealed class GetOrderByIdHandler(OrderWorkflow orders) : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
public Task<OrderDto?> Handle(GetOrderByIdQuery request, CancellationToken cancellationToken) =>
orders.GetAsync(request.Id, cancellationToken);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // File: Infrastructure/Pipeline/ValidationLoggingBehavior.cs
using System.Diagnostics;
using FluentValidation;
using MediatR;
namespace Store.Api.Infrastructure.Pipeline;
public sealed class ValidationLoggingBehavior<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators,
ILogger<ValidationLoggingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
await RequestValidation.ValidateAsync(request, validators, cancellationToken);
var started = Stopwatch.GetTimestamp();
var response = await next();
logger.LogInformation("{Request} handled in {ElapsedMs:F1} ms",
typeof(TRequest).Name, Stopwatch.GetElapsedTime(started).TotalMilliseconds);
return response;
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // File: Orders/OrdersController.cs
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Store.Api.Auth;
using Store.Api.Orders.Placing;
namespace Store.Api.Orders;
[ApiController]
[Authorize]
[Route("api/orders")]
public sealed class OrdersController(IMediator mediator, ICurrentUser currentUser) : ControllerBase
{
[HttpPost]
public async Task<ActionResult<PlaceOrderResult>> Place(PlaceOrderRequest request, CancellationToken ct)
{
var result = await mediator.Send(new PlaceOrderCommand(currentUser.UserId!, request.Lines), ct);
return CreatedAtAction(nameof(GetById), new { id = result.OrderId }, result);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<OrderDto>> GetById(int id, CancellationToken ct)
{
var order = await mediator.Send(new GetOrderByIdQuery(id), ct);
return order is null ? NotFound() : order;
}
}
|
1
2
3
4
5
6
7
| // Program.cs, before builder.Build() (add using Store.Api.Infrastructure.Pipeline;)
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.AddOpenBehavior(typeof(ValidationLoggingBehavior<,>));
cfg.LicenseKey = builder.Configuration["MediatR:LicenseKey"]; // MediatR 13+ only; delete on 12.x
});
|
RegisterServicesFromAssemblyContaining scans the assembly with reflection at startup and registers every handler.await next() compiles on 12.4, 12.5, and 14.x. From 12.5.0 the delegate also accepts an optional CancellationToken.- Instead of configuration, MediatR also reads the key from the
MEDIATR_LICENSE_KEY or LUCKYPENNY_LICENSE_KEY environment variables.
1
2
| dotnet add package Mediator.SourceGenerator --version 3.0.*
dotnet add package Mediator.Abstractions --version 3.0.*
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // File: Orders/Placing/OrderRequests.cs
using Mediator;
namespace Store.Api.Orders.Placing;
public sealed record PlaceOrderCommand(string UserId, IReadOnlyList<PlaceOrderLine> Lines) : ICommand<PlaceOrderResult>;
public sealed record GetOrderByIdQuery(int Id) : IQuery<OrderDto?>;
public sealed class PlaceOrderHandler(OrderWorkflow orders) : ICommandHandler<PlaceOrderCommand, PlaceOrderResult>
{
public async ValueTask<PlaceOrderResult> Handle(PlaceOrderCommand command, CancellationToken cancellationToken) =>
await orders.PlaceAsync(command.UserId, command.Lines, cancellationToken);
}
public sealed class GetOrderByIdHandler(OrderWorkflow orders) : IQueryHandler<GetOrderByIdQuery, OrderDto?>
{
public async ValueTask<OrderDto?> Handle(GetOrderByIdQuery query, CancellationToken cancellationToken) =>
await orders.GetAsync(query.Id, cancellationToken);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // File: Infrastructure/Pipeline/ValidationLoggingBehavior.cs
using System.Diagnostics;
using FluentValidation;
using Mediator;
namespace Store.Api.Infrastructure.Pipeline;
public sealed class ValidationLoggingBehavior<TMessage, TResponse>(
IEnumerable<IValidator<TMessage>> validators,
ILogger<ValidationLoggingBehavior<TMessage, TResponse>> logger)
: IPipelineBehavior<TMessage, TResponse> where TMessage : notnull, IMessage
{
public async ValueTask<TResponse> Handle(TMessage message, MessageHandlerDelegate<TMessage, TResponse> next,
CancellationToken cancellationToken)
{
await RequestValidation.ValidateAsync(message, validators, cancellationToken);
var started = Stopwatch.GetTimestamp();
var response = await next(message, cancellationToken);
logger.LogInformation("{Request} handled in {ElapsedMs:F1} ms",
typeof(TMessage).Name, Stopwatch.GetElapsedTime(started).TotalMilliseconds);
return response;
}
}
|
1
2
3
4
5
6
| // Program.cs, before builder.Build() (add using Mediator; and using Store.Api.Infrastructure.Pipeline;)
builder.Services.AddMediator((MediatorOptions options) =>
{
options.ServiceLifetime = ServiceLifetime.Scoped;
options.PipelineBehaviors = [typeof(ValidationLoggingBehavior<,>)];
});
|
The controller is the MediatR one with using Mediator; instead of using MediatR;. Send returns ValueTask<T>, and await handles that.
AddMediator is generated at build time with one registration per handler. A request without a handler produces a build warning instead of a runtime exception.ServiceLifetime.Scoped is required here. The default singleton lifetime would capture the scoped OrderWorkflow and AppDbContext, and DI scope validation in Development throws at startup.- Options are read by the generator, so values must be compile-time constants and inline
typeof(...) expressions.
Plain handler classes with Scrutor#
1
| dotnet add package Scrutor
|
1
2
3
4
5
6
7
| // File: Infrastructure/Handlers/IHandler.cs
namespace Store.Api.Infrastructure.Handlers;
public interface IHandler<in TRequest, TResult>
{
Task<TResult> HandleAsync(TRequest request, CancellationToken ct);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // File: Orders/Placing/OrderRequests.cs
using Store.Api.Infrastructure.Handlers;
namespace Store.Api.Orders.Placing;
public sealed record PlaceOrderCommand(string UserId, IReadOnlyList<PlaceOrderLine> Lines);
public sealed record GetOrderByIdQuery(int Id);
public sealed class PlaceOrderHandler(OrderWorkflow orders) : IHandler<PlaceOrderCommand, PlaceOrderResult>
{
public Task<PlaceOrderResult> HandleAsync(PlaceOrderCommand request, CancellationToken ct) =>
orders.PlaceAsync(request.UserId, request.Lines, ct);
}
public sealed class GetOrderByIdHandler(OrderWorkflow orders) : IHandler<GetOrderByIdQuery, OrderDto?>
{
public Task<OrderDto?> HandleAsync(GetOrderByIdQuery request, CancellationToken ct) =>
orders.GetAsync(request.Id, ct);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // File: Infrastructure/Handlers/ValidationLoggingDecorator.cs
using System.Diagnostics;
using FluentValidation;
using Store.Api.Infrastructure.Pipeline;
namespace Store.Api.Infrastructure.Handlers;
public sealed class ValidationLoggingDecorator<TRequest, TResult>(
IHandler<TRequest, TResult> inner,
IEnumerable<IValidator<TRequest>> validators,
ILogger<ValidationLoggingDecorator<TRequest, TResult>> logger) : IHandler<TRequest, TResult>
{
public async Task<TResult> HandleAsync(TRequest request, CancellationToken ct)
{
await RequestValidation.ValidateAsync(request, validators, ct);
var started = Stopwatch.GetTimestamp();
var result = await inner.HandleAsync(request, ct);
logger.LogInformation("{Request} handled in {ElapsedMs:F1} ms",
typeof(TRequest).Name, Stopwatch.GetElapsedTime(started).TotalMilliseconds);
return result;
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| // File: Orders/OrdersController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Store.Api.Auth;
using Store.Api.Infrastructure.Handlers;
using Store.Api.Orders.Placing;
namespace Store.Api.Orders;
[ApiController]
[Authorize]
[Route("api/orders")]
public sealed class OrdersController(ICurrentUser currentUser) : ControllerBase
{
[HttpPost]
public async Task<ActionResult<PlaceOrderResult>> Place(PlaceOrderRequest request,
[FromServices] IHandler<PlaceOrderCommand, PlaceOrderResult> handler, CancellationToken ct)
{
var result = await handler.HandleAsync(new PlaceOrderCommand(currentUser.UserId!, request.Lines), ct);
return CreatedAtAction(nameof(GetById), new { id = result.OrderId }, result);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<OrderDto>> GetById(int id,
[FromServices] IHandler<GetOrderByIdQuery, OrderDto?> handler, CancellationToken ct)
{
var order = await handler.HandleAsync(new GetOrderByIdQuery(id), ct);
return order is null ? NotFound() : order;
}
}
|
1
2
3
4
5
6
7
8
9
10
| // Program.cs, before builder.Build() (add using Store.Api.Infrastructure.Handlers;)
builder.Services.Scan(scan => scan
.FromAssemblyOf<Program>()
.AddClasses(classes => classes
.AssignableTo(typeof(IHandler<,>))
.Where(type => !type.IsGenericTypeDefinition))
.AsImplementedInterfaces()
.WithScopedLifetime());
builder.Services.Decorate(typeof(IHandler<,>), typeof(ValidationLoggingDecorator<,>));
|
- The
Where filter keeps the open generic decorator out of the scan. Without it, the decorator could register itself as a handler. Decorate wraps every existing IHandler<,> registration, so it must run after Scan. A second Decorate call wraps the first, which makes the last decorator the outermost one.- Scrutor’s
AddClasses scans only public classes by default, so internal handlers are silently skipped.
All three produce the same behavior. A bad request:
1
2
3
4
5
| POST https://localhost:7001/api/orders
Authorization: Bearer {{accessToken}}
Content-Type: application/json
{ "lines": [ { "productId": 1, "quantity": 0 }, { "productId": 999, "quantity": 1 } ] }
|
1
2
3
4
5
6
7
8
| {
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Lines[0].Quantity": [ "'Quantity' must be between 1 and 100. You entered 0." ],
"Lines": [ "One or more products do not exist." ]
}
}
|
The response is trimmed; ASP.NET Core also adds type and traceId. A valid order returns 201 Created, and the behavior or decorator logs the request name with the elapsed milliseconds.
Side by side#
| Criterion | MediatR 12.5 / 14.x | Mediator 3.x | Plain handlers + Scrutor |
|---|
| Setup effort | Low: one AddMediatR call | Low: two packages, AddMediator options | Low to medium: your own interface, Scan, Decorate |
| Learning curve | Familiar to most .NET teams | Same model as MediatR, ValueTask returns | Only DI and the decorator pattern |
| Startup cost | Reflection scan of the assembly | Registrations generated at build time | Reflection scan (none if you register by hand) |
| Runtime cost | Handler lookup, pipeline built from DI per call, Task allocations | Generated dispatch, ValueTask; lowest of the three mediators | Direct interface call through the decorator chain |
| Missing handler | Runtime exception on first Send | Build-time diagnostic | Runtime DI error on first request |
| Discoverability | “Go to Definition” on Send lands on IMediator | Same, but generated code is browsable | Goes to IHandler<,>; “Go to Implementation” lists the handler |
| Debugging | Wrapper and behavior frames in stack traces | Generated frames, can step through | Decorator frame, then handler |
| Testing | Handlers and behaviors are plain classes | Same | Same; no mediator to fake in controller tests |
| Native AOT | Reflection-based registration | Supported by design | Works with manual registration |
| License and cost | 12.x Apache 2.0; 13+ RPL 1.5 or commercial | MIT, free | Scrutor MIT, free |
| Maintenance | Active, commercial | Active open source project | You maintain a few dozen lines |
How to choose#
- Small team, a few dozen handlers, no notifications. Plain handlers. You lose nothing important, and new developers read the code without learning a library.
- Large codebase built on
IRequest and behaviors. Mediator keeps the shape of your code, so the migration is mechanical. Check that you do not use generic requests first; Mediator does not support them. - You need MediatR features that the others lack, or vendor support. License MediatR. At launch, the Community license was free for companies under 5 million US dollars in gross annual revenue and for non-production environments, with further conditions in the license text. Read the current terms; Lucky Penny decides eligibility.
- You cannot touch the code this quarter. Pin MediatR 12.5.0 and plan the move. Non-production use of 13+ is free under the license agreement, so you can test the upgrade without a key.
Switching later#
MediatR to Mediator. Change IRequest<T> to ICommand<T> or IQuery<T> (Mediator also has IRequest<T> if you want fewer edits), IRequestHandler to ICommandHandler or IQueryHandler, and Task<T> to ValueTask<T> in every handler. In behaviors, next() becomes next(message, cancellationToken) and the constraint becomes where TMessage : notnull, IMessage. Replace AddMediatR with AddMediator, set the lifetime, and list behaviors in PipelineBehaviors. Install the source generator only in the outermost project (the API), not in every class library.
MediatR 12 to 13 or later. The code is unchanged. Add the license key through cfg.LicenseKey or an environment variable and watch the startup log for the license validation message. MediatR.Contracts stays Apache 2.0, so shared contract assemblies do not change license.
MediatR or Mediator to plain handlers. Move one feature at a time. Make the handler implement IHandler<,> next to the MediatR interface, switch the controller to inject IHandler<,>, then delete the old interface. Behaviors become decorators with the same body. Notifications need a replacement, such as calling the subscribers directly or publishing a message to a queue.
Plain handlers to a mediator. Rename the interface and method, and delete the Scan and Decorate lines. Because the handlers were already small classes, this is the cheapest move of all.
Common pitfalls#
- Calling
Validate instead of ValidateAsync. A validator with MustAsync throws AsyncValidatorInvokedSynchronouslyException when called synchronously. - Running validators in parallel.
Task.WhenAll over validators that share one AppDbContext throws a concurrency exception. Run them in sequence. - Singleton handlers in Mediator. The default lifetime is singleton. Any handler that needs
AppDbContext forces ServiceLifetime.Scoped. - Behavior order. MediatR and Mediator run behaviors in registration order. Scrutor decorators run in reverse order of the
Decorate calls. Put validation outside transactions and retries. - Leaving the MediatR license warning in production logs. It is easy to miss in a noisy log. Add the key, or pin 12.5.0 on purpose with a comment that explains why.
- Internal handler classes. Scrutor’s
AddClasses, and FluentValidation’s AddValidatorsFromAssemblyContaining by default, skip non-public types.
Part of The .NET Field Guide. Previous: Replacing AutoMapper and MediatR After the License Change. Next in the series: FluentAssertions vs AwesomeAssertions vs Shouldly (coming soon).
Get the next articles by email: subscribe to the newsletter.
How this article was made: drafted with AI assistance, then reviewed by Softinbit Technology before publishing.