From 6534087522586eb64da4008da45a51bb1db3978c Mon Sep 17 00:00:00 2001 From: Nikolaos Fragos Date: Sun, 13 Sep 2026 17:53:04 +0300 Subject: [PATCH 1/2] docs: Add changelog and an Upgrading to 1.2 guide to the README --- CHANGELOG.md | 50 +++++++++++++++++++++++++++++++++ README.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9848418 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# 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.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`. + 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()` / `AddBehavior(Type)` for closed pipeline behaviors, sharing one ordering with `AddOpenBehavior`. + +## [1.0.0] — 2026-07-26 + +- Initial release: `IRequest`/`IRequest`, `INotification`, `IPipelineBehavior<,>`, `ISender`/`IPublisher`/`IMediator`, untyped `Send(object)`/`Publish(object)`, and assembly scanning, all MediatR-compatible. + +[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 diff --git a/README.md b/README.md index 105d041..3d03f83 100644 --- a/README.md +++ b/README.md @@ -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 @@ -262,6 +264,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 never runs for N void request(s)` | Change the constraint from `where TRequest : IRequest` 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(); + +// After +services.AddSwitchboard(cfg => cfg.UseScopePerDispatch(async (scope, cancellationToken) => +{ + var auth = await scope.Parent.GetRequiredService().GetAuthenticationStateAsync(); + scope.ServiceProvider.GetRequiredService().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`. From 56a39cbd231bf2a1169917264b614c2f25bcb6fb Mon Sep 17 00:00:00 2001 From: Nikolaos Fragos Date: Sun, 13 Sep 2026 18:06:12 +0300 Subject: [PATCH 2/2] Fix scope-per-dispatch folding deliberately isolated scopes into the outer dispatch; v1.2.1 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 created itself to get its own DbContext. Only a mediator resolved from the in-flight scope (the ISender/IPublisher injected into a handler) now joins it; any other mediator starts its own dispatch scope. --- CHANGELOG.md | 7 ++++ README.md | 3 +- src/Switchboard/Mediator.cs | 13 ++++--- src/Switchboard/Switchboard.csproj | 4 +-- src/Switchboard/SwitchboardConfiguration.cs | 6 ++-- .../ScopePerDispatchTests.cs | 34 +++++++++++++++++++ 6 files changed, 58 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9848418..4505f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to Switchboard. The format follows [Keep a Changelog](https: 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. @@ -44,6 +50,7 @@ No breaking API changes: every 1.1 call site compiles and behaves the same, apar - Initial release: `IRequest`/`IRequest`, `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 diff --git a/README.md b/README.md index 3d03f83..e69dddf 100644 --- a/README.md +++ b/README.md @@ -245,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: diff --git a/src/Switchboard/Mediator.cs b/src/Switchboard/Mediator.cs index 1e33dae..ad9d332 100644 --- a/src/Switchboard/Mediator.cs +++ b/src/Switchboard/Mediator.cs @@ -22,7 +22,8 @@ public sealed class Mediator : IMediator private static readonly ConcurrentDictionary 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 ActiveDispatchScope = new(); /// Creates a mediator that resolves handlers and behaviors from . @@ -129,7 +130,8 @@ private Task PublishInternal(object notification, CancellationToken cancellation /// /// 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. /// private Task Dispatch( TWrapper wrapper, @@ -142,9 +144,12 @@ private Task Dispatch( 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); diff --git a/src/Switchboard/Switchboard.csproj b/src/Switchboard/Switchboard.csproj index 0ae3037..e8e3722 100644 --- a/src/Switchboard/Switchboard.csproj +++ b/src/Switchboard/Switchboard.csproj @@ -9,8 +9,8 @@ SoftwareFirst.Switchboard - 1.2.0 - 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. + 1.2.1 + 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. Software First Software First Copyright © Software First diff --git a/src/Switchboard/SwitchboardConfiguration.cs b/src/Switchboard/SwitchboardConfiguration.cs index 274b0e8..a893597 100644 --- a/src/Switchboard/SwitchboardConfiguration.cs +++ b/src/Switchboard/SwitchboardConfiguration.cs @@ -97,8 +97,10 @@ public SwitchboardConfiguration AddBehavior(Type behaviorType) /// DbContext fails with "a second operation was started on this context". /// /// - /// 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 or + /// 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. /// public SwitchboardConfiguration UseScopePerDispatch() { diff --git a/tests/Switchboard.Tests/ScopePerDispatchTests.cs b/tests/Switchboard.Tests/ScopePerDispatchTests.cs index 5fcbb07..69fdad0 100644 --- a/tests/Switchboard.Tests/ScopePerDispatchTests.cs +++ b/tests/Switchboard.Tests/ScopePerDispatchTests.cs @@ -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)>; + + /// Opens a scope of its own on purpose — the way code isolates a DbContext — and sends through it. + public sealed class IsolatedScopeProbeHandler : IRequestHandler + { + 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().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().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() {