Skip to content

Add MediatR-compatible streaming requests - #4

Merged
nifragos merged 1 commit into
mainfrom
claude/adoring-brown-01p0oh
Oct 17, 2026
Merged

nifragos merged 1 commit into
mainfrom
claude/adoring-brown-01p0oh

Conversation

@nifragos

Copy link
Copy Markdown
Contributor

Closes the most visible gap in the migration table: Streaming (IStreamRequest<>) ❌ → ✅ identical.

API (namespace Switchboard, shapes checked by reflection against MediatR 12.5 and 14.2)

Type / member Shape
IStreamRequest<out TResponse> marker. Like MediatR, it does not extend IBaseRequest
IStreamRequestHandler<in TRequest, out TResponse> IAsyncEnumerable<TResponse> Handle(TRequest, CancellationToken), where TRequest : IStreamRequest<TResponse>
IStreamPipelineBehavior<in TRequest, TResponse> IAsyncEnumerable<TResponse> Handle(TRequest, StreamHandlerDelegate<TResponse> next, CancellationToken)
StreamHandlerDelegate<out TResponse> IAsyncEnumerable<TResponse>(). No token parameter, as in MediatR (unlike RequestHandlerDelegate), so () => ... lambdas in migrated behavior tests still compile
ISender.CreateStream<TResponse>(IStreamRequest<TResponse>, CancellationToken = default) IAsyncEnumerable<TResponse>
ISender.CreateStream(object, CancellationToken = default) IAsyncEnumerable<object?>
SwitchboardConfiguration.AddOpenStreamBehavior(Type), AddStreamBehavior<TBehavior>(), AddStreamBehavior(Type) same pattern as AddOpenBehavior / AddBehavior

Assembly scanning in AddSwitchboard now picks up IStreamRequestHandler<,>.

Semantics

  • Lazy. CreateStream checks its argument and returns. Behaviors and the handler are resolved by the first MoveNextAsync. Each enumeration dispatches the request again. A null request, or an untyped call with a non-stream object, throws at the call (ArgumentNullException / ArgumentException), as MediatR does.
  • The handler is resolved inside the pipeline (1.3 semantics). It is resolved when the innermost behavior enumerates next(). A behavior that answers the stream itself never constructs the handler. A missing handler fails on the first MoveNextAsync, where behaviors can see it.
  • Ordering: the first stream behavior added runs outermost, with open and closed behaviors in one ordering. Registrations are deduplicated across AddSwitchboard calls (TryAddEnumerable). Everything is transient.
  • Cancellation: the CreateStream token and the consumer's WithCancellation token are combined ([EnumeratorCancellation] on the outermost iterator), and the result is passed to every behavior and to the handler. The handler's stream is also always enumerated with that token. So a handler that returns a query (EF Core AsAsyncEnumerable()), which only sees the enumerator token, is still cancelled behind a behavior that runs await foreach (var x in next()) with no token. A token a behavior passes via next().WithCancellation(t) is combined in as well.
  • Scope per dispatch: each enumeration gets one scope, created on the first MoveNextAsync and disposed when the stream completes, throws, or is disposed early (break). A stream that is never enumerated never creates a scope. Subtle point: an async iterator resumes on the consumer's ExecutionContext after every yield. So the AsyncLocal dispatch scope is set again before each step; without that, a handler's injected ISender would open a fresh scope after its first yield instead of joining. A test covers this, and it fails if the re-set is removed.
  • Telemetry:
    • A CreateStream {Request} span tagged switchboard.request and switchboard.stream.items (number of items yielded).
    • Errors are recorded as for Send: status Error, error.type, an exception event, and cancellations are not marked as errors.
    • A new switchboard.stream.duration histogram. It is separate from switchboard.request.duration so long-lived streams don't skew request latency.
    • The span is made Activity.Current again before every step, for the same ExecutionContext reason. Work the handler does between items is therefore traced as its child, while the consumer's code between items is not. Two tests cover this.
    • Like the existing paths, it costs nothing extra when nobody is listening.
  • Validation: ValidateSwitchboard() reports stream requests with no handler (OrphanStream has no handler. Add a class implementing IStreamRequestHandler<OrphanStream, Int32>.) and stream requests with two different handlers, under the existing options.

Deliberate differences from MediatR

  1. Covariant CreateStream works (a fix). Take IStreamRequest<object> r = new GetOrderLines(), where the request declares IStreamRequest<OrderLine>. Here, CreateStream(r) runs the OrderLine handler. MediatR 12.5 looks for IStreamRequestHandler<GetOrderLines, object> and throws. It also caches that wrapper, so every later correct CreateStream(new GetOrderLines()) throws InvalidCastException. I checked this against the real MediatR 12.5 package. It is the same bug 1.2.2 fixed for Send. No item conversion is needed, because IAsyncEnumerable<out T> is covariant too.
  2. No ServiceLifetime / service+implementation overloads on AddOpenStreamBehavior / AddStreamBehavior. This mirrors Switchboard's existing AddOpenBehavior / AddBehavior, which already omit them. Behaviors are always transient.

(I checked one more candidate difference and dropped it. MediatR also enumerates the handler with the token behind a token-dropping behavior, so that part is parity.)

Things for you to decide

  • ISender gained two members, and so did IMediator. Anyone who implements ISender themselves, such as the ScopingSender decorator the README's 1.2 upgrade guide mentions, gets a compile error until they add both overloads. MediatR has them on ISender, so this is needed for drop-in compatibility. The CHANGELOG calls it out under "Behavior notes". Default interface implementations would avoid the break, but MediatR doesn't use them and they'd make a broken stream silent, so I didn't.
  • "Under 500 lines of code" (README, twice): this adds about 140 lines of non-blank, non-comment, non-brace code to src/. By a strict count the library was already right at ~500 and is now ~650. I left the claim alone because it's your marketing copy, but it probably needs updating.
  • No <Version> change and no "New in 1.4" banner. The CHANGELOG has an [Unreleased] section that says the next release is a minor one (1.4.0).

Tests

There are 39 new tests in four new files. Existing test files are untouched.

  • StreamTests.cs:
    • typed, untyped, via IMediator, covariant (plus no cache poisoning)
    • laziness, re-enumeration
    • behavior ordering, open/closed shared ordering, filter + transform behaviors, closed behavior scoping, short-circuit never constructs the handler
    • cancellation mid-stream via the CreateStream token and via WithCancellation; both tokens reach behaviors and the handler; enumerator-token-only handler behind a token-dropping behavior
    • handler exception after items, missing handler on enumeration, eager argument validation
  • StreamScopePerDispatchTests.cs:
    • default shares the caller's scope
    • scope alive during enumeration and disposed after it; disposed on early break and on a throw
    • no scope when never enumerated, a scope per enumeration
    • nested Send and CreateStream after a yield join the scope
    • the callback sees the request
  • StreamRegistrationTests.cs:
    • scanning, order, separation from pipeline behaviors, dedup across two AddSwitchboard calls, argument validation
    • validation: missing, duplicate, passing
  • StreamTelemetryTests.cs (in the telemetry collection):
    • span name, tags, item count; span current for the handler at every step and never for the consumer
    • abandoned stream still ends its span; failure → Error; duration histogram with error.type

I mutation-checked the three hard-to-see guarantees (the scope re-set, the Activity.Current re-set, and the handler enumerated with the token): removing each one makes its test fail.

dotnet build -c Release (0 warnings), dotnet test -c Release (131/131 on net8.0, net9.0 and net10.0, 5 consecutive runs), and dotnet pack with package validation all pass locally.


Generated by Claude Code

IStreamRequest<T>, IStreamRequestHandler<,>, IStreamPipelineBehavior<,>,
StreamHandlerDelegate<T> and ISender.CreateStream (typed and untyped),
with the same shapes as MediatR so migrating code compiles unchanged.

- Assembly scanning picks up stream handlers; AddOpenStreamBehavior,
  AddStreamBehavior<T>() and AddStreamBehavior(Type) register stream
  behaviors, first added outermost, deduplicated like pipeline behaviors.
- Lazy: nothing is resolved until enumeration, and the handler is
  resolved inside the pipeline (1.3 semantics).
- The CreateStream and WithCancellation tokens are combined and reach
  every behavior and the handler; the handler's stream is always
  enumerated with them, even behind a behavior that drops the token.
- Scope per dispatch: one scope per enumeration, disposed when the
  stream ends, throws or is abandoned; re-established before every
  step so the handler's own sends join it after a yield.
- Telemetry: a CreateStream span, current for the handler at every
  step but not for the consumer, and switchboard.stream.duration.
- ValidateSwitchboard covers missing and duplicate stream handlers.
- A covariant CreateStream runs the declared handler instead of
  failing and poisoning the wrapper cache (a MediatR bug).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qh4oHys2D2TLycvt3yKVtX
@nifragos
nifragos merged commit 12c3aaa into main Oct 17, 2026
2 checks passed
@nifragos
nifragos deleted the claude/adoring-brown-01p0oh branch October 17, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants