Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Changelog

All notable changes to Switchboard. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/).

For step-by-step upgrade instructions, see [Upgrading to 1.2](README.md#upgrading-to-12) in the README.

## [1.2.1] — 2026-09-13

### Fixed

- **Scope per dispatch no longer merges scopes you isolate on purpose.** In 1.2.0, while a dispatch was in flight, *any* nested `Send`/`Publish` reused its scope — including one made through a mediator resolved from a scope the handler had created itself to get its own `DbContext`. That silently put the isolated work back on the outer `DbContext`. Now only a mediator resolved from the in-flight scope (the `ISender`/`IPublisher` injected into a handler) joins it; any other mediator starts its own dispatch scope. Only affects apps that call `UseScopePerDispatch`.

## [1.2.0] — 2026-09-13

No breaking API changes: every 1.1 call site compiles and behaves the same, apart from the fix and the small behavior notes below.

### Added

- **Startup validation** — `services.ValidateSwitchboard()` reads the registrations (without building the container or constructing handlers) and throws a `SwitchboardValidationException` listing every problem:
- requests in scanned assemblies with no handler,
- requests with more than one handler (only the last one registered would ever run),
- open behaviors that never run for void requests because they are constrained to `IRequest<TResponse>`.
Each check can be switched off through `SwitchboardValidationOptions`.
- **OpenTelemetry tracing and metrics** through `ActivitySource` and `Meter` from the base class library, with no new dependency. The spans are `Send {Request}`, `Publish {Notification}` and `Handle {Notification}` (one per handler). The histograms are `switchboard.request.duration` and `switchboard.notification.duration`. Nothing is recorded until you subscribe to `SwitchboardTelemetry.ActivitySourceName` / `SwitchboardTelemetry.MeterName`.
- **Scope per dispatch** — `cfg.UseScopePerDispatch()` runs every top-level `Send` and `Publish` in its own DI scope, reusing it for nested dispatches. It has overloads that take a callback (`DispatchScope` exposes `Parent`, `ServiceProvider` and `Message`) for carrying ambient state such as the current user into the new scope. Built for Blazor Server, where the caller's scope is the whole circuit.
- Tests and documentation for **constrained open behaviors** (`where TRequest : ISomeMarker`).

### Fixed

- Calling `AddSwitchboard` more than once with the same assembly or behavior no longer registers handlers and behaviors twice. Before, a notification handler or behavior registered twice ran twice.

### Behavior notes

- **Duplicates are skipped, not appended.** If the same open behavior is registered directly (`services.AddTransient(typeof(IPipelineBehavior<,>), typeof(X<,>))`) *and* through `AddOpenBehavior(typeof(X<,>))`, it now runs once, at the position of the first registration. Previously it ran twice.
- **Exceptions surface through the returned `Task`** when tracing/metrics are being listened to or scope-per-dispatch is on. Before, an exception such as a missing handler could be thrown synchronously from `Send`. Code that `await`s the call is unaffected.

## [1.1.0] — 2026-08-09

### Added

- `net8.0` and `net9.0` targets alongside `net10.0`, each floored at the lowest `Microsoft.Extensions.DependencyInjection.Abstractions` patch of its major. No API changes.

## [1.0.1] — 2026-07-26

### Added

- `AddBehavior<TBehavior>()` / `AddBehavior(Type)` for closed pipeline behaviors, sharing one ordering with `AddOpenBehavior`.

## [1.0.0] — 2026-07-26

- Initial release: `IRequest`/`IRequest<T>`, `INotification`, `IPipelineBehavior<,>`, `ISender`/`IPublisher`/`IMediator`, untyped `Send(object)`/`Publish(object)`, and assembly scanning, all MediatR-compatible.

[1.2.1]: https://github.com/Software-First-Gr/Switchboard/compare/v1.2.0...v1.2.1
[1.2.0]: https://github.com/Software-First-Gr/Switchboard/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/Software-First-Gr/Switchboard/compare/v1.0.1...v1.1.0
[1.0.1]: https://github.com/Software-First-Gr/Switchboard/releases/tag/v1.0.1
[1.0.0]: https://github.com/Software-First-Gr/Switchboard/commits/v1.0.1
81 changes: 80 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

📖 Overview, migration guide and FAQ: **[softwarefirst.gr/switchboard](https://softwarefirst.gr/switchboard)**

> **New in 1.2:** startup validation, built-in OpenTelemetry, scope per dispatch for Blazor Server, and a fix for handlers registered twice. It's a drop-in upgrade from 1.1 — see **[Upgrading to 1.2](#upgrading-to-12)** and the [changelog](CHANGELOG.md).

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
Expand Down Expand Up @@ -243,7 +245,8 @@ services.AddSwitchboard(cfg => cfg
```

- 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.
- A handler that sends or publishes again through its injected `ISender`/`IPublisher` **reuses the scope in flight**, so nested commands (and domain events published from `SaveChanges`) share one `DbContext` and one transaction.
- A scope you create yourself inside a handler stays yours: a mediator resolved from it gets its own dispatch scope, so deliberately isolated work is never folded back into the outer unit of work.
- 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:
Expand All @@ -262,6 +265,82 @@ services.AddSwitchboard(cfg => cfg

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

## Upgrading to 1.2

1.2 is a drop-in upgrade from 1.1: no API was removed or changed, so bumping the package is enough to compile and run. The new features are opt-in. The steps below take about fifteen minutes and are worth doing, because the validation tends to find something real.

### 1. Bump the package

```bash
dotnet add package SoftwareFirst.Switchboard --version 1.2.0
```

With central package management, change the version in `Directory.Packages.props` instead.

### 2. Check the two behavior notes

Most applications are unaffected by either note, but they are the only differences you can observe:

| What changed | Who notices | What to do |
| --- | --- | --- |
| Registering the same handler or behavior twice (two `AddSwitchboard` calls, or `AddOpenBehavior` plus a direct `AddTransient` of the same type) now registers it **once**. | Apps where a notification handler or behavior was running twice without anyone meaning it to. That was a bug, and it's fixed. | Nothing, unless you relied on the double run. If a behavior was registered both ways, it now sits at the position of its **first** registration, so check the pipeline order. |
| Exceptions can surface through the returned `Task` instead of being thrown synchronously from `Send` (when telemetry is listened to or scope-per-dispatch is on). | Only code that calls `Send` without `await` inside a `try`. | `await` the call. |

### 3. Turn on startup validation

Add it after every registration, just before the provider is built:

```csharp
builder.Services.ValidateSwitchboard();
var app = builder.Build();
```

Also add a one-line test over your real registrations, so a problem fails the build before it fails a deploy:

```csharp
[Fact]
public void Switchboard_registrations_are_valid()
=> new ServiceCollection().AddApplication().ValidateSwitchboard();
```

If it throws, each message says what to fix:

| Message | Fix |
| --- | --- |
| `X has no handler` | Add the handler. If the request is handled in another host, turn the check off for this one: `ValidateSwitchboard(o => o.RequireHandlerForEveryRequest = false)`. |
| `X has 2 handlers (A, B)` | Delete the handler that shouldn't exist. Until now only the last one registered was running. |
| `SomeBehaviour<TRequest, TResponse> never runs for N void request(s)` | Change the constraint from `where TRequest : IRequest<TResponse>` to `where TRequest : IBaseRequest`. |

> **Before widening a constraint, read the behavior.** After the change it starts running for your void commands too. For logging, timing, exception handling and validation that is the point. For a transaction or caching behavior, make sure that's what you want. Any metrics the behavior records also gain a series per void command, so dashboards and alerts that filter by request name may start matching commands they never saw before.

### 4. Opt into telemetry (optional)

```csharp
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource(SwitchboardTelemetry.ActivitySourceName))
.WithMetrics(metrics => metrics.AddMeter(SwitchboardTelemetry.MeterName));
```

If you already have a `PerformanceBehaviour` that starts its own span, you'll see Switchboard's `Send X` span as its parent. Either is fine to keep. The `Publish` and `Handle` spans for notification handlers are usually the new information. See [OpenTelemetry](#opentelemetry) for the span and metric names.

### 5. Blazor Server: replace a hand-written scoping sender (optional)

If you wrote an `ISender` decorator that creates a scope per call to avoid sharing a `DbContext` across a circuit, 1.2 does the same thing for `Send` **and** `Publish`, and handles nested dispatches:

```csharp
// Before
services.AddScoped<ISender, ScopingSender>();

// After
services.AddSwitchboard(cfg => cfg.UseScopePerDispatch(async (scope, cancellationToken) =>
{
var auth = await scope.Parent.GetRequiredService<AuthenticationStateProvider>().GetAuthenticationStateAsync();
scope.ServiceProvider.GetRequiredService<CurrentUser>().Set(auth.User);
}));
```

`AddSwitchboard` is safe to call again for this, so you can put it in the host or infrastructure layer without touching your application layer's registration. Port the decorator's tests too, especially for identity: the callback is where the user carries over. See [Scope per dispatch](#scope-per-dispatch-blazor-server).

## Migrating from MediatR

1. Replace the `MediatR` package reference with `SoftwareFirst.Switchboard`.
Expand Down
13 changes: 9 additions & 4 deletions src/Switchboard/Mediator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ public sealed class Mediator : IMediator
private static readonly ConcurrentDictionary<Type, NotificationHandlerWrapper> NotificationWrappers = new();

// The scope of the dispatch in flight on this async path, when scope-per-dispatch is on. A handler
// that sends or publishes again reuses it, so the inner work shares the outer unit of work.
// that sends or publishes again through a mediator resolved in that scope reuses it, so the inner
// work shares the outer unit of work.
private static readonly AsyncLocal<IServiceProvider?> ActiveDispatchScope = new();

/// <summary>Creates a mediator that resolves handlers and behaviors from <paramref name="provider"/>.</summary>
Expand Down Expand Up @@ -129,7 +130,8 @@ private Task PublishInternal(object notification, CancellationToken cancellation

/// <summary>
/// Picks the provider the message is handled from: the caller's scope by default, or — with
/// scope-per-dispatch on — the dispatch already in flight, else a fresh scope for this message.
/// scope-per-dispatch on — the dispatch in flight when this mediator was resolved from it, else a
/// fresh scope for this message.
/// </summary>
private Task<TResult> Dispatch<TWrapper, TResult>(
TWrapper wrapper,
Expand All @@ -142,9 +144,12 @@ private Task<TResult> Dispatch<TWrapper, TResult>(
return invoke(wrapper, message, _provider, cancellationToken);
}

if (ActiveDispatchScope.Value is { } activeScope)
// Only a mediator resolved from the scope in flight — the ISender or IPublisher injected into a
// handler — joins it. One resolved from a scope the caller created on purpose, to keep its own
// DbContext, must not be folded back into the outer unit of work.
if (ReferenceEquals(ActiveDispatchScope.Value, _provider))
{
return invoke(wrapper, message, activeScope, cancellationToken);
return invoke(wrapper, message, _provider, cancellationToken);
}

return DispatchInNewScope(_scopePerDispatch, wrapper, message, cancellationToken, invoke);
Expand Down
4 changes: 2 additions & 2 deletions src/Switchboard/Switchboard.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@

<PropertyGroup Label="Package">
<PackageId>SoftwareFirst.Switchboard</PackageId>
<Version>1.2.0</Version>
<PackageReleaseNotes>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.</PackageReleaseNotes>
<Version>1.2.1</Version>
<PackageReleaseNotes>Fix: with UseScopePerDispatch, a mediator resolved from a scope you created yourself inside a handler now gets its own dispatch scope instead of silently reusing the outer one. Nested dispatches through an injected ISender/IPublisher still share the outer scope. See CHANGELOG.md.</PackageReleaseNotes>
<Authors>Software First</Authors>
<Company>Software First</Company>
<Copyright>Copyright © Software First</Copyright>
Expand Down
6 changes: 4 additions & 2 deletions src/Switchboard/SwitchboardConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,10 @@ public SwitchboardConfiguration AddBehavior(Type behaviorType)
/// <c>DbContext</c> fails with "a second operation was started on this context".
/// </summary>
/// <remarks>
/// A handler that sends or publishes again reuses the scope already in flight, so nested work
/// shares one unit of work. Work that outlives the dispatch (fire-and-forget) must not reuse it.
/// A handler that sends or publishes again through its injected <see cref="ISender"/> or
/// <see cref="IPublisher"/> reuses the scope already in flight, so nested work shares one unit of work.
/// A mediator resolved from a scope you create yourself gets its own dispatch scope instead.
/// Work that outlives the dispatch (fire-and-forget) must not reuse it.
/// </remarks>
public SwitchboardConfiguration UseScopePerDispatch()
{
Expand Down
34 changes: 34 additions & 0 deletions tests/Switchboard.Tests/ScopePerDispatchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,40 @@ public async Task A_nested_send_reuses_the_scope_of_the_dispatch_in_flight()
Assert.Same(outer, inner);
}

public sealed record IsolatedScopeProbe : IRequest<(ScopeMarker Outer, ScopeMarker Inner)>;

/// <summary>Opens a scope of its own on purpose — the way code isolates a DbContext — and sends through it.</summary>
public sealed class IsolatedScopeProbeHandler : IRequestHandler<IsolatedScopeProbe, (ScopeMarker Outer, ScopeMarker Inner)>
{
private readonly ScopeMarker _marker;
private readonly IServiceScopeFactory _scopes;

public IsolatedScopeProbeHandler(ScopeMarker marker, IServiceScopeFactory scopes)
{
_marker = marker;
_scopes = scopes;
}

public async Task<(ScopeMarker Outer, ScopeMarker Inner)> Handle(IsolatedScopeProbe request, CancellationToken cancellationToken)
{
await using var ownScope = _scopes.CreateAsyncScope();
var inner = await ownScope.ServiceProvider.GetRequiredService<ISender>().Send(new ScopeProbe(), cancellationToken);
return (_marker, inner);
}
}

[Fact]
public async Task A_send_through_a_scope_the_handler_created_itself_is_not_folded_into_the_outer_dispatch()
{
await using var provider = BuildProvider(cfg => cfg.UseScopePerDispatch());
await using var callerScope = provider.CreateAsyncScope();

var (outer, inner) = await callerScope.ServiceProvider.GetRequiredService<ISender>().Send(new IsolatedScopeProbe());

Assert.NotSame(outer, inner); // 1.2.0 handed back the outer dispatch's own marker here
Assert.True(inner.Disposed, "the inner dispatch got, and disposed, a scope of its own");
}

[Fact]
public async Task Publish_also_runs_in_its_own_scope()
{
Expand Down
Loading