JM.MiniMediator
1.0.0
dotnet add package JM.MiniMediator --version 1.0.0
NuGet\Install-Package JM.MiniMediator -Version 1.0.0
<PackageReference Include="JM.MiniMediator" Version="1.0.0" />
<PackageVersion Include="JM.MiniMediator" Version="1.0.0" />
<PackageReference Include="JM.MiniMediator" />
paket add JM.MiniMediator --version 1.0.0
#r "nuget: JM.MiniMediator, 1.0.0"
#:package JM.MiniMediator@1.0.0
#addin nuget:?package=JM.MiniMediator&version=1.0.0
#tool nuget:?package=JM.MiniMediator&version=1.0.0
MiniMediator
A small, from-scratch in-process mediator for .NET 8 — a MIT-friendly stand-in for the
request/handler + pipeline-behavior parts of MediatR. One dependency:
Microsoft.Extensions.DependencyInjection.Abstractions.
Register
builder.Services.AddMediator(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); // optional
// cfg.Lifetime = ServiceLifetime.Scoped; // default is Transient
});
Request + handler
public sealed record GetUserById(int Id) : IRequest<UserDto>;
public sealed class GetUserByIdHandler : IRequestHandler<GetUserById, UserDto>
{
private readonly IUserRepository _repo;
public GetUserByIdHandler(IUserRepository repo) => _repo = repo;
public async Task<UserDto> Handle(GetUserById request, CancellationToken ct)
=> await _repo.GetAsync(request.Id, ct);
}
Command with no return value — use IRequest (returns Unit):
public sealed record DeleteUser(int Id) : IRequest;
public sealed class DeleteUserHandler : IRequestHandler<DeleteUser>
{
public async Task<Unit> Handle(DeleteUser request, CancellationToken ct)
{
// ... delete ...
return Unit.Value;
}
}
Commands & queries (CQRS naming)
IQuery/ICommand are just IRequest with clearer intent — reads vs writes. They dispatch the
exact same way, so ISender.Send(...) still works for everything; use whichever name documents the
operation best. No extra registration needed — the scanner picks up their handlers automatically.
Query — reads (get/list/search). Always returns a value:
public sealed record GetUserById(int Id) : IQuery<UserDto>;
public sealed class GetUserByIdHandler : IQueryHandler<GetUserById, UserDto>
{
private readonly IUserRepository _repo;
public GetUserByIdHandler(IUserRepository repo) => _repo = repo;
public async Task<UserDto> Handle(GetUserById query, CancellationToken ct)
=> await _repo.GetAsync(query.Id, ct);
}
Command that returns a result — create/update (e.g. the new id):
public sealed record CreateUser(string Name, string Email) : ICommand<int>;
public sealed class CreateUserHandler : ICommandHandler<CreateUser, int>
{
private readonly IUserRepository _repo;
public CreateUserHandler(IUserRepository repo) => _repo = repo;
public async Task<int> Handle(CreateUser command, CancellationToken ct)
=> await _repo.CreateAsync(command.Name, command.Email, ct); // returns new id
}
Command with no return value — delete (returns Unit):
public sealed record DeleteUser(int Id) : ICommand;
public sealed class DeleteUserHandler : ICommandHandler<DeleteUser>
{
public async Task<Unit> Handle(DeleteUser command, CancellationToken ct)
{
// ... delete ...
return Unit.Value;
}
}
Dispatch. All three go through the same sender. Plain Send works, or use the optional
.Query(...) / .Command(...) sugar for readability:
var user = await _sender.Send(new GetUserById(id), ct); // general-purpose
var user2 = await _sender.Query(new GetUserById(id), ct); // reads as a query
var newId = await _sender.Command(new CreateUser("Ann", "a@x.co"), ct);
await _sender.Command(new DeleteUser(id), ct);
Rule of thumb: IQuery = get/read (must return something), ICommand = create/update/delete.
Reach for plain IRequest only when an operation doesn't clearly fit either bucket.
FastEndpoints endpoint
Inject ISender via the constructor (FastEndpoints resolves endpoints from DI):
using FastEndpoints;
using MiniMediator;
public sealed class GetUserEndpoint : Endpoint<GetUserRequest, UserDto>
{
private readonly ISender _sender;
public GetUserEndpoint(ISender sender) => _sender = sender;
public override void Configure()
{
Get("/users/{id}");
AllowAnonymous();
}
public override async Task HandleAsync(GetUserRequest req, CancellationToken ct)
{
var user = await _sender.Send(new GetUserById(req.Id), ct);
await SendAsync(user, cancellation: ct);
}
}
Multiple handlers in one endpoint
Send always resolves exactly one handler per request type — that is the point of the
mediator pattern. "An endpoint with multiple handlers" therefore means one of two things:
A) One endpoint that dispatches several different requests
Each Send still goes to its own single handler; the endpoint just orchestrates them:
public override async Task HandleAsync(GetUserRequest req, CancellationToken ct)
{
var user = await _sender.Send(new GetUserById(req.Id), ct);
var orders = await _sender.Send(new GetOrdersForUser(req.Id), ct);
var wallet = await _sender.Send(new GetWalletBalance(req.Id), ct);
await SendAsync(new UserProfileDto(user, orders, wallet), cancellation: ct);
}
If those requests are independent you can run them concurrently:
var userTask = _sender.Send(new GetUserById(req.Id), ct);
var ordersTask = _sender.Send(new GetOrdersForUser(req.Id), ct);
var walletTask = _sender.Send(new GetWalletBalance(req.Id), ct);
await Task.WhenAll(userTask, ordersTask, walletTask);
await SendAsync(new UserProfileDto(userTask.Result, ordersTask.Result, walletTask.Result), cancellation: ct);
Caveat: do not share one scoped DbContext across concurrent Send calls — EF Core's
DbContext is not thread-safe. Either keep those calls sequential, or give each handler its own
scope/context.
B) One trigger, many reactions → use a notification, not a request
If you want a single event to invoke several handlers (e.g. after creating a user: send email,
write audit log, warm cache), publish an INotification. All registered handlers run:
public sealed record UserCreated(int UserId) : INotification;
public sealed class SendWelcomeEmail : INotificationHandler<UserCreated> { /* ... */ }
public sealed class WriteAuditLog : INotificationHandler<UserCreated> { /* ... */ }
public sealed class WarmProfileCache : INotificationHandler<UserCreated> { /* ... */ }
// In the endpoint (inject IPublisher, or IMediator to get both Send + Publish):
await _publisher.Publish(new UserCreated(user.Id), ct); // all three handlers run
Don't register two handlers for the same request
Registering two IRequestHandler<GetUserById, UserDto> implementations does not fan out.
Send calls GetRequiredService, which returns the last one registered and silently ignores
the other — a common source of "why is my other handler never hit?" bugs. If you need multiple
reactions to the same input, use Publish (option B). To catch accidental duplicates at startup,
add a guard after AddMediator:
var dupes = builder.Services
.Where(d => d.ServiceType.IsGenericType &&
d.ServiceType.GetGenericTypeDefinition() == typeof(IRequestHandler<,>))
.GroupBy(d => d.ServiceType)
.Where(g => g.Count() > 1)
.Select(g => g.Key.ToString())
.ToList();
if (dupes.Count > 0)
throw new InvalidOperationException(
"Multiple handlers registered for the same request: " + string.Join(", ", dupes));
Pipeline behavior
public sealed class LoggingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _log;
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> log) => _log = log;
public async Task<TResponse> Handle(
TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
_log.LogInformation("Handling {Request}", typeof(TRequest).Name);
var sw = System.Diagnostics.Stopwatch.StartNew();
var response = await next();
_log.LogInformation("Handled {Request} in {Elapsed}ms", typeof(TRequest).Name, sw.ElapsedMilliseconds);
return response;
}
}
Behaviors run in registration order (first registered = outermost).
Notifications
public sealed record UserRegistered(int UserId) : INotification;
public sealed class SendWelcomeEmail : INotificationHandler<UserRegistered>
{
public Task Handle(UserRegistered n, CancellationToken ct) { /* ... */ return Task.CompletedTask; }
}
await _mediator.Publish(new UserRegistered(user.Id), ct);
Notes
- The mediator must be transient or scoped (never singleton) so it captures the request-scoped
IServiceProvider— scoped handlers (e.g.DbContext) then resolve correctly. - Notification dispatch is sequential; change the loop in
NotificationHandlerWrapperImpltoTask.WhenAll(...)for parallel handlers. IQuery/ICommandare naming conventions overIRequest; they need no special dispatch or registration and share the same one-handler-per-request rule.- Not included: streaming requests, pre/post processors, exception handlers — add as needed.
| 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 was computed. 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. |
-
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 |
|---|---|---|
| 1.0.0 | 134 | 7/23/2026 |