SoftwareFirst.Switchboard 1.2.0

There is a newer version of this package available.
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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SoftwareFirst.Switchboard" Version="1.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SoftwareFirst.Switchboard" Version="1.2.0" />
                    
Directory.Packages.props
<PackageReference Include="SoftwareFirst.Switchboard" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SoftwareFirst.Switchboard --version 1.2.0
                    
#r "nuget: SoftwareFirst.Switchboard, 1.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SoftwareFirst.Switchboard@1.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SoftwareFirst.Switchboard&version=1.2.0
                    
Install as a Cake Addin
#tool nuget:?package=SoftwareFirst.Switchboard&version=1.2.0
                    
Install as a Cake Tool

Switchboard

NuGet CI License

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 other Microsoft.Extensions.* packages forward. Telemetry uses ActivitySource and Meter from 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 Send and Publish runs in its own DI scope, disposed when it completes — one DbContext, 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 one DbContext and 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.Run fire-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

  1. Replace the MediatR package reference with SoftwareFirst.Switchboard.
  2. Replace using MediatR; with using Switchboard;.
  3. Replace services.AddMediatR(...) with services.AddSwitchboard(...) — the configuration methods (RegisterServicesFromAssemblyContaining, RegisterServicesFromAssembly, AddOpenBehavior) keep their names.
  4. Optionally add services.ValidateSwitchboard() before Build() — 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 CancellationToken passed to Send flows to every behavior and the handler, even when a behavior calls next() 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.
  • AddSwitchboard is 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

Apache 2.0

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.3.0 55 9/30/2026
1.2.2 339 9/13/2026
1.2.1 95 9/13/2026
1.2.0 90 9/13/2026
1.1.0 198 8/9/2026
1.0.1 253 7/26/2026
1.0.0 120 7/26/2026

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.