Switchboard implements the request/response, notification and pipeline-behavior surface of
MediatR on top of Microsoft.Extensions.DependencyInjection. 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.Switchboard
Version 1.2 is a drop-in upgrade from 1.1: nothing was removed or renamed. Everything new is opt-in and adds no dependency — it's the safety net each of our own apps ended up writing by hand.
services.ValidateSwitchboard() reads your registrations — without building the
container or constructing a handler — and stops the app at startup with every problem listed:
requests with no handler, requests with two, and behaviors that never run for void requests.
A span per Send, per Publish and per notification handler, plus
switchboard.request.duration and switchboard.notification.duration
histograms — through ActivitySource and Meter, so there's no package to add.
AddSource line to switch on
In Blazor Server the DI scope is the whole circuit, so one DbContext serves every
click. UseScopePerDispatch() gives each top-level Send and
Publish its own scope, and a callback carries the current user across.
Two modules that both scan a shared assembly used to register every handler twice — so every
notification handler ran twice. AddSwitchboard is now safe to call as often as your
composition root needs.
Bump the package to 1.2.1, then add ValidateSwitchboard() just before
Build() and fix what it reports. The usual finding is a behavior constrained to
IRequest<TResponse> — the shape most templates ship — that has been silently
skipping your void commands. Constrain it to IBaseRequest instead.
Telemetry and scope per dispatch are opt-in. The upgrade guide walks through each step, including replacing a hand-written Blazor scoping sender.
builder.Services.AddSwitchboard(cfg => cfg
.RegisterServicesFromAssemblyContaining<GetOrderHandler>()
.UseScopePerDispatch()); // Blazor Server: a scope per command
// Spans and duration histograms — nothing is recorded until you subscribe.
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddSource(SwitchboardTelemetry.ActivitySourceName))
.WithMetrics(m => m.AddMeter(SwitchboardTelemetry.MeterName));
// Missing or duplicate handlers, behaviors that skip void requests:
// the app refuses to start and lists every one.
builder.Services.ValidateSwitchboard();
var app = builder.Build();
Most of the alternatives that appeared after MediatR went commercial compete on throughput or feature count. Switchboard optimises for the thing that actually made the migration painful: how much new surface you have to trust, and how much of your code has to change.
The API surface is deliberately identical. ISender, IPublisher,
IMediator, IRequest<T>, IRequestHandler<,>,
INotification, IPipelineBehavior<,> and Unit
all keep their names and shapes.
Only Microsoft.Extensions.DependencyInjection.Abstractions, referenced at the
lowest patch of each major — so adding Switchboard never drags the rest of your
Microsoft.Extensions.* graph forward a version.
Plain reflection over the DI container, the way MediatR does it. No source generators, no analyzers, no MSBuild steps that change what ships. What you read in the repo is what runs in production.
Switchboard came out of the platform behind ΠΑΦΑΜΕ — a production system that made this exact switch — then got cleaned up and published under Apache 2.0. It wasn't written for a blog post.
The package ID is prefixed, but the assembly and namespace are both plain
Switchboard — you write using Switchboard;.
A request carries its response type. Its handler is found by assembly scanning and resolved from the same scope the mediator was resolved from, so scoped dependencies behave exactly as you'd expect.
Void requests implement IRequest with no type argument and are handled by
IRequestHandler<TRequest>. Under the hood they run through the same
pipeline with TResponse == Unit, so open-generic behaviors apply to them
unchanged.
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)
=> /* ... */;
}
AddSwitchboard takes the same configuration methods MediatR does —
RegisterServicesFromAssemblyContaining,
RegisterServicesFromAssembly and AddOpenBehavior — so the
registration block usually survives the migration with one word changed.
Inject ISender (or IPublisher, or IMediator)
anywhere in your app and dispatch.
using Switchboard;
// One registration call — assembly scanning included.
builder.Services.AddSwitchboard(cfg => cfg
.RegisterServicesFromAssemblyContaining<GetOrderHandler>()
.AddOpenBehavior(typeof(LoggingBehaviour<,>)));
public sealed class OrderController(ISender sender)
: ControllerBase
{
[HttpGet("{id}")]
public Task<OrderDto> Get(int id, CancellationToken ct)
=> sender.Send(new GetOrder(id), ct);
}
Behaviors wrap every handler, outermost first in the order they are added. Open generics
go in with AddOpenBehavior; a behavior bound to one specific request/response
pair goes in with AddBehavior.
Both kinds share a single ordering, so the first one added is outermost regardless of which
kind it is. Registering straight against the container still works too:
services.AddTransient<IPipelineBehavior<GetOrder, OrderDto>, MyBehavior>().
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;
}
}
// Ordering is shared between open and closed behaviors:
cfg.AddOpenBehavior(typeof(LoggingBehaviour<,>)) // outermost
.AddBehavior<AuditGetOrder>()
.AddOpenBehavior(typeof(ValidationBehaviour<,>)); // innermost
Publish a notification and every registered handler runs — sequentially, in registration
order, never in parallel. That's a deliberate choice: it means handlers can safely share
scoped state such as an EF Core DbContext.
Publishing to zero handlers is a no-op, mirroring MediatR.
public sealed record OrderPlaced(int OrderId) : INotification;
public sealed class SendReceipt
: INotificationHandler<OrderPlaced> { /* ... */ }
public sealed class UpdateStats
: INotificationHandler<OrderPlaced> { /* ... */ }
// Runs SendReceipt, then UpdateStats — one after the other.
await publisher.Publish(new OrderPlaced(42), cancellationToken);
The first three steps cover almost every codebase; the fourth is optional and worth it. The table is the honest part: if you depend on one of the ❌ rows, Switchboard is not your library, and the README says so too.
Replace the MediatR package reference with SoftwareFirst.Switchboard.
Replace using MediatR; with using Switchboard;.
Replace services.AddMediatR(...) with services.AddSwitchboard(...) —
the configuration methods keep their names.
Optionally, add services.ValidateSwitchboard() just before building the host — it
tends to find something on the first run.
| MediatR feature | Switchboard | Notes |
|---|---|---|
IRequest, IRequest<T>, IRequestHandler<,>, IRequestHandler<> |
✅ identical | Same names, same shapes |
INotification, INotificationHandler<> |
✅ identical | Sequential publish, registration order |
IPipelineBehavior<,> |
✅ identical | First registered runs outermost |
ISender, IPublisher, IMediator, Unit |
✅ identical | Inject whichever you already inject |
Untyped Send(object) / Publish(object) |
✅ identical | For dynamic dispatch call sites |
| Assembly scanning for handlers | ✅ identical | RegisterServicesFromAssembly… |
| Startup validation of handlers and behaviors | ➕ Switchboard only | ValidateSwitchboard() |
| OpenTelemetry traces and metrics | ➕ Switchboard only | SwitchboardTelemetry, no extra package |
| Scope per dispatch (Blazor Server) | ➕ Switchboard only | UseScopePerDispatch() |
Streaming (IStreamRequest<>) |
❌ not implemented | Use a source-generated alternative |
| Request pre-/post-processors | ❌ not implemented | Use a pipeline behavior instead |
Exception handlers/actions (IRequestExceptionHandler) |
❌ not implemented | Use a pipeline behavior instead |
| Custom publish strategies (parallel, etc.) | ❌ not implemented | Sequential only, on purpose |
A mediator is mostly invisible until something surprises you at 2am. These are the choices Switchboard makes, stated up front.
The CancellationToken passed to Send flows to every behavior and
to the handler — even when a behavior calls next() with no arguments.
They're resolved from the scope the mediator was resolved from — or from a fresh per-dispatch scope, if you turn that on — so your scoped dependencies work exactly as they do today.
No exception, no warning — the same thing MediatR does, so a partially migrated codebase doesn't start throwing.
Handler-type wrappers are cached statically per request type. The cache is stateless and thread-safe, so the reflection cost is paid once.
IRequest<TResponse> constraints
A void request implements IRequest, not IRequest<Unit> — in
MediatR 12+ too — so a behavior constrained to IRequest<TResponse> silently
never runs for it. Constrain to IBaseRequest; ValidateSwitchboard()
finds the ones that don't.
A handler or behavior that is already registered isn't added again, so modules that scan a shared assembly never make a handler run twice.
For the request/response, notification and pipeline-behavior surface — yes. Change the
package reference, replace using MediatR; with using Switchboard;
and AddMediatR with AddSwitchboard. Handlers, behaviors and call
sites compile unchanged. Streaming, pre-/post-processors, exception handlers and custom
publish strategies are not implemented — see the table above.
Nothing. It's Apache 2.0 — free for commercial use, no per-seat or per-organisation fee, no licence key. That's the whole reason it exists.
net8.0, net9.0 and net10.0. You can move off MediatR
without moving frameworks first. The single dependency is floored at the lowest patch of
each major, so it won't pull your other Microsoft.Extensions.* packages forward.
No — sequentially, in registration order, always. That keeps scoped state such as an EF Core
DbContext safe to share between handlers. If you need a parallel publish
strategy, Switchboard isn't the right fit.
None. It's plain reflection over the DI container, the way MediatR does it. If you need maximum throughput or streaming, a source-generated alternative is genuinely the better choice — we'd rather say that than sell you the wrong library.
Bump the package to 1.2.1 — no API was removed or changed. Then add
services.ValidateSwitchboard() just before building the host and fix what it reports.
The most common finding is a behavior constrained to IRequest<TResponse> that has
silently been skipping void commands. Telemetry and scope per dispatch are opt-in; the
upgrade guide
walks through each.
Yes, since 1.2. It emits a span per Send, per Publish and per notification
handler, plus switchboard.request.duration and switchboard.notification.duration
histograms, through ActivitySource and Meter — no extra package. Subscribe with
AddSource(SwitchboardTelemetry.ActivitySourceName) and
AddMeter(SwitchboardTelemetry.MeterName); nothing is recorded until you do.
Yes. In Blazor Server the DI scope is the whole circuit, so one DbContext serves every
click. cfg.UseScopePerDispatch() runs each top-level Send and
Publish in its own scope; nested commands sent through an injected ISender
share it, and a callback carries the current user into the new scope.
The package ID is SoftwareFirst.Switchboard because NuGet IDs are global; the
assembly and namespace are both plain Switchboard, so your code reads
using Switchboard; and nothing else.
Software First, a software studio in Athens. We run it in our own production systems — that's the maintenance model, not a promise on a page.
Install it, run your test suite, and see how much actually breaks. If you'd rather we looked at your architecture properly, that's what the rest of this site is about.