SoftwareFirst.Switchboard
1.2.0
See the version list below for details.
dotnet add package SoftwareFirst.Switchboard --version 1.2.0
NuGet\Install-Package SoftwareFirst.Switchboard -Version 1.2.0
<PackageReference Include="SoftwareFirst.Switchboard" Version="1.2.0" />
<PackageVersion Include="SoftwareFirst.Switchboard" Version="1.2.0" />
<PackageReference Include="SoftwareFirst.Switchboard" />
paket add SoftwareFirst.Switchboard --version 1.2.0
#r "nuget: SoftwareFirst.Switchboard, 1.2.0"
#:package SoftwareFirst.Switchboard@1.2.0
#addin nuget:?package=SoftwareFirst.Switchboard&version=1.2.0
#tool nuget:?package=SoftwareFirst.Switchboard&version=1.2.0
Switchboard
A lightweight, MediatR-compatible mediator for .NET — with the production safety nets MediatR never had.
📖 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 under 500 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.
Install
dotnet add package SoftwareFirst.Switchboard
Targets 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;.
Beyond MediatR
Same API, plus four things every one of our production apps ended up needing — each opt-in, none adding a dependency.
| The problem in production | What Switchboard does |
|---|---|
| A request with no handler (or two) is only discovered when a user hits it. | Startup validation — ValidateSwitchboard() lists every missing and duplicate handler before the app serves a request, without constructing a single handler. |
A behavior constrained to where TRequest : IRequest<TResponse> — the shape most templates ship — silently never runs for void commands. Your exception logging and timing just aren't there. |
The same validation names the behavior and every request it skips, and tells you the one-word fix. |
Every app writes its own PerformanceBehaviour to get spans and timings. |
OpenTelemetry built in — a span per Send, per Publish and per notification handler, plus duration histograms. One AddSource line to switch on; zero cost when nobody listens. |
In Blazor Server the DI scope is the whole circuit, so one DbContext serves every click: "A second operation was started on this context". |
Scope per dispatch — each command gets its own scope and unit of work; nested commands share it; the current user is carried across. |
It also fixes a trap common to hand-rolled registration: calling AddSwitchboard from two modules that scan the same assembly no longer registers handlers twice (which made every notification handler run twice).
Why this one
Several MediatR alternatives exist now, and most compete on speed or feature count. Switchboard competes on being small and safe:
- Under 500 lines of code, across 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. Telemetry usesActivitySourceandMeterfrom the base class library. - 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.
Quick start
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:
builder.Services.AddSwitchboard(cfg => cfg
.RegisterServicesFromAssemblyContaining<GetOrderHandler>());
builder.Services.ValidateSwitchboard(); // optional, recommended: fail at startup, not in production
var app = builder.Build();
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>.
Pipeline behaviors
Behaviors wrap every handler, outermost first in the order they are added:
public sealed class LoggingBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IBaseRequest
{
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 logging
A 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<,>))); // innermost
Open 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 — as long as their constraints allow it (see below).
Constrained behaviors
Generic constraints decide which requests a behavior applies to. The container skips the behavior for any request that doesn't satisfy them, so a marker interface is all it takes to target a subset:
public interface IIdempotentCommand { Guid CommandId { get; } }
public sealed class IdempotencyBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IIdempotentCommand // runs only for commands that opt in
{
/* ... */
}
This is covered by tests for both typed and void requests, so you can rely on it.
The void-request trap
This constraint looks harmless and is in most Clean Architecture templates:
public class UnhandledExceptionBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse> // ⚠️ excludes every void request
A void request implements IRequest, not IRequest<Unit> (the same is true in MediatR 12+), so the container silently skips this behavior for every void command — no exception logging, no timing, no transaction, and nothing tells you. Constrain to the marker every request implements instead:
where TRequest : IBaseRequest // ✅ typed and void requests alike
ValidateSwitchboard() detects the trap and names every request it affects. Constraints that exclude requests on purpose, like IIdempotentCommand above, are left alone.
Notifications
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.
Startup validation
Call ValidateSwitchboard() after all registrations, just before building the provider:
builder.Services.AddSwitchboard(cfg => cfg.RegisterServicesFromAssemblyContaining<GetOrderHandler>());
// ... everything else ...
builder.Services.ValidateSwitchboard();
var app = builder.Build();
It reads the service registrations only — it never builds the container or constructs a handler, so it is safe for handlers that need an HTTP request or a Blazor circuit. When something is wrong it throws a SwitchboardValidationException listing every problem at once:
Switchboard registration is invalid:
- CancelOrder has no handler. Add a class implementing IRequestHandler<CancelOrder>.
- GetOrder has 2 handlers (GetOrderHandler, LegacyGetOrderHandler); only the last one registered would ever run.
- UnhandledExceptionBehaviour<TRequest, TResponse> never runs for 26 void request(s) (SubmitFeedback, ArchiveOrder, ...) because it constrains TRequest to IRequest<TResponse>, which void requests do not implement. Constrain it to IBaseRequest to cover every request.
| Check | Covers | Option to turn it off |
|---|---|---|
| Missing handlers | Every concrete request type in the scanned assemblies | RequireHandlerForEveryRequest |
| Duplicate handlers | Every handler registration, scanned or manual | ForbidDuplicateHandlers |
| Behaviors that skip void requests | Every open behavior, via AddOpenBehavior or registered directly |
ForbidBehaviorsThatSkipVoidRequests |
// e.g. a shared application assembly whose handlers live in several hosts
services.ValidateSwitchboard(o => o.RequireHandlerForEveryRequest = false);
It also works well as a one-line unit test over your real registrations.
OpenTelemetry
Switchboard emits traces and metrics through System.Diagnostics — no package to add, and nothing is recorded until a listener subscribes. Switch them on in your OpenTelemetry setup:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource(SwitchboardTelemetry.ActivitySourceName))
.WithMetrics(metrics => metrics.AddMeter(SwitchboardTelemetry.MeterName));
Spans
| Span | When | Tags |
|---|---|---|
Send GetOrder |
every Send, behaviors included |
switchboard.request |
Publish OrderPlaced |
every Publish |
switchboard.notification |
Handle OrderPlaced |
each notification handler, as a child of its Publish |
switchboard.notification, switchboard.handler |
A failure sets the span status to Error, adds error.type and an exception event. Cancellations are tagged with error.type but are not marked as errors — a user navigating away is not a failed handler.
Metrics
| Instrument | Unit | Tags |
|---|---|---|
switchboard.request.duration |
s | switchboard.request, error.type on failure |
switchboard.notification.duration |
s | switchboard.notification, error.type on failure |
Tag values are type names (GetOrder, Envelope<Invoice>), never request contents, so cardinality stays bounded and no personal data leaks into your telemetry.
Scope per dispatch (Blazor Server)
By default a handler is resolved from the scope the mediator was resolved from — exactly like MediatR. In ASP.NET Core that is the HTTP request, which is what you want. In Blazor Server it is the whole circuit, so a single scoped DbContext serves every render and click on the connection. DbContext isn't thread-safe and Blazor interleaves async work, so this fails intermittently with "A second operation was started on this context instance".
Turn on a scope per dispatch:
services.AddSwitchboard(cfg => cfg
.RegisterServicesFromAssemblyContaining<GetOrderHandler>()
.UseScopePerDispatch());
- Every top-level
SendandPublishruns in its own DI scope, disposed when it completes — oneDbContext, one unit of work per operation. - A handler that sends or publishes again reuses the scope in flight, so nested commands (and domain events published from
SaveChanges) share oneDbContextand one transaction. - Concurrent dispatches from the same circuit never share a scope.
Scoped state that lived in the caller's scope — typically the current user — has to be carried into the new one. The callback runs before the handler, with both scopes in hand:
services.AddSwitchboard(cfg => cfg
.RegisterServicesFromAssemblyContaining<GetOrderHandler>()
.UseScopePerDispatch(async (scope, cancellationToken) =>
{
var auth = await scope.Parent.GetRequiredService<AuthenticationStateProvider>().GetAuthenticationStateAsync();
scope.ServiceProvider.GetRequiredService<CurrentUser>().Set(auth.User);
}));
scope.Parent is the caller's scope, scope.ServiceProvider the new one, and scope.Message the request or notification. A synchronous overload, UseScopePerDispatch(scope => ...), is there too.
Work that outlives the dispatch —
Task.Runfire-and-forget started inside a handler — must not send through the mediator it inherited: the scope it would reuse is disposed when the outer dispatch completes. Create a scope of your own for background work.
Migrating from MediatR
- 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. - Optionally add
services.ValidateSwitchboard()beforeBuild()— it tends to find something on the first run.
| 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 |
| Startup validation of handlers and behaviors | ➕ Switchboard only |
| Built-in OpenTelemetry traces and metrics | ➕ Switchboard only |
| Scope per dispatch for Blazor Server | ➕ Switchboard only |
Semantics worth knowing
- 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 (or the per-dispatch scope, when enabled), so scoped dependencies work as expected.
AddSwitchboardis safe to call more than once. A handler or behavior that is already registered is not added again, so modules that scan a shared assembly never make a handler run twice.- 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.
License
| 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 is compatible. 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
-
net9.0
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
Startup validation (ValidateSwitchboard), built-in OpenTelemetry tracing and metrics, and scope-per-dispatch for Blazor Server. Fix: calling AddSwitchboard more than once on the same assembly no longer registers handlers and behaviors twice.