SwitchMediator: A High-Performance, Source-Generated Mediator for .NET
SwitchMediator is a zero-allocation, AOT-friendly implementation of the mediator pattern, designed to be API-compatible with popular libraries like MediatR.
By leveraging C# Source Generators, SwitchMediator moves the heavy lifting from runtime to compile time. Instead of scanning assemblies and using Reflection for every dispatch, it generates a static, type-safe lookup (using FrozenDictionary on .NET 8+) that routes messages to handlers instantly.
The result is a mediator that offers:
- Zero Runtime Reflection: No scanning cost at startup.
- AOT & Trimming Compatibility: Native support for modern .NET deployment models.
- Compile-Time Safety: Missing handlers are caught during the build, not at runtime.
- Step-Through Debugging: You can step directly into the generated dispatch code to see exactly how your pipeline works.
- What's New in V3.2
- What's New in V3.1
- What's New in V3
- What's New in V2
- Why SwitchMediator?
- 🌟 Feature Spotlight: True Polymorphic Dispatch
- Key Advantages
- Features
- Installation
- Usage Example
- License
V3.2 fixes behavior applicability checks for self-referential generic constraints in request/value-request pipelines. This primarily affects advanced patterns where a request or behavior constrains TResponse using the same type parameter recursively, for example where TResponse : struct, IErrorResultFactory<TResponse>.
Both Task-based and ValueTask-based pipelines are supported here. In V3.2, the generated mediator now applies matching pipeline behaviors correctly for self-referential constraint patterns such as error-result factories, OneOf-style responses, and similar static-abstract factory shapes.
V3.1 introduces IValueMediator, IValueSender, and IValuePublisher — a parallel set of interfaces that dispatch via ValueTask instead of Task.
The generated mediator class now implements both IMediator and IValueMediator simultaneously. You can inject whichever interface suits your performance needs.
New interfaces added:
| Interface | Purpose |
|---|---|
IValueSender |
ValueTask<TResponse> Send(IRequest<TResponse>, CancellationToken) |
IValuePublisher |
ValueTask Publish(INotification, CancellationToken) |
IValueMediator |
Combines IValueSender + IValuePublisher |
IValueRequestHandler<TRequest, TResponse> |
ValueTask-returning handler |
IValueNotificationHandler<TNotification> |
ValueTask-returning notification handler |
IValuePipelineBehavior<TRequest, TResponse> |
ValueTask-returning pipeline behavior |
IValueNotificationPipelineBehavior<TNotification> |
ValueTask-returning notification pipeline behavior |
Allocation characteristics:
| Path | Allocation |
|---|---|
IValuePublisher.Publish + IValueNotificationHandler |
Zero allocation in the dispatch layer |
IPublisher.Publish + INotificationHandler (existing) |
Already zero allocation (unchanged) |
IValueSender.Send + IValueRequestHandler |
Zero allocation — fully alloc-free in the mediator infrastructure |
ISender.Send + IRequestHandler (existing) |
~96 B per call (unchanged) |
Note:
IValueSender.Sendpaired withIValueRequestHandler(and no pipeline behaviors) is fully alloc-free.IValuePublisher.Publishuses the same zero-cost dictionary routing, but the generated notification fan-out uses async iteration, which may allocate when your handlers are genuinely asynchronous (as per normal usage of .netValueTask).
A new PipelineConsistencyAnalyzer (diagnostic ID SMD002) reports a build error if you accidentally mix IPipelineBehavior (Task) behaviors with IValueRequestHandler (ValueTask) handlers, or vice versa.
Why does this happen?
SwitchMediator automatically applies behaviors to handlers based on generic constraints. A behavior like ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull applies to all requests (since all requests satisfy notnull). If you also have a ValueTask handler for the same request type, the analyzer detects the mismatch and reports SMD002.
// ❌ SMD002 error: Generic IPipelineBehavior applies to ValueTask handler
public class FastStatusCheckHandler : IValueRequestHandler<FastStatusCheckRequest, bool> { ... }
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull // This applies to ALL requests — including FastStatusCheckRequest!
{ ... }
// ✅ Fix 1: Constrain the behavior so it only applies to specific requests
public interface IValidatable { }
public class FastStatusCheckRequest : IRequest<bool> { ... } // No IValidatable — behavior doesn't apply
public class UserUpdateRequest : IRequest<bool>, IValidatable { ... } // Has IValidatable — behavior applies
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull, IValidatable // Now only applies to requests implementing IValidatable
{ ... }
// ✅ Fix 2: Create a matching ValueTask version for behaviors that should apply to both pipelines
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull { ... } // For Task handlers
public class ValidationValueBehavior<TRequest, TResponse> : IValuePipelineBehavior<TRequest, TResponse>
where TRequest : notnull { ... } // For ValueTask handlersHow the analyzer works: The analyzer checks each behavior's generic constraints against handler types. A behavior only applies if the request type satisfies all of its constraints (e.g., where TRequest : notnull, IValidatable). This allows you to selectively apply behaviors to specific request types while avoiding cross-pipeline contamination.
Self-referential generic constraints are supported too. This matters for ValueTask-based pipelines that model OneOf/Result-style responses using constraints like where TResponse : struct, IErrorResultFactory<TResponse>. SwitchMediator now correctly recognizes those behaviors as applicable when the request constraint flows the same response type through the pipeline.
public interface IErrorResultFactory<TSelf>
where TSelf : struct, IErrorResultFactory<TSelf>
{
static abstract TSelf CreateFromError(string error);
}
public interface IOneOfRequest<TResponse> : IRequest<TResponse>
where TResponse : struct, IErrorResultFactory<TResponse>;
public sealed class DeleteMenuItemCommand : IOneOfRequest<MenuOperationResult> { }
public readonly struct MenuOperationResult : IErrorResultFactory<MenuOperationResult>
{
public static MenuOperationResult CreateFromError(string error) => new(isError: true);
}
public sealed class RecoverableValueBehavior<TRequest, TResponse> : IValuePipelineBehavior<TRequest, TResponse>
where TRequest : class, IOneOfRequest<TResponse>
where TResponse : struct, IErrorResultFactory<TResponse>
{
public async ValueTask<TResponse> Handle(
TRequest request,
ValueRequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken = default)
{
try
{
return await next(cancellationToken);
}
catch (Exception ex)
{
return TResponse.CreateFromError(ex.Message);
}
}
}This is included in sample/Sample.ConsoleApp so you can see both the successful path and the fallback error-result path in a working example.
AddMediator<T>() automatically registers IValueMediator, IValueSender, and IValuePublisher alongside the existing IMediator, ISender, and IPublisher. No extra configuration needed.
// All six interfaces are available after a single AddMediator call
var sender = sp.GetRequiredService<ISender>(); // Task-based
var valueSender = sp.GetRequiredService<IValueSender>(); // ValueTask-based (zero extra setup)In previous versions, the library automatically generated a class named SwitchMediator. In V3, you must define the mediator class yourself as a partial class and mark it with the [SwitchMediator] attribute.
Why?
- Namespace Control: You can now place the mediator in any namespace you choose.
- Visibility Control: You decide if your mediator is
publicorinternal.
The KnownTypes property is no longer on a static SwitchMediator class. It is now generated as a static property on your custom partial class.
V2 introduced support for pipeline behaviors on Notifications.
SwitchMediator wraps each handler execution independently in its own pipeline scope. This enables powerful patterns like Resilience: you can write a behavior that catches exceptions from a specific handler, logs them, and swallows them, ensuring that other handlers for the same notification still execute.
The Performance Advantage: SwitchMediator generates this "Russian Doll" wrapping code at compile time. This makes the pipeline structure effectively "free" at runtime—zero allocation overhead for the pipeline construction itself.
To further optimize performance, the signature for pipeline delegates has changed. You must now pass the cancellationToken explicitly to next.
Why? This prevents the compiler from creating a closure to capture the cancellationToken from the outer scope, significantly reducing allocations in high-throughput scenarios.
Before (V1):
await next(); // Implicitly captured token, caused allocationAfter (V2+):
await next(cancellationToken); // Explicit pass, zero allocationTraditional mediator implementations often rely on runtime reflection to discover handlers. While flexible, this approach has trade-offs regarding startup time, memory allocation, and AOT compatibility.
SwitchMediator solves this by handling discovery at build time. The source generator creates explicit C# code with direct method calls, offering a "pay-as-you-go" approach where the cost is incurred during compilation, not during your application's execution.
Most source-generated mediators force you to have an Exact Type Match between your Request and your Handler.
SwitchMediator is smarter. It analyzes your type hierarchy at compile time and generates the necessary dispatch logic to support inheritance, without the runtime cost of walking the inheritance tree.
| Feature | SwitchMediator | MediatR (Reflection) | Mediator (Source Gen) |
|---|---|---|---|
| Method | Source Generator | Runtime Reflection | Source Generator |
| Request Inheritance | ✅ Supported | ✅ Supported | ❌ Not Supported |
| Notification Inheritance | ✅ Supported (Fallback)* | ✅ Supported (Broadcast) | ❌ Not Supported |
| AOT / Trimming | ✅ Native | ✅ Native |
- Request Inheritance: You can define a handler for
IRequestHandler<BaseClass, ...>and send aDerivedClass. SwitchMediator detects this relationship during the build and routesDerivedClassdirectly to the base handler. - Notification Fallback (*): If you publish a
UserCreatedEvent(which inherits fromDomainEvent), and you only have a handler forDomainEvent, SwitchMediator will automatically route it there. (Note: Unlike MediatR which broadcasts to ALL handlers in the hierarchy, SwitchMediator targets the most specific handler found to avoid accidental double-execution).
- 🚀 Maximum Performance: Eliminates runtime reflection lookup overhead. Ideal for performance-critical paths and high-throughput applications.
- 🧐 Enhanced Debuggability: You can directly step into the generated code! See the exact logic matching your request and observe the explicit nesting of pipeline behavior calls.
- ✅ Compile-Time Safety: Missing request handlers result in build errors, not runtime exceptions, catching issues earlier in the development cycle.
- ✂️ Trimming / AOT Friendly: Because there is no dynamic Reflection, the dispatch mechanism is inherently compatible with .NET trimming and NativeAOT.
- Request/Response messages (
IRequest<TResponse>,IRequestHandler<TRequest, TResponse>) - ValueTask Request/Response (
IValueRequestHandler<TRequest, TResponse>,IValueSender) for reduced-allocation dispatch - Notification messages (
INotification,INotificationHandler<TNotification>) - ValueTask Notifications (
IValueNotificationHandler<TNotification>,IValuePublisher) for zero-allocation notification dispatch - Hybrid
IValueMediator: generated class implements bothIMediatorandIValueMediatorsimultaneously - Polymorphic Dispatch (Inheritance support for Requests and Notifications).
- Pipeline Behaviors (
IPipelineBehavior<TRequest, TResponse>) for cross-cutting concerns. - ValueTask Pipeline Behaviors (
IValuePipelineBehavior<TRequest, TResponse>) - Notification Pipeline Behaviors (
INotificationPipelineBehavior<TNotification>) for per-handler middleware (Resilience, Retries, etc.). - SMD002 compile-time enforcement of Task/ValueTask pipeline consistency
- Native support for Result pattern (e.g. FluentResults).
- Flexible Pipeline Behavior Ordering via
[PipelineBehaviorOrder(int order)]. - Explicit Notification Handler Ordering via DI configuration.
- Seamless integration with
Microsoft.Extensions.DependencyInjection.
You'll typically need two packages:
Mediator.Switch.SourceGenerator: The SwitchMediator source generator itself.Mediator.Switch.Extensions.Microsoft.DependencyInjection: (Optional) Provides extension methods for easy registration with the standard .NET DI container.
dotnet add package Mediator.Switch.SourceGenerator
dotnet add package Mediator.Switch.Extensions.Microsoft.DependencyInjectionRefer to Sample app for more information.
Create a partial class in your project and mark it with [SwitchMediator]. This tells the source generator where to generate the dispatch logic.
Note: Because this class is instantiated via Dependency Injection (Reflection), your IDE might warn that the class is unused. You can suppress this warning as shown below.
using Mediator.Switch;
using System.Diagnostics.CodeAnalysis;
namespace My.Application;
[SwitchMediator]
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via DI")]
public partial class AppMediator; Register your custom mediator class in your application's composition root (e.g., Program.cs).
using Microsoft.Extensions.DependencyInjection;
using Mediator.Switch;
using Mediator.Switch.Extensions.Microsoft.DependencyInjection;
using My.Application; // Namespace where you defined AppMediator
public static class Program
{
public static async Task Main()
{
var services = new ServiceCollection();
// --- SwitchMediator Registration ---
// Register your custom partial class.
// The extension method automatically finds the generated 'KnownTypes' on AppMediator.
services.AddMediator<AppMediator>(op =>
{
op.ServiceLifetime = ServiceLifetime.Scoped;
// Optional: Specify notification handler order
op.OrderNotificationHandlers<UserLoggedInEvent>(
typeof(UserLoggedInLogger),
typeof(UserLoggedInAnalytics)
);
});
// --- Build and Scope ---
var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
// Both Task-based and ValueTask-based interfaces are registered automatically
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
var publisher = scope.ServiceProvider.GetRequiredService<IPublisher>();
var valueSender = scope.ServiceProvider.GetRequiredService<IValueSender>(); // New in V4
var valuePublisher = scope.ServiceProvider.GetRequiredService<IValuePublisher>(); // New in V4
await RunSampleLogic(sender, publisher, valueSender, valuePublisher);
}
}Inject ISender and IPublisher (Task-based) or IValueSender and IValuePublisher (ValueTask-based) into your services and use them to dispatch messages.
public static async Task RunSampleLogic(ISender sender, IPublisher publisher,
IValueSender valueSender, IValuePublisher valuePublisher)
{
// Task-based (backward compatible)
var response = await sender.Send(new GetUserRequest(123));
await publisher.Publish(new UserLoggedInEvent(123));
// ValueTask-based (reduced-allocation path) — New in V4
bool ok = await valueSender.Send(new FastStatusCheckRequest());
await valuePublisher.Publish(new ServerStartedEvent());
}SwitchMediator is licensed under the MIT License.