LightWeightMediator 1.0.0
dotnet add package LightWeightMediator --version 1.0.0
NuGet\Install-Package LightWeightMediator -Version 1.0.0
<PackageReference Include="LightWeightMediator" Version="1.0.0" />
<PackageVersion Include="LightWeightMediator" Version="1.0.0" />
<PackageReference Include="LightWeightMediator" />
paket add LightWeightMediator --version 1.0.0
#r "nuget: LightWeightMediator, 1.0.0"
#:package LightWeightMediator@1.0.0
#addin nuget:?package=LightWeightMediator&version=1.0.0
#tool nuget:?package=LightWeightMediator&version=1.0.0
LightWeightMediator
A convention-driven, lightweight mediator library that contains only the parts of MediatR you actually need.
Contents: IRequest, IRequestHandler, IMediator, IPipelineBehavior. Nothing else — concepts you don't use, such as notifications, stream requests and RequestPreProcessor, are deliberately left out.
Single dependency: Microsoft.Extensions.DependencyInjection.Abstractions.
Targets: .NET 8 and .NET 10.
Enforced rules
| # | Rule | Where it is caught |
|---|---|---|
| 1 | IRequest can only be a class / record |
Compile time + startup + runtime |
| 2 | Only an IRequest can be passed as the generic argument to IRequestHandler |
Compile time |
| 3 | A request cannot be bound to a second handler | Startup (fail-fast) |
How is Rule 1 enforced?
C# has no language feature to say "only reference types may implement this interface". So the rule is defended at three layers at once:
- Compile time — the
where TRequest : classconstraint onIRequestHandler<TRequest, TResponse>. If you try to write a handler for a struct request, the project won't compile. So a struct request without a handler can be defined in theory, but can never be executed. - Startup — if a value type implementing
IRequest<>is found while scanning assemblies, anInvalidRequestTypeExceptionis thrown.record structis caught here too. - Runtime — if
IMediator.Sendis called with a value type, again anInvalidRequestTypeException.
If you want it even stricter: you can write a Roslyn Analyzer that raises a direct compile error on a struct implementing
IRequest. The library is safe without it; the analyzer just surfaces the error instantly in the IDE.
How is Rule 2 enforced?
public interface IRequestHandler<in TRequest, TResponse>
where TRequest : class, IRequest<TResponse>
The constraint guarantees both "a non-request type cannot be passed" and "TResponse must match the response type declared by the request". You cannot write IRequestHandler<string, int>; nor can you write IRequestHandler<CreateOrderCommand, int> for a request that is IRequest<Guid>.
How is Rule 3 enforced?
A Dictionary<requestType, handlerType> is filled during scanning. The moment a second handler for the same request type appears, a DuplicateRequestHandlerException is thrown — the application won't start. It does not wait for the first request; it fails at deploy time.
LightWeightMediator.DuplicateRequestHandlerException:
Multiple handlers were found for request 'Shop.Orders.CreateOrderCommand':
'Shop.Orders.CreateOrderCommandHandler' and 'Shop.Orders.CreateOrderCommandHandlerV2'.
A request can be bound to only a single handler.
Note: the reverse is allowed — a single handler class can handle multiple requests, because the dictionary key is the request type.
Installation
dotnet add package LightWeightMediator
Program.cs:
using LightWeightMediator.Abstractions;
using LightWeightMediator.DependencyInjection;
builder.Services.AddLightWeightMediator(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
// Pipeline order = registration order. The first registered is the outermost.
cfg.AddOpenBehavior(typeof(CachingBehavior<,>));
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
});
Short form:
builder.Services.AddLightWeightMediator(typeof(Program).Assembly);
Configuration options
| Member | Description |
|---|---|
RegisterServicesFromAssembly(assembly) |
Adds an assembly to the scan list |
RegisterServicesFromAssemblies(params ...) |
Multiple assemblies |
RegisterServicesFromAssemblyContaining<T>() |
The assembly containing the type |
AddOpenBehavior(typeof(X<,>)) |
A behavior for all requests |
AddBehavior<T>() |
A behavior for a single request type |
HandlerLifetime |
Default Transient |
ValidateEveryRequestHasHandler |
If true, a request without a handler fails at startup |
Usage
1. Request that returns a response (query)
public sealed record GetUserByIdQuery(Guid Id) : IRequest<UserDto>;
public sealed class GetUserByIdQueryHandler : IRequestHandler<GetUserByIdQuery, UserDto>
{
private readonly AppDbContext _db;
public GetUserByIdQueryHandler(AppDbContext db) => _db = db;
public async Task<UserDto> Handle(GetUserByIdQuery request, CancellationToken cancellationToken)
{
var user = await _db.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == request.Id, cancellationToken);
return user is null ? throw new KeyNotFoundException() : new UserDto(user.Id, user.Name);
}
}
2. Request that returns no response (command)
Recommended way — identical to modern MediatR: implement the arity-1 IRequestHandler<TRequest> directly, return a plain Task, no Unit, no base class:
public sealed record DeleteUserCommand(Guid Id) : IRequest;
public sealed class DeleteUserCommandHandler : IRequestHandler<DeleteUserCommand>
{
public async Task Handle(DeleteUserCommand request, CancellationToken cancellationToken)
{
// ...
}
}
The library bridges this plain Task to Unit internally in the pipeline; IPipelineBehavior<TRequest, Unit> behaviors work for void handlers exactly the same way.
<details> <summary>Alternatives (backward compatibility)</summary>
If you prefer to return Unit manually, you can use the arity-2 signature too:
public sealed class DeleteUserCommandHandler : IRequestHandler<DeleteUserCommand, Unit>
{
public async Task<Unit> Handle(DeleteUserCommand request, CancellationToken cancellationToken)
{
// ...
return Unit.Value;
}
}
Or the ready-made base class to avoid dealing with Unit:
public sealed class DeleteUserCommandHandler : RequestHandler<DeleteUserCommand>
{
protected override async Task HandleAsync(DeleteUserCommand request, CancellationToken cancellationToken)
{
// ...
}
}
All three styles work with the same mediator.Send(command) call.
</details>
3. Sending
app.MapGet("/users/{id:guid}", async (Guid id, IMediator mediator, CancellationToken ct) =>
{
var user = await mediator.Send(new GetUserByIdQuery(id), ct);
return Results.Ok(user);
});
app.MapDelete("/users/{id:guid}", async (Guid id, IMediator mediator, CancellationToken ct) =>
{
await mediator.Send(new DeleteUserCommand(id), ct); // returns Task, you never see Unit
return Results.NoContent();
});
Inside a controller:
public sealed class UsersController : ControllerBase
{
private readonly IMediator _mediator;
public UsersController(IMediator mediator) => _mediator = mediator;
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id, CancellationToken ct)
=> Ok(await _mediator.Send(new GetUserByIdQuery(id), ct));
}
Pipeline Behavior
Exactly the same idea as ASP.NET Core middleware: code runs before and after next(), and if next() is never called the handler does not run (short-circuit).
Send()
└─ CachingBehavior (registered 1st → outermost)
└─ ValidationBehavior (registered 2nd)
└─ TransactionBehavior
└─ Handler (innermost)
Validation behavior (with FluentValidation)
public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : class, IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators) => _validators = validators;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (!_validators.Any())
{
return await next();
}
var context = new ValidationContext<TRequest>(request);
var failures = (await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken))))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count > 0)
{
throw new ValidationException(failures);
}
return await next();
}
}
Transaction behavior
public sealed class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : class, IRequest<TResponse>
{
private readonly AppDbContext _db;
public TransactionBehavior(AppDbContext db) => _db = db;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
// Open a transaction only for commands
if (request is not ITransactionalRequest)
{
return await next();
}
await using var transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
var response = await next();
await _db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return response;
}
}
Defining an empty marker interface like ITransactionalRequest and running the behavior only for those requests saves you from having to "register a separate behavior for each request".
Behavior for a single request
public sealed class CreateOrderAuditBehavior : IPipelineBehavior<CreateOrderCommand, Guid>
{
public async Task<Guid> Handle(
CreateOrderCommand request,
RequestHandlerDelegate<Guid> next,
CancellationToken cancellationToken)
{
var orderId = await next();
// audit...
return orderId;
}
}
// registration
cfg.AddBehavior<CreateOrderAuditBehavior>();
Architecture test (recommended)
The rules already fail at startup, but to catch them early in CI:
[Fact]
public void All_handlers_can_be_registered_according_to_the_rules()
{
var services = new ServiceCollection();
// If there is a duplicate handler or a struct request, an exception is thrown here
services.AddLightWeightMediator(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>());
var registry = services.BuildServiceProvider().GetRequiredService<IHandlerRegistry>();
Assert.NotEmpty(registry.Handlers);
}
With IHandlerRegistry you can also see at runtime which request is bound to which handler (useful for a health check / debug endpoint).
Performance notes
- Reflection is done once per request type (
MakeGenericType), and the result is kept in a staticConcurrentDictionary. All subsequent calls run fully typed. Mediatoris transient and stateless; it only carries theIServiceProviderof the scope it lives in.- The behavior chain is built on every request (delegate allocation). If profiling shows this is a problem, the chain can be cached per request type too — but measure first.
- Because it uses reflection, you may get a
MakeGenericTypewarning in Native AOT / trimming scenarios. There is no issue in a classic ASP.NET Core deployment.
Migrating from MediatR
| MediatR | LightWeightMediator |
|---|---|
IRequest<T> |
IRequest<T> (same) |
IRequestHandler<T, TResponse> |
IRequestHandler<T, TResponse> (same) |
IRequestHandler<T> (void, plain Task) |
IRequestHandler<T> (same) |
IMediator / ISender |
IMediator (same) |
IPipelineBehavior<T, R> |
IPipelineBehavior<T, R> (same) |
RequestHandlerDelegate<R> |
RequestHandlerDelegate<R> (same) |
INotification, IStreamRequest |
None |
AddMediatR(cfg => ...) |
AddLightWeightMediator(cfg => ...) |
In practice, changing using MediatR; lines to the LightWeightMediator.* namespaces and swapping the AddMediatR call is enough for most projects.
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
-
net8.0
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|