What it looks like
The PoC in the solution dispatches one command twice — once happily, once where the main handler throws.
Pre Handling command: John Second Pre Handling command: John Handling command: John Post Handled command: John Pre Handling command: John Doe Second Pre Handling command: John Doe Failed handling command: John Doe, Exception: Invalid name
PoC console output — two pre-handlers, one main handler, one post-handler, and the fail-handler picking up the exception.
What it does
One call, four stages. Each stage asks the container for GetServices<THandler>(), so a stage can hold zero, one or many handlers — every one of them runs.
await dispatcher.DispatchAsync(new MyCommand { Name = "John" }, cancellationToken)
+------------------------------ try ------------------------------+
| 1 IPreCommandHandler<TCommand> all of them, awaited |
| 2 ICommandHandler<TCommand> the actual work |
| 3 IPostCommandHandler<TCommand> all of them, awaited |
+----------------------------- catch -----------------------------+
|
v
4 IFailCommandHandler<TCommand>.HandleAsync(command, exception)
| Stage | Interface | When it runs |
|---|---|---|
1 Pre | IPreCommandHandler<T> | Before the work — validate, normalize, load context, log. |
2 Main | ICommandHandler<T> | The command itself. Zero, one or several handlers per command type. |
3 Post | IPostCommandHandler<T> | After the work — publish events, drop caches, send mail. |
4 Fail | IFailCommandHandler<T> | When anything in 1–3 throws: it receives the command and the exception. |
Why write this yourself?
Because the pattern is older than the libraries — and the best-known library is no longer free for every kind of project.
The mediator pattern is one of the 23 classic design patterns from the 1994 “Gang of Four” book: an object that encapsulates how a group of objects interact, so a caller sends one message to the mediator instead of knowing every collaborator. In .NET that grew into in-process messaging: a request or command object, one or more handlers, and a dispatcher that looks the handler up in the container.
MediatR is the best-known implementation. Up to 12.x it was published under Apache-2.0. From 13.0.0 (July 2025) the line ships under RPL-1.5 — a reciprocal (copyleft) licence — unless you buy the commercial licence from Lucky Penny Software, and the library now expects a licence key (cfg.LicenseKey, or the MEDIATR_LICENSE_KEY / LUCKYPENNY_LICENSE_KEY environment variables).
| Option | Licence | What it means for a closed-source app |
|---|---|---|
| MediatR 12.x and earlier | Apache-2.0 | Free and permissive — but no longer the maintained line. |
| MediatR 13.x / 14.x | RPL-1.5 or paid | RPL is copyleft: if you do not want to publish your own source, you need the commercial licence — plus a key at runtime. |
| Dispatcher (this project) | your own source | Six interfaces and ~70 lines you can read, rename and extend. No key, no fee, no copyleft. The only package is Microsoft.Extensions.DependencyInjection. |
Sources: github.com/LuckyPennySoftware/MediatR and its LICENSE.md, luckypennysoftware.com/license, and the Apache-2.0 listing for MediatR 12.4.1 on nuget.org.
The code, piece by piece
Everything below is the real source in the download — eight small files in one class library.
1. The command
A command is a marker plus an identity. The identity is handy in logs and traces.
namespace Caldro.Dispatcher.Interfaces
{
public interface ICommand
{
public string Id { get; }
}
}
2. The handler contract
One async method, cancellation-aware. Note the in keyword: it makes the type parameter contravariant, so a handler written for a base command can serve a more specific one — the C# generic-variance trick that mediator libraries advertise.
public interface ICommandHandler<in TCommand> where TCommand : ICommand
{
Task HandleAsync(TCommand command, CancellationToken cancellationToken = default);
}
3. The dispatcher interface
public interface ICommandDispatcher
{
Task DispatchAsync<TCommand>(TCommand command, CancellationToken cancellationToken = default)
where TCommand : ICommand;
}
4. The dispatcher itself — the whole engine
This is the file that does the work. No base class to inherit, no behaviour pipeline to learn, no licence check.
using Caldro.Dispatcher.Interfaces;
using Microsoft.Extensions.DependencyInjection;
namespace Caldro.Dispatcher;
public class CommandDispatcher : ICommandDispatcher
{
private readonly IServiceProvider _serviceProvider;
public CommandDispatcher(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task DispatchAsync<TCommand>(TCommand command, CancellationToken cancellationToken = default)
where TCommand : ICommand
{
try
{
var preHandlers = _serviceProvider.GetServices<IPreCommandHandler<TCommand>>();
await Task.WhenAll(preHandlers.Select(h => h.HandleAsync(command, cancellationToken)));
var handlers = _serviceProvider.GetServices<ICommandHandler<TCommand>>();
await Task.WhenAll(handlers.Select(h => h.HandleAsync(command, cancellationToken)));
var postHandlers = _serviceProvider.GetServices<IPostCommandHandler<TCommand>>();
await Task.WhenAll(postHandlers.Select(h => h.HandleAsync(command, cancellationToken)));
}
catch (Exception exception)
{
var failHandlers = _serviceProvider.GetServices<IFailCommandHandler<TCommand>>();
await Task.WhenAll(failHandlers.Select(h => h.HandleAsync(command, exception, cancellationToken)));
}
}
}
- GetServices, not GetService — a stage runs every registered handler, not just the first one the container returns.
- One try for three stages — pre, main and post share a single
catch, so a failure anywhere lands in the fail stage. A throwing pre-handler therefore stops the actual work from running. - Task.WhenAll — the handlers of a stage start together and are awaited together, so a stage is as slow as its slowest handler and the order inside a stage is not defined (see ideas).
- Nothing is rethrown — after the fail handlers have run, the exception is swallowed. Handy for fire-and-forget commands, surprising for anything else (again: see ideas).
5. The three hook interfaces
Pre, post and fail are the same shape as the main handler — except that the fail stage also receives the exception.
public interface IPreCommandHandler<in TCommand> where TCommand : ICommand
{
Task HandleAsync(TCommand command, CancellationToken cancellationToken = default);
}
public interface IPostCommandHandler<in TCommand> where TCommand : ICommand
{
Task HandleAsync(TCommand command, CancellationToken cancellationToken = default);
}
public interface IFailCommandHandler<in TCommand> where TCommand : ICommand
{
Task HandleAsync(TCommand command, Exception exception, CancellationToken cancellationToken = default);
}
6. Registration — finding the handlers
One extension method scans an assembly and wires up all four stages.
using System.Reflection;
using Caldro.Dispatcher.Interfaces;
using Microsoft.Extensions.DependencyInjection;
namespace Caldro.Dispatcher
{
public static class CommandHandlerRegistration
{
public static void RegisterDispatcher(this IServiceCollection services, Assembly assembly)
{
services.AddTransient<ICommandDispatcher, CommandDispatcher>();
RegisterHandlersByInterface(services, assembly, typeof(IPreCommandHandler<>));
RegisterHandlersByInterface(services, assembly, typeof(ICommandHandler<>));
RegisterHandlersByInterface(services, assembly, typeof(IPostCommandHandler<>));
RegisterHandlersByInterface(services, assembly, typeof(IFailCommandHandler<>));
}
private static void RegisterHandlersByInterface(IServiceCollection services, Assembly assembly, Type handlerInterfaceType)
{
var handlerTypes = assembly.GetTypes()
.Where(t => t.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == handlerInterfaceType) && !t.IsInterface && !t.IsAbstract);
foreach (var handlerType in handlerTypes)
{
var interfaces = handlerType.GetInterfaces()
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == handlerInterfaceType);
foreach (var @interface in interfaces)
{
services.AddTransient(@interface, handlerType);
}
}
}
}
}
- It registers the closed interface —
services.AddTransient(typeof(ICommandHandler<MyCommand>), typeof(MyCommandHandler)). That is exactly the type the dispatcher asks for, so no generic type factory is needed at dispatch time. - Interfaces and abstract classes are skipped, and so are open generic handlers such as
class Logging<TCommand> : IPreCommandHandler<TCommand>— a genuinely useful thing to add (see ideas). @interface— the variable needs the@becauseinterfaceis a C# keyword. A small detail that is easy to trip over.- One scan at startup — the reflection cost is paid once while the container is built; dispatching itself is just container lookups.
How to use it
Three steps to write, one line to register, one call to dispatch.
Write a command
Any class that implements ICommand: give it an Id and whatever data the work needs.
Write handlers
Implement any of the four stages for that command type — validation, the work itself, cleanup, or error handling.
Register once
services.RegisterDispatcher(assembly) wires up the dispatcher and every handler found in that assembly.
Dispatch
Resolve ICommandDispatcher and call DispatchAsync, with a CancellationToken for real work.
The whole picture in one file
using Caldro.Dispatcher.Interfaces;
using Microsoft.Extensions.DependencyInjection;
// 1 - a command
public class MyCommand : ICommand
{
public string Id { get; } = Guid.NewGuid().ToString();
public string? Name { get; set; }
}
// 2 - as many handler stages as you like
public class MyPreCommandHandler : IPreCommandHandler<MyCommand>
{
public Task HandleAsync(MyCommand command, CancellationToken cancellationToken = default)
{
Console.WriteLine($"Pre: {command.Name}");
return Task.CompletedTask;
}
}
public class MyCommandHandler : ICommandHandler<MyCommand>
{
public Task HandleAsync(MyCommand command, CancellationToken cancellationToken = default)
{
if (command.Name == "John Doe")
{
throw new InvalidOperationException("Invalid name");
}
Console.WriteLine($"Handling: {command.Name}");
return Task.CompletedTask;
}
}
public class MyFailCommandHandler : IFailCommandHandler<MyCommand>
{
public Task HandleAsync(MyCommand command, Exception exception, CancellationToken cancellationToken = default)
{
Console.WriteLine($"Failed: {command.Name}, {exception.Message}");
return Task.CompletedTask;
}
}
// 3 - register once: finds the dispatcher and all four stages in the assembly
var services = new ServiceCollection();
services.RegisterDispatcher(typeof(MyCommand).Assembly);
// 4 - dispatch
var provider = services.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<ICommandDispatcher>();
await dispatcher.DispatchAsync(new MyCommand { Name = "John" });
await dispatcher.DispatchAsync(new MyCommand { Name = "John Doe" }); // throws inside -> fail handler runs
Swap MyCommand for anything that implements ICommand, and ServiceCollection for the container you already use: the registration is plain IServiceCollection, so ASP.NET Core, worker services, console apps and test hosts all behave the same way.
Key features
Small, but not toy-sized.
Zero mediator packages
The only dependency is Microsoft.Extensions.DependencyInj.. No licence key, no fee, no copyleft clause.
Four stages
Pre, main, post and fail hooks per command type — the useful part of a behaviour pipeline, without the ceremony.
Many handlers per stage
A stage resolves all registrations, so one command can fan out to several handlers.
Async and cancellable
Everything returns Task and accepts a CancellationToken.
Register by scanning
One call finds the dispatcher and every handler in an assembly — no per-handler wiring to forget.
Readable engine
Two classes and six interfaces. You can hold the whole thing in your head, which matters when something misbehaves in production.
Yours to extend
It is your source code, so changing it is a normal commit — not a fork, a wrapper or a licence review.
Unit tested
18 xUnit tests with Moq cover stage order, failure paths, multiple handlers and registration.
18 tests, no surprises
xUnit and Moq. The interesting ones are the ordering and failure tests, because those are the promises the dispatcher makes.
dotnet test Test summary: total: 18; failed: 0; succeeded: 18; skipped: 0
WithValidCommand_ExecutesHandlersExecutesPreHandlersBeforeMainHandlersExecutesPostHandlersAfterMainHandlersExecutesFullPipeline_InCorrectOrderWhenMainHandlerThrows_ExecutesFailHandlersWhenPreHandlerThrows_SkipsMainAndPostHandlers_ExecutesFailHandlersWithMultipleHandlers_ExecutesAllHandlersWithMultiplePreHandlers_ExecutesAllPreHandlersWhenMultipleFailHandlers_ExecutesAllFailHandlersWithCancellationToken_PassesCancellationTokenWhenNoHandlersRegistered_CompletesSuccessfullyCommandPassedToAllHandlers
RegistersCommandDispatcherRegistersPreCommandHandlersRegistersCommandHandlersRegistersPostCommandHandlersRegistersFailCommandHandlersRegistersMultipleHandlersAsTransient
Ideas — what I would add next
The boilerplate is deliberately minimal, so the interesting work is still open. If you extend it, these are the gaps I would close first.
Make the order deterministic
Stages go through Task.WhenAll, so the handlers of one stage start together and their order is undefined. Await them one by one, or give handlers a sort order, whenever the sequence matters — logging before validation, for instance.
Middleware alternative
An ICommandPipeline<TCommand> with a next delegate gives you one place for logging, validation, transactions, retries and timing — the behaviour-pipeline idea, in about twenty lines.
Check cancellation between stages
Call cancellationToken.ThrowIf..Requested() between stages, so an aborted request skips the remaining work instead of finishing it.
Support open generic handlers
A class Logging<TCommand> : IPreCommandHandler<TCommand> is skipped by the scan today. Detect open generics and register them as AddTransient(typeof(IPreCommandHand..), implType) and a single handler can cover every command.
Return results, not just void
Add ICommand<TResult> plus a DispatchAsync<TCommand, TResult> overload, and the same dispatcher also serves queries.
Add observability
The dispatcher is the one place every command passes through: the natural spot for an OpenTelemetry span, a duration histogram and one structured log line per command.
About this project
Why it came to be.
I have used the mediator pattern since long before there was a NuGet package for it — it is a chapter in a 1994 book, and nearly every project I joined reinvented the same three hooks around it. Then the best-known .NET implementation moved to a copyleft-or-paid model, and that made me look at what I actually depend on: an interface, a container lookup and the order in which things run.
So here is that, written down once, with tests. This is the pattern, not a product: no key, no invoice, no clause telling you what you may do with your own source code. Copy it into a solution, rename the namespaces, add the pipeline behaviour and the ordering rules your project needs — and if it saves you a licence negotiation over a design pattern, all the better.
If you would rather use a package, there are free alternatives out there too — that is a perfectly fine choice. Just make it a choice.
Download the source
The complete Caldro.Dispatcher solution — the library, the PoC console app and the 18 unit tests — in a single archive.
⬇ Download Caldro.Dispatcher.zip