Snowberry.Mediator
2.0.0
dotnet add package Snowberry.Mediator --version 2.0.0
NuGet\Install-Package Snowberry.Mediator -Version 2.0.0
<PackageReference Include="Snowberry.Mediator" Version="2.0.0" />
<PackageVersion Include="Snowberry.Mediator" Version="2.0.0" />
<PackageReference Include="Snowberry.Mediator" />
paket add Snowberry.Mediator --version 2.0.0
#r "nuget: Snowberry.Mediator, 2.0.0"
#:package Snowberry.Mediator@2.0.0
#addin nuget:?package=Snowberry.Mediator&version=2.0.0
#tool nuget:?package=Snowberry.Mediator&version=2.0.0
Lightweight mediator implementation (request/response, streaming requests and notifications) with support for pipeline behaviors and pluggable registration via assembly scanning or explicit type lists.
Use this package to decouple request/response handlers, streaming request handlers and notification handlers from callers, and to add cross-cutting pipeline behaviors.
Usage
Register an IMediator implementation and handlers (either by scanning assemblies or by specifying types) and call into the mediator using the IMediator surface:
- Send a request:
ValueTask<TResponse> SendAsync<TRequest, TResponse>(IRequest<TRequest, TResponse> request, CancellationToken) - Create a stream:
IAsyncEnumerable<TResponse> CreateStreamAsync<TRequest, TResponse>(IStreamRequest<TRequest, TResponse> request, CancellationToken) - Publish a notification:
ValueTask PublishAsync<TNotification>(TNotification notification, CancellationToken)
The mediator resolves handlers from an IServiceProvider (provided at construction) and can execute optional pipeline behavior chains when a global pipeline registry is registered.
Features
- Core contracts and supported types (with generic signatures):
IRequest<TRequest, TResponse>/IRequestHandler<TRequest, TResponse>IStreamRequest<TRequest, TResponse>/IStreamRequestHandler<TRequest, TResponse>INotification/INotificationHandler<TNotification>IPipelineBehavior<TRequest, TResponse>/IPipelineContinuation<TRequest, TResponse>IStreamPipelineBehavior<TRequest, TResponse>/IStreamPipelineContinuation<TRequest, TResponse>
- Assembly scanning helper to discover handlers, pipeline behaviors and notification handlers (
MediatorAssemblyHelper). - Global registries for pipeline and notification handlers used at runtime by
Mediator. - Zero per-call allocation through the mediator's own dispatch code (see Performance).
SendAsync/PublishAsyncreturnValueTask/ValueTask<T>and never allocate a state machine inside the library - any per-call allocation that remains comes from your own handlers if they useasync/awaitand suspend. - AOT-compatible:
Snowberry.Mediator,Snowberry.Mediator.Abstractionsand the DI helpers ship withIsAotCompatible=trueonnet9.0+. Assembly-scanning entry points are gated with[RequiresUnreferencedCode]/[RequiresDynamicCode]; the explicit-registration entry points are AOT-safe. - OpenTelemetry tracing and metrics through an opt-in
IMediatordecorator, gated onActivitySource.HasListeners()so it costs nothing when no listener is attached (see OpenTelemetry). - Two first-party dependency-injection integrations:
Microsoft.Extensions.DependencyInjectionand the siblingSnowberry.DependencyInjectioncontainer.
Examples
Below are minimal examples demonstrating common usage patterns.
Microsoft Dependency Injection
The repository contains an integration extension (Snowberry.Mediator.Extensions.DependencyInjection) which exposes AddSnowberryMediator to register the mediator and handlers into an IServiceCollection.
Example: register by scanning the current assembly and enable pipeline/notification scanning:
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddSnowberryMediator(options =>
{
options.Assemblies = new List<Assembly> { Assembly.GetExecutingAssembly() };
options.ScanNotificationHandlers = true;
options.ScanPipelineBehaviors = true;
});
var provider = services.BuildServiceProvider();
var mediator = provider.GetRequiredService<Snowberry.Mediator.Abstractions.IMediator>();
// Send a request (example)
// await mediator.SendAsync(new MyRequest(...));
Example: explicit registration of pipeline behaviors (ordered) and notification handlers (no scanning):
services.AddSnowberryMediator(opts =>
{
opts.Assemblies = new List<Assembly>();
opts.RegisterPipelineBehaviors = true;
opts.PipelineBehaviorTypes = new List<Type>
{
typeof(MyApp.Pipeline.LoggingBehavior<,>),
typeof(MyApp.Pipeline.ValidationBehavior<,>)
};
opts.RegisterNotificationHandlers = true;
opts.NotificationHandlerTypes = new List<Type>
{
typeof(MyApp.Notifications.SomeNotificationHandler)
};
});
Snowberry.DependencyInjection
Snowberry.Mediator.DependencyInjection integrates with the sibling Snowberry.DependencyInjection container. It exposes AddSnowberryMediator and AppendSnowberryMediator on IServiceRegistry, taking the same MediatorOptions as the Microsoft container. The registry must also be resolvable as an IServiceProvider so the mediator can resolve handlers at dispatch time.
Example: register by scanning the current assembly and enable pipeline/notification scanning:
using System.Reflection;
using Snowberry.DependencyInjection; // ServiceContainer
using Snowberry.DependencyInjection.Abstractions; // ServiceLifetime
using Snowberry.DependencyInjection.Abstractions.Extensions; // GetRequiredService
using Snowberry.Mediator.DependencyInjection; // AddSnowberryMediator
using var registry = new ServiceContainer();
registry.AddSnowberryMediator(options =>
{
options.Assemblies = new List<Assembly> { Assembly.GetExecutingAssembly() };
options.ScanNotificationHandlers = true;
options.ScanPipelineBehaviors = true;
}, ServiceLifetime.Scoped);
var mediator = registry.GetRequiredService<Snowberry.Mediator.Abstractions.IMediator>();
To extend a registry that already has the mediator (for example when loading plugins), call AppendSnowberryMediator. Calling AddSnowberryMediator a second time throws, so a double registration cannot silently orphan handlers into a discarded registry.
The source generator targets this container too: when Snowberry.Mediator.DependencyInjection is referenced, the generated AddSnowberryMediator() (with an append overload) is emitted as an extension on IServiceRegistry, with the same zero-reflection guarantees described below.
Source generator (zero-reflection, NativeAOT-friendly)
The Snowberry.Mediator.SourceGenerator package discovers handlers, behaviors and notification handlers at compile time (across your project and its referenced assemblies) and emits the registration with literal closed generics, eliminating all runtime reflection (Assembly.GetTypes(), Type.GetInterfaces(), MakeGenericType). It is the recommended setup for trimmed / NativeAOT apps.
Reference
Snowberry.Mediator.SourceGeneratoralongsideSnowberry.Mediatorand a DI integration package.Add the opt-in attribute to your composition-root project:
[assembly: Snowberry.Mediator.SnowberryMediator]Call the generated registration with no
options.Assembliesand no hand-listed handler types:using Microsoft.Extensions.DependencyInjection; var services = new ServiceCollection(); services.AddSnowberryMediator(); // default Scoped lifetime // services.AddSnowberryMediator(ServiceLifetime.Singleton); var mediator = services.BuildServiceProvider() .GetRequiredService<Snowberry.Mediator.Abstractions.IMediator>();
Handlers in referenced assemblies are discovered as long as the composition-root project can access the type (public, or internal exposed via [InternalsVisibleTo]). [PipelineOverwritePriority] ordering, open-generic behaviors and open-generic notification handlers are fully supported, with byte-identical dispatch semantics to the reflection path. Only the startup registration changes, so the dispatch numbers below are unaffected. The generated path executes no dynamic code, so it is clean under PublishAot/trimming. See the package README for the diagnostics table and the runnable AOT sample under samples/.
To restrict which referenced assemblies are scanned, set [assembly: SnowberryMediator(ScanReferencedAssemblies = false)] and name each one to include with [assembly: SnowberryMediatorAssembly(typeof(AnyTypeInThatAssembly))] (the current assembly is always scanned). To give a single handler a non-default lifetime, register it before AddSnowberryMediator(); the generated registrations use TryAdd, so a pre-registered handler keeps your lifetime. Both are documented in the package README.
OpenTelemetry
Snowberry.Mediator ships first-class OpenTelemetry tracing and metrics through an IMediator decorator (InstrumentedMediator). It emits an Activity and metric measurements for every SendAsync, CreateStreamAsync and PublishAsync dispatch, with opt-in per-pipeline-behavior and per-notification-handler spans. The decorator is gated on ActivitySource.HasListeners(), so with no listener attached the dispatch path stays allocation-free.
Pick the integration package that matches your container:
| Container | Package |
|---|---|
Microsoft.Extensions.DependencyInjection |
Snowberry.Mediator.Extensions.OpenTelemetry |
Snowberry.DependencyInjection |
Snowberry.Mediator.OpenTelemetry |
Microsoft Dependency Injection
Register the mediator first, then decorate it. Subscribe the OpenTelemetry SDK to the mediator sources with AddSnowberryMediatorInstrumentation() on the tracer and meter builders:
using Microsoft.Extensions.DependencyInjection;
using Snowberry.Mediator.Extensions.DependencyInjection;
using Snowberry.Mediator.Extensions.OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
services.AddSnowberryMediator(/* ... */);
services.AddSnowberryMediatorOpenTelemetry(options =>
{
options.EnablePipelineBehaviorSpans = true;
options.EnableNotificationHandlerSpans = true;
});
services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSnowberryMediatorInstrumentation())
.WithMetrics(metrics => metrics.AddSnowberryMediatorInstrumentation());
AddSnowberryMediatorOpenTelemetry must run after AddSnowberryMediator, because it swaps the existing IMediator registration for the decorator.
Snowberry.DependencyInjection
The order is reversed here: call AddSnowberryMediatorOpenTelemetry before AddSnowberryMediator, because the registry does not permit re-registering IMediator once it exists. The decorator claims the IMediator slot first, and the later mediator registration becomes a no-op.
using Snowberry.DependencyInjection;
using Snowberry.Mediator.DependencyInjection;
using Snowberry.Mediator.OpenTelemetry;
using var registry = new ServiceContainer();
registry.AddSnowberryMediatorOpenTelemetry(options =>
{
options.EnablePipelineBehaviorSpans = true;
options.EnableNotificationHandlerSpans = true;
});
registry.AddSnowberryMediator(/* ... */);
Subscribe the SDK with the same AddSnowberryMediatorInstrumentation() builder extensions shown above; they live in the Snowberry.Mediator.OpenTelemetry namespace, reachable from both packages.
Emitted telemetry
Top-level activities come from the Snowberry.Mediator source. The opt-in per-step spans come from Snowberry.Mediator.Pipeline and Snowberry.Mediator.Notification.
| Activity | Tags |
|---|---|
Mediator.Send {RequestType} |
request type, response type, operation = send |
Mediator.Stream {RequestType} |
request type, response type, operation = stream |
Mediator.Publish {NotificationType} |
notification type, operation = publish |
Mediator.Behavior {BehaviorType} (opt-in) |
behavior type, request type |
Mediator.Handler {HandlerType} (opt-in) |
handler type, notification type |
Six metric instruments are emitted under the Snowberry.Mediator meter: a Counter<long> and a Histogram<double> (ms) for each of send, stream and publish, tagged with type and status (success or failure). Activity names and the *.type tags use the fully-qualified type name, so types that share a simple name across namespaces are never conflated into one span or metric series.
Enrichment and filtering
MediatorTelemetryOptions exposes enrichment callbacks (EnrichWithRequest, EnrichWithResponse, EnrichWithNotification, EnrichWithException) and a Filter that short-circuits instrumentation for a dispatch. Exceptions thrown from enrichment callbacks are recorded as an Activity event named snowberry.mediator.enrichment.failed and do not propagate to the caller. Filter is the exception: it runs before any Activity exists, so a throwing Filter propagates out of the dispatch call. Keep it total (non-throwing).
A runnable, end-to-end example wired into the .NET Aspire dashboard lives under samples/. The full conventions, including every tag key and the per-step opt-in details, are in the package README.
Writing a pipeline behavior
A pipeline behavior implements IPipelineBehavior<TRequest, TResponse>. Its HandleAsync method receives a struct continuation that you invoke to call the next behavior in the chain (or, at the end of the chain, the terminal request handler):
using Snowberry.Mediator.Abstractions.Messages;
using Snowberry.Mediator.Abstractions.Pipeline;
public sealed class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : class, IRequest<TRequest, TResponse>
{
public async ValueTask<TResponse> HandleAsync<TNext>(
TRequest request,
TNext next,
CancellationToken cancellationToken = default)
where TNext : struct, IPipelineContinuation<TRequest, TResponse>
{
// pre-handler logic - runs before the next behavior / terminal handler
var response = await next.InvokeAsync(request, cancellationToken);
// post-handler logic - runs after the next behavior / terminal handler returns
return response;
}
}
The TNext : struct, IPipelineContinuation<,> constraint lets the JIT specialize the method per continuation type and devirtualize next.InvokeAsync(...) to a direct call. Combined with the static-generic walker the library uses internally, this keeps the synchronous dispatch path allocation-free regardless of chain length.
Stream pipeline behaviors (IStreamPipelineBehavior<TRequest, TResponse>) follow the same shape: their HandleAsync<TNext>(request, next, ct) returns IAsyncEnumerable<TResponse> and next is constrained as where TNext : struct, IStreamPipelineContinuation<TRequest, TResponse>. One mental model for both request and stream pipelines.
Pipeline behavior ordering and priority
Pipeline behaviors are executed as a chain. The order matters because each behavior receives a continuation that it invokes to advance to the next behavior - the first behavior in dispatch order wraps every subsequent behavior and the terminal handler.
Ordering rules:
- When you explicitly provide
PipelineBehaviorTypes(orStreamPipelineBehaviorTypes), the order of types in the list is preserved and used as the base ordering. - When pipeline behaviors are discovered via scanning, or when combining scanned and explicit lists, the framework uses the
PipelineOverwritePriorityAttributeapplied to behavior types to determine priority. PipelineOverwritePriorityAttributecontains an integerPriorityproperty. Higher values indicate higher priority - behaviors with a higherPriorityvalue are executed earlier in the chain (and therefore wrap behaviors with lower priorities).
Practical guidance:
- If you need a behavior to run first (for example, logging or diagnostics that should wrap everything), give it a higher
Priority. - If you mix scanned behaviors and an explicit list, set priorities on scanned implementations when their relative position matters, or prefer explicit ordering for predictable placement.
Performance
Snowberry.Mediator is designed for hot-path dispatch with zero per-call allocation from the library itself. The dispatch code (Mediator.SendAsync, the pipeline walker, the registries) is not async anywhere on the hot path - it returns whatever ValueTask / ValueTask<T> your handler chain produces, without ever wrapping it in its own state machine. When your handlers and behaviors also return synchronously-completed ValueTasks, the entire Send/Publish is 0 B end-to-end. When your async handler actually suspends at an await, the runtime boxes that handler's state machine - that allocation comes from your code, not from the library.
Representative numbers on .NET 10 (BenchmarkDotNet 0.14.0, MemoryDiagnoser):
| Scenario | Latency | Allocated |
|---|---|---|
Send_NoPipeline |
~10.4 ns | 0 B |
Send_Specific1 |
~34.8 ns | 0 B |
Send_Specific3 |
~60.2 ns | 0 B |
Send_Specific10 |
~184.5 ns | 0 B |
Send_OpenGeneric1 |
~34.1 ns | 0 B |
Send_OpenGeneric3 |
~60.1 ns | 0 B |
Send_Mixed4 (2 specific + 2 generic) |
~86.4 ns | 0 B |
Publish_Specific3 (3 sync handlers) |
~39.8 ns | 0 B |
Publish_OpenGeneric3 |
~44.3 ns | 0 B |
Publish_Mixed3 |
~46.2 ns | 0 B |
Stream_NoPipeline_Enumerate10 |
~134.4 ns | 104 B¹ |
Stream_Specific1_Enumerate10 |
~287.6 ns | 352 B¹ |
Stream_Specific3_Enumerate10 |
~580.7 ns | 848 B¹ |
Stream_Specific10_Enumerate10 |
~1547.2 ns | 2584 B¹ |
Stream_OpenGeneric3_Enumerate10 |
~617.7 ns | 848 B¹ |
Send_Specific1_Async (awaiting handler) |
~466.6 ns | 264 B² |
¹ The stream allocations are the compiler-emitted async IAsyncEnumerable<T> enumerator state machines in user code - one per behavior plus the terminal handler.
² The async-Send 264 B is the user handler's async ValueTask<T> state-machine box. Opt into the pooled builder per method to recycle these:
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
public async ValueTask<MyResponse> HandleAsync(MyRequest request, CancellationToken cancellationToken = default)
{
// ... uses await ...
}
How the library reaches these numbers:
- Pipeline and notification registries are resolved once in the
Mediatorconstructor, not on every Send. - Behavior registrations are sorted once during
Build()and snapshotted into aFrozenDictionary<Type, T[]>; the per-Send dispatch path reads the pre-sorted arrays directly. - Open-generic behavior types are closed once per
(TRequest, TResponse)pair viaMakeGenericTypeand cached - the cost is paid on the first Send for each pair, never repeated. Notification open-generic handler types are cached the same way per notification type. - The synchronous request pipeline uses a
readonly structwalker that implementsIPipelineContinuation<TRequest, TResponse>and is passed by value through the chain. The JIT specializes each behavior'sHandleAsync<TNext>per walker type, so chain steps are direct calls with no delegate allocation. - A static-generic fast cache (
PipelineFastCache<TRequest, TResponse>) holds the closed behavior-type array for a hot pair. Hot-path lookup is a singleVolatile.Readof an immutable carrier object plus an owner/generation check; multi-registry scenarios (e.g. test suites) fall back to a per-instanceConcurrentDictionarywith no correctness impact. - The stream pipeline (
IStreamPipelineBehavior/IStreamPipelineContinuation) uses the same walker andStreamPipelineFastCachepattern. The remaining 104+ B per stream behavior shown in the table is the compiler-generatedasync IAsyncEnumerable<T>state machine in your code, allocated once perCreateStreamAsynccall per behavior; the mediator's own per-call overhead is zero.
Benchmarks live in the Snowberry.Mediator.Benchmarks project.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. 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. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- Snowberry.Mediator.Abstractions (>= 2.0.0)
- System.Diagnostics.DiagnosticSource (>= 10.0.6)
-
net10.0
- Snowberry.Mediator.Abstractions (>= 2.0.0)
-
net9.0
- Snowberry.Mediator.Abstractions (>= 2.0.0)
NuGet packages (4)
Showing the top 4 NuGet packages that depend on Snowberry.Mediator:
| Package | Downloads |
|---|---|
|
Snowberry.Mediator.DependencyInjection.Shared
A small helper library for supporting multiple Dependency Injection frameworks. |
|
|
Snowberry.Mediator.Extensions.DependencyInjection
Extension for the main package to work with `Microsoft.Extensions.DependencyInjection`. |
|
|
Snowberry.Mediator.DependencyInjection
Extension for the main package to work with `Snowberry.DependencyInjection`. |
|
|
Snowberry.Mediator.OpenTelemetry.Shared
Core OpenTelemetry tracing and metrics decorator for Snowberry.Mediator (container-agnostic). |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated | |
|---|---|---|---|
| 2.0.0 | 404 | 6/1/2026 | |
| 1.1.0 | 1,031 | 1/28/2026 | |
| 1.0.4-alpha | 313 | 11/15/2025 | |
| 1.0.3-alpha | 277 | 11/4/2025 | |
| 1.0.2-alpha | 260 | 10/7/2025 | |
| 1.0.1-alpha | 358 | 9/23/2025 | |
| 1.0.0-alpha | 339 | 9/9/2025 |