net8.0 · net9.0 · net10.0 One dependency No source generators New in 1.2: startup validation · OpenTelemetry Apache 2.0 — free, including commercially

A drop-in MediatR alternative
you can read in an afternoon.

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
1 dependency
DependencyInjection.Abstractions — that's it
<500 lines
of code, across 18 files you can read end to end
3 targets
net8.0, net9.0 and net10.0
v1.2.1
on NuGet as SoftwareFirst.Switchboard

Same API. Now it also catches the mistakes that reach production.

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.

🛡️

Startup validation

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.

  • Fails the deploy, not a user
  • Safe for handlers that need a request or a circuit
  • Doubles as a one-line unit test
📡

OpenTelemetry, built in

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.

  • One AddSource line to switch on
  • Zero cost while nobody listens
  • Tags carry type names, never request contents
🧵

A scope per dispatch for Blazor Server

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.

  • No more “a second operation was started on this context”
  • Nested commands share one unit of work
  • Scopes you create yourself stay isolated
🔂

Registered twice, runs once

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.

  • Handlers and behaviors deduplicated
  • Pipeline order preserved
  • Nothing to change in your code

Upgrading from 1.1

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.

Program.cs
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();

Several MediatR alternatives exist. This one competes on being small.

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.

🔁

A find-and-replace, not a rewrite

The API surface is deliberately identical. ISender, IPublisher, IMediator, IRequest<T>, IRequestHandler<,>, INotification, IPipelineBehavior<,> and Unit all keep their names and shapes.

  • Handlers compile unchanged
  • Behaviors compile unchanged
  • Call sites compile unchanged
📦

One dependency, floored on purpose

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.

  • No transitive surprises
  • Nothing to audit you can't read
  • Symbols and source link published
🔍

No build-time magic

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.

  • Debuggable stack traces
  • Works with trimming caveats you already know
  • Nothing generated behind your back
🏗️

Extracted, not invented

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.

  • Apache 2.0, free commercially
  • CI on every push
  • Multi-targeted so you don't have to upgrade first
Apache-2.0 net8.0 net9.0 net10.0 CQRS Pipeline behaviors Notifications

Define a request, register once, send.

The package ID is prefixed, but the assembly and namespace are both plain Switchboard — you write using Switchboard;.

Requests and handlers

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.

GetOrder.cs
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)
        => /* ... */;
}

One registration call

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.

Program.cs
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);
}

Pipeline behaviors

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>().

LoggingBehaviour.cs
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

Notifications

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.

OrderPlaced.cs
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);

Three steps, then read the table.

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.

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 keep their names.

4

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

The behaviours people actually get bitten by.

A mediator is mostly invisible until something surprises you at 2am. These are the choices Switchboard makes, stated up front.

Cancellation is never lost

The CancellationToken passed to Send flows to every behavior and to the handler — even when a behavior calls next() with no arguments.

Handlers and behaviors are transient

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.

Publishing to zero handlers is a no-op

No exception, no warning — the same thing MediatR does, so a partially migrated codebase doesn't start throwing.

Wrapper types are cached

Handler-type wrappers are cached statically per request type. The cache is stateless and thread-safe, so the reflection cost is paid once.

Void requests skip 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.

Registering twice is harmless

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.

Questions we got asked before publishing it.

Is Switchboard really a drop-in replacement for MediatR?

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.

What does it cost?

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.

Which .NET versions are supported?

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.

Do notification handlers run in parallel?

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.

Are there source generators or analyzers?

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.

How do I upgrade from 1.1 to 1.2?

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.

Does it work with OpenTelemetry?

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.

Can it fix “A second operation was started on this context” in Blazor Server?

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.

Why is the package ID prefixed but the namespace isn't?

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.

Who maintains it?

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.

Still on MediatR and reading the invoice?

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.