A lightweight, MediatR-compatible mediator for .NET.
📖 Overview, migration guide and FAQ: softwarefirst.gr/switchboard
Switchboard implements the request/response, notification, and pipeline-behavior surface of MediatR on top of Microsoft.Extensions.DependencyInjection, in a few hundred lines of code with a single dependency (Microsoft.Extensions.DependencyInjection.Abstractions). It was extracted from a production system that moved off MediatR when it became commercially licensed: swap your using directives, change one registration call, and your handlers, behaviors, and call sites compile unchanged.
dotnet add package SoftwareFirst.SwitchboardTargets net8.0, net9.0 and net10.0, so you can move off MediatR without moving frameworks first. The package ID is prefixed, but the assembly and namespace are both plain Switchboard — you write using Switchboard;.
Several MediatR alternatives exist now, and most compete on speed or feature count. Switchboard competes on being small:
- A few hundred lines, across about a dozen files you can read end to end in one sitting.
- One dependency —
Microsoft.Extensions.DependencyInjection.Abstractions, floored at the lowest patch of each major so it never drags your otherMicrosoft.Extensions.*packages forward. - No source generators, analyzers, or build-time magic. Plain reflection over the DI container, the way MediatR does it.
- A deliberately identical API surface — the migration is a find-and-replace, not a rewrite.
- Apache 2.0, extracted from a production system that made this exact switch.
If you need streaming, parallel publish strategies, or maximum throughput, a source-generated alternative is the better fit — the migration table below says so explicitly.
Define a request and its handler:
using Switchboard;
public sealed record GetOrder(int Id) : IRequest<OrderDto>;
public sealed class GetOrderHandler : IRequestHandler<GetOrder, OrderDto>
{
public Task<OrderDto> Handle(GetOrder request, CancellationToken cancellationToken)
=> /* ... */;
}Register the mediator and send:
services.AddSwitchboard(cfg => cfg
.RegisterServicesFromAssemblyContaining<GetOrderHandler>());public sealed class OrderController(ISender sender) : ControllerBase
{
[HttpGet("{id}")]
public Task<OrderDto> Get(int id, CancellationToken ct) => sender.Send(new GetOrder(id), ct);
}Void requests implement IRequest (no type argument) and are handled by IRequestHandler<TRequest>.
Behaviors wrap every handler, outermost first in the order they are added:
public sealed class LoggingBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
public async Task<TResponse> Handle(
TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
// before
var response = await next(cancellationToken);
// after
return response;
}
}services.AddSwitchboard(cfg => cfg
.RegisterServicesFromAssemblyContaining<GetOrderHandler>()
.AddOpenBehavior(typeof(LoggingBehaviour<,>)) // runs outermost
.AddOpenBehavior(typeof(ValidationBehaviour<,>))); // runs inside loggingA behavior that applies to one specific request/response pair goes in with AddBehavior:
services.AddSwitchboard(cfg => cfg
.RegisterServicesFromAssemblyContaining<GetOrderHandler>()
.AddOpenBehavior(typeof(LoggingBehaviour<,>)) // outermost
.AddBehavior<AuditGetOrder>() // IPipelineBehavior<GetOrder, OrderDto>
.AddOpenBehavior(typeof(ValidationBehaviour<,>))); // innermostOpen and closed behaviors share a single ordering, so the first one added is outermost regardless of which kind it is. Registering directly against the container still works too:
services.AddTransient<IPipelineBehavior<GetOrder, OrderDto>, MyBehavior>().
Void requests run through the same pipeline with TResponse == Unit, so open-generic behaviors apply to them unchanged.
public sealed record OrderPlaced(int OrderId) : INotification;
public sealed class SendReceipt : INotificationHandler<OrderPlaced> { /* ... */ }
public sealed class UpdateStats : INotificationHandler<OrderPlaced> { /* ... */ }await publisher.Publish(new OrderPlaced(42), cancellationToken);Handlers run sequentially, in registration order — never in parallel — so they can safely share scoped state such as an EF Core DbContext.
- Replace the
MediatRpackage reference withSoftwareFirst.Switchboard. - Replace
using MediatR;withusing Switchboard;. - Replace
services.AddMediatR(...)withservices.AddSwitchboard(...)— the configuration methods (RegisterServicesFromAssemblyContaining,RegisterServicesFromAssembly,AddOpenBehavior) keep their names.
| MediatR feature | Switchboard |
|---|---|
IRequest, IRequest<T>, IRequestHandler<,>, IRequestHandler<> |
✅ identical |
INotification, INotificationHandler<> |
✅ identical |
IPipelineBehavior<,> (first registered runs outermost) |
✅ identical |
ISender, IPublisher, IMediator, Unit |
✅ identical |
Untyped Send(object) / Publish(object) |
✅ identical |
| Assembly scanning for handlers | ✅ identical |
Streaming (IStreamRequest<>) |
❌ not implemented |
| Request pre-/post-processors | ❌ use a pipeline behavior |
Exception handlers/actions (IRequestExceptionHandler) |
❌ use a pipeline behavior |
| Custom publish strategies (parallel, etc.) | ❌ sequential only |
- Cancellation is never lost. The
CancellationTokenpassed toSendflows to every behavior and the handler, even when a behavior callsnext()without arguments. - Handlers and behaviors are transient; they are resolved from the scope the mediator was resolved from, so scoped dependencies work as expected.
- Publishing to zero handlers is a no-op, mirroring MediatR.
- Handler-type wrappers are cached statically per request type; the cache is stateless and thread-safe.