s4ndr0ne.SimpleMediator 4.1.0

dotnet add package s4ndr0ne.SimpleMediator --version 4.1.0
                    
NuGet\Install-Package s4ndr0ne.SimpleMediator -Version 4.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="s4ndr0ne.SimpleMediator" Version="4.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="s4ndr0ne.SimpleMediator" Version="4.1.0" />
                    
Directory.Packages.props
<PackageReference Include="s4ndr0ne.SimpleMediator" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add s4ndr0ne.SimpleMediator --version 4.1.0
                    
#r "nuget: s4ndr0ne.SimpleMediator, 4.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package s4ndr0ne.SimpleMediator@4.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=s4ndr0ne.SimpleMediator&version=4.1.0
                    
Install as a Cake Addin
#tool nuget:?package=s4ndr0ne.SimpleMediator&version=4.1.0
                    
Install as a Cake Tool

SimpleMediator

A lightweight implementation of the mediator pattern for .NET, built on Microsoft.Extensions.DependencyInjection.

SimpleMediator focuses on predictable behaviour rather than raw speed: correct DI scope handling, explicit failure contracts, and fail-fast validation. Dispatch overhead is small; handlers and pipeline services are resolved through DI on every call, see What the performance actually consists of. An optional source generator makes the composition root reflection-free for trimming and Native AOT, see AOT & Trimming.

.NET GitHub NuGet NuGet Source Generator NuGet Downloads .NET 8 .NET 10 .NET Standard 2.0

Core Features & Optimizations

  • 🚀 Cached Dispatch: One typed wrapper per request/notification type is created on first use and cached, so steady-state dispatch involves no reflection; handlers, behaviors and pre/post handlers are then resolved through Microsoft DI on every call so lifetimes stay correct.
  • 🛡️ Scope Correctness: Scoped services (like DbContext or UnitOfWork) are shared correctly between your controllers and handlers, and resolving the mediator from the root provider — which would silently turn every one of them into a process-wide singleton — throws MediatorScopeException when it is resolved. See Resolve the mediator from a scope.
  • ✂️ Segregated interfaces: depend on ISender (requests) or IPublisher (notifications) instead of the full IMediator.
  • ⚡ Configurable Notification Dispatch: Notification handlers run sequentially by default — safe to share a scoped service (like DbContext) across handlers — and can opt into parallel execution via Task.WhenAll when handlers are independent.
  • 🔗 Advanced Pipeline: Supports IPipelineBehavior, IPreRequestHandler, IPostRequestHandler, and IRequestExceptionHandler, with ordering and open generics — including open-generic request handlers for generic requests.
  • ✂️ Native AOT ready: the optional s4ndr0ne.SimpleMediator.SourceGenerator package discovers handlers at compile time and emits AddSimpleMediatorGenerated, a drop-in replacement for AddSimpleMediator with no trim/AOT warnings. See AOT & Trimming.
  • 📦 Minimal Dependencies: Built on top of Microsoft.Extensions.DependencyInjection.Abstractions.
  • 🎯 Target frameworks: net8.0, net10.0 and netstandard2.0 (for legacy consumers such as .NET Framework 4.7.2+; on netstandard2.0, IAsyncDisposable and ValueTask come from Microsoft.Bcl.AsyncInterfaces, and implementers of IOrderedPipelineBehavior / IRequestExceptionHandler<,> must declare Order explicitly because default interface members are not supported).

Installation

This library is intended to be used as a NuGet package. To install it, use the .NET CLI:

dotnet add package s4ndr0ne.SimpleMediator

For trimming or Native AOT, install the source generator instead (it depends on the runtime package) and use AddSimpleMediatorGenerated; see AOT & Trimming:

dotnet add package s4ndr0ne.SimpleMediator.SourceGenerator

Supported DI container

SimpleMediator supports only Microsoft.Extensions.DependencyInjection (the default container of ASP.NET Core, the Generic Host, Azure Functions and Worker Services). Every behaviour documented here — scope handling, the root-mediator guard, open-generic resolution and constraint filtering, the order in which GetServices<T>() returns handlers and behaviors, disposal of custom-mapped handlers — is implemented and tested against that container only.

Third-party containers plugged in through IServiceProviderFactory (Autofac, Lamar, DryIoc, SimpleInjector, …) are not supported. They may appear to work, but known differences include:

  • the root-mediator guard relies on how Microsoft DI resolves IServiceScopeFactory; on another container it silently never fires, so a root-owned mediator is no longer detected;
  • enumeration order, open-generic constraint handling and disposal semantics differ between containers, which changes behavior ordering and "one handler per request" detection.

If you must use another container, keep the mediator and its handlers in a Microsoft DI IServiceCollection and validate the behaviours your application relies on with your own tests.

Getting Started

1. Dependency Injection

Register SimpleMediator in your Program.cs or Startup.cs.

using SimpleMediator;
using SimpleMediator.Interfaces;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

services.AddSimpleMediator(options =>
{
    // Scan for request, notification, pre/post, and exception handlers.
    options.RegisterAssembly(typeof(Program).Assembly);
    // Register pipeline behaviors explicitly with AddBehavior.

    // Optionally change the default lifetime (default is Scoped)
    options.DefaultLifetime = ServiceLifetime.Scoped;
});

var serviceProvider = services.BuildServiceProvider();

2. Resolve the mediator from a scope (not from the root)

This is a hard requirement, not a style preference.

IMediator resolves every handler, pre/post handler, behavior, and exception handler from the provider it was created with. A mediator created from the root provider therefore resolves scoped services — your DbContext, unit of work, tenant context, current-user accessor — from the root, where MS DI treats them as one process-wide instance shared by every concurrent request. The failure is silent: nothing throws, you just get a non-thread-safe context serving all traffic.

// Correct: one scope per HTTP request (ASP.NET Core does this for you when you inject IMediator
// into a controller or minimal-API endpoint).
using var scope = serviceProvider.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(new PingRequest("Hello"));

Resolving IMediator, ISender or IPublisher from the root provider throws MediatorScopeException at the moment it is resolved. For a singleton that injects the mediator this happens when the singleton is first constructed (at startup if it is created eagerly, e.g. a hosted service), so the mistake surfaces as an exception instead of as shared state under load. The guard covers both resolution through DI and a Mediator constructed by hand with new Mediator(rootProvider). To accept the root-owned trade-off deliberately — only correct when every handler dependency is a singleton — set:

services.AddSimpleMediator(options => options.RequireScopedMediator = false);
Long-lived components: background services, hosted services, queue consumers

Inject IServiceScopeFactory (not IMediator) and create a scope per unit of work. The CreateMediatorScope() helper owns the scope and the mediator together so they cannot drift apart:

public sealed class OrderConsumer(IServiceScopeFactory scopeFactory, ILogger<OrderConsumer> log)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var order in ReadOrdersAsync(stoppingToken))
        {
            // One scope per message: scoped dependencies are created and released with it.
            await using var mediatorScope = scopeFactory.CreateMediatorScope();
            await mediatorScope.Mediator.Send(new ProcessOrder(order), stoppingToken);
        }
    }
}

CreateMediatorScope() has overloads for both IServiceProvider and IServiceScopeFactory, and the returned IMediatorScope exposes Mediator and ServiceProvider and implements both IDisposable and IAsyncDisposable.

How the check works. MS DI gives the root provider and every scope the same runtime type, so the discriminator is the scope factory: IServiceScopeFactory always resolves to the container's root scope. A service resolved from the root is handed that exact object as its IServiceProvider; the public root ServiceProvider (what new Mediator(root) receives) is not that object but resolves IServiceProvider to it. A caller-created scope matches neither. The comparison is advisory by design — a container that wires the scope factory differently simply does not trigger the guard instead of rejecting a valid mediator. This is one of the reasons only Microsoft DI is supported (see Supported DI container).

Usage

ISender, IPublisher and IMediator

IMediator is the union of two narrower interfaces:

Interface Members Typical consumer
ISender Send<TResponse>(IRequest<TResponse>), Send(IRequest) controllers, endpoints, application services
IPublisher Publish<TNotification>(TNotification) domain-event dispatchers, outbox relays
IMediator : ISender, IPublisher all of the above components that need both

AddSimpleMediator registers all three as transient services. ISender and IPublisher forward to the IMediator registration, so replacing IMediator (for example with a test double registered before AddSimpleMediator) is honoured by all three. The same scope rules apply: resolve them inside the request or operation scope.

public sealed class OrdersController(ISender sender) : ControllerBase
{
    [HttpPost]
    public Task<OrderId> Create(CreateOrder command, CancellationToken ct) => sender.Send(command, ct);
}

public sealed class DomainEventDispatcher(IPublisher publisher)
{
    public async Task DispatchAsync(IEnumerable<INotification> events, CancellationToken ct)
    {
        foreach (var domainEvent in events)
        {
            await publisher.Publish(domainEvent, ct); // dispatched on the runtime type
        }
    }
}

Request/Response

Requests are point-to-point messages that return a result.

// 1. Define Request
public record PingRequest(string Message) : IRequest<string>;

// 2. Define Handler
public class PingRequestHandler : IRequestHandler<PingRequest, string>
{
    public Task<string> Handle(PingRequest request, CancellationToken ct) 
        => Task.FromResult($"Pong: {request.Message}");
}

// 3. Send via Mediator
var response = await mediator.Send(new PingRequest("Hello"));

AOT/trimming: AddSimpleMediator scans assemblies and creates dispatch wrappers through reflection, so it is annotated as trim/AOT-unsafe. For trimmed or Native AOT applications use the source-generated AddSimpleMediatorGenerated instead; see AOT & Trimming.

Request matching is exact. Dispatch uses the request's concrete runtime type, so a handler registered for a base request does not handle a derived request. Although IRequestHandler<in TRequest, TResponse> is contravariant, the built-in DI lookup used by the mediator resolves the exact closed request type.

Notifications

Notifications are broadcast messages sent to every registered handler.

// 1. Define Notification
public record UserCreated(string Email) : INotification;

// 2. Multiple Handlers
public class WelcomeEmailHandler : INotificationHandler<UserCreated> { ... }
public class AnalyticsHandler : INotificationHandler<UserCreated> { ... }

// 3. Publish
await mediator.Publish(new UserCreated("user@example.com"));
Dispatch strategy

Publishing a notification with no registered handler is a silent no-op, not an error. By default handlers run sequentially (NotificationPublishStrategy.Sequential). This is the safe choice: all handlers share the same DI scope, so a scoped, non-thread-safe service (e.g. DbContext) is never touched concurrently. If a handler throws, the remaining handlers are not invoked. Every notification handler must return a non-null Task; returning null fails with InvalidOperationException in sequential mode and follows the same single-failure contract in parallel mode.

Opt into parallel dispatch only when handlers are independent:

services.AddSimpleMediator(options =>
{
    options.RegisterAssembly(typeof(Program).Assembly);
    options.NotificationPublishStrategy = NotificationPublishStrategy.Parallel;
});

In Parallel mode handlers run via Task.WhenAll. If exactly one handler fails, its original exception is rethrown; if more than one fails, an AggregateException carrying all failures is thrown (not just the first). Cancellation retains its dedicated OperationCanceledException behavior.

Notification matching is exact, not contravariant. Although INotificationHandler<in TNotification> is declared contravariant, Microsoft DI resolves handlers by the exact closed type that is published. A handler registered as INotificationHandler<INotification> (or for any base type) will not receive derived concrete notifications — register handlers for the concrete notification type you publish.

Pipeline Behaviors

Behaviors allow you to wrap requests with cross-cutting concerns (Logging, Validation, Caching).

public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    public int Order => 1; // Control execution order

    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        Console.WriteLine($"Handling {typeof(TRequest).Name}");
        return await next(ct);
    }
}

Register behaviors via AddBehavior. Execution order is controlled by each behavior's Order property (lower runs first / outermost). Equal values run in registration order (FIFO): the first registered behavior is outermost and runs first, like MediatR and ASP.NET Core middleware. Order is a default interface member defaulting to 0, so a behavior may omit it entirely; IRequestExceptionHandler<,>.Order has the same default, so both ordering contracts behave identically. Assembly scanning does not register behaviors; add each one explicitly. You can register an open generic type or a closed type bound to a specific request/response pair:

services.AddSimpleMediator(options =>
{
    options.RegisterAssembly(typeof(Program).Assembly);
    options.AddBehavior(typeof(LoggingBehavior<,>)); // open generic, applies to every request
    options.AddBehavior(typeof(MySpecificBehavior)); // closed, implements IPipelineBehavior<MyRequest, MyResponse>
});

Order is read once per request, so it may depend on scoped state, but it must be stable for the duration of a single request: the pipeline snapshots the values and then sorts, instead of re-reading the property on every comparison. A behavior whose Order changes between reads cannot produce an inconsistent sort.

Registering the same behavior type for the same request/response pair twice with different lifetimes is reported as a configuration error rather than silently resolved. TryAddEnumerable keeps the first registration and discards the second, which would otherwise make the effective lifetime depend on module registration order.

Pre / Post Request Handlers

Lightweight hooks that run inside the behavior pipeline, right before or after the main handler.

  • IPreRequestHandler<TRequest, TResponse>: Task Handle(TRequest request, CancellationToken)
  • IPostRequestHandler<TRequest, TResponse>: Task Handle(TRequest request, TResponse response, CancellationToken)

Open-Generic Request Handlers

A single handler can serve a generic request for every closed type argument. Both the request and the handler are open generics:

public record EchoRequest<T>(T Value) : IRequest<T>;

public class EchoHandler<T> : IRequestHandler<EchoRequest<T>, T>
{
    public Task<T> Handle(EchoRequest<T> request, CancellationToken ct) => Task.FromResult(request.Value);
}

// Discovered automatically by RegisterAssembly — no explicit registration needed.
int n   = await mediator.Send(new EchoRequest<int>(42));      // -> 42
string s = await mediator.Send(new EchoRequest<string>("hi")); // -> "hi"

The handler is closed to the concrete request type on first use (the match and its construction factory are cached), and its constructor dependencies are injected from the current DI scope. The one-handler-per-request rule still applies: if both a closed and an open-generic handler match the same request, Send throws.

There are two open-generic paths. A native-compatible handler lines its implementation parameters up 1:1 with the service contract:

public class GenericHandler<TRequest, TResponse> : IRequestHandler<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    public Task<TResponse> Handle(TRequest request, CancellationToken ct) => /* ... */;
}

Native-compatible handlers are registered as ordinary open-generic DI services. They follow DefaultLifetime, are disposed by the container, and behave exactly like closed handlers. Custom-mapped handlers such as EchoHandler<T> : IRequestHandler<EchoRequest<T>, T> cannot be closed by Microsoft DI, so they use SimpleMediator's type-argument matcher and are activated by SimpleMediator itself, outside the native registration path.

Lifetime of custom-mapped open-generic handlers. These follow DefaultLifetime exactly like every other handler. Concretely:

DefaultLifetime Where the instance lives Built from Disposed by
Transient (not the default) one per Send the current scope SimpleMediator, at the end of the request
Scoped (the default) one per DI scope the current scope the scope
Singleton one per application the root provider the root provider

Only the resolution plan (the closed type plus its factory) is cached, never the instance, so scoped dependencies stay correct across requests.

The Singleton row is the subtle one. A singleton custom-mapped handler is built once and reused forever, so it must not be built from a request scope: it would capture that scope's DbContext and keep handing out an instance whose scope was already disposed. SimpleMediator therefore builds singleton custom handlers from the root provider, and — because "the root scope's DbContext shared for the whole process" is almost never what an application wants — it rejects a singleton custom-mapped handler whose constructor takes a Scoped or Transient dependency:

Open-generic request handler 'MyHandler<T>' is registered as a Singleton, but its constructor
depends on 'AppDbContext', which is registered as Scoped. ... Use ServiceLifetime.Scoped
(or Transient) for this handler, or register a closed handler instead.

This check runs on every AddSimpleMediator call, not only under ValidateOnBuild. It reads lifetimes the way Microsoft DI resolves them:

  • a single dependency uses its last non-keyed registration; an IEnumerable<T> dependency is rejected if any registration of T is Scoped or Transient;
  • a dependency closed over the handler's own type parameter (for example Handler<T>(IRepo<T> repo) or ILogger<Handler<T>>) is checked against its open-generic registration (IRepo<>, ILogger<>); with no open-generic registration its lifetime is unknowable up front and the handler is rejected;
  • the constructor marked [ActivatorUtilitiesConstructor] is checked, otherwise every public constructor.

Only direct constructor dependencies are checked. Enable the host's ValidateScopes to catch a scoped service reached indirectly.

Handler decoration is not supported by the single-handler resolver; use IPipelineBehavior<,> for cross-cutting concerns.

Matcher scope: type-argument inference covers the common shapes — direct parameters (IRequestHandler<Query<T>, Result<T>>), nested generics, and single-dimension arrays (IRequestHandler<ArrayRequest<T>, T[]>). It is a deliberately simplified unifier; exotic signatures (multi-dimensional arrays, by-ref/pointer types, deeply mixed constructions) may not resolve. Unsupported open-generic mappings are rejected during registration. When in doubt, register a closed handler. Startup validation checks ambiguities for closed request types represented in the service registrations; it cannot predict every request type an application may send.

Exception Handlers

Recover from (or observe) exceptions thrown anywhere in a request's processing — the handler, its pre/post handlers, any behavior, and the construction of any of them.

public class ValidationExceptionHandler : IRequestExceptionHandler<CreateUser, UserResult>
{
    public Task Handle(CreateUser request, Exception exception,
        RequestExceptionHandlerState<UserResult> state, CancellationToken ct)
    {
        if (exception is ValidationException) state.SetHandled(UserResult.Invalid()); // swallow + substitute
        return Task.CompletedTask; // leaving it un-handled rethrows the original exception
    }
}

Handlers run in ascending Order (a default interface member, default 0); equal values preserve DI resolution order. The value is read once per resolution, so it may depend on scoped state. The first handler to call SetHandled supplies the response returned to the caller and short-circuits the rest. If none handles the exception, it is rethrown with its original stack trace. A catch-all handler is just an open generic — class LogExceptions<TRequest, TResponse> : IRequestExceptionHandler<TRequest, TResponse> — and is picked up automatically by assembly scanning. Assembly scanning orders candidate types by FullName, so discovery is deterministic; still, assign distinct Order values when a fixed relative order matters.

If an exception handler itself throws, the mediator throws an AggregateException containing both the original request exception and the exception-handler failure.

Exactly what reaches an exception handler

This is worth being precise about, because a global catch-all is often used for logging and metrics and silently misses some failures.

Failure Routed to IRequestExceptionHandler<,>?
The handler's Handle throws yes
A pre-handler, post-handler, or behavior's Handle throws yes
A behavior's Handle returns null yes
A behavior, pre/post handler, or request handler fails to be constructed (missing dependency, bad configuration, throwing constructor) yes
A handler returns null instead of a Task yes, as an InvalidOperationException naming the handler
OperationCanceledException from anywhere no — cancellation is control flow, never offered
No handler, or more than one handler, matches the request no — see below
The exception handlers themselves cannot be constructed no — the original exception wins

Handler selection is not a request failure. RequestHandlerResolutionException (an InvalidOperationException) is raised when no handler or more than one handler matches. It is deliberately not offered to exception handlers: letting a catch-all observe it would hide a wiring bug behind whatever substitute response the handler returns, and an exception handler that cannot itself be constructed would replace a precise diagnostic with an unrelated DI error. The same exception type is raised by the startup validators, so ValidateOnBuild and the first failing request report identical wording.

A broken exception pipeline never masks the request failure. If the IRequestExceptionHandler<,> instances cannot themselves be resolved, the original request exception is rethrown rather than being replaced by the DI error — the caller still sees the thing that actually went wrong.

Partial commits. An exception handler can substitute a response for a request whose handler has already committed work (for example a POST handler succeeded and a post-handler then failed). SimpleMediator offers no transaction or outbox, and the substitution is not a rollback: the caller receives a "successful" shape while the write stands. Use a behavior that opens a transaction around next(ct), or an outbox, if you need atomicity.

Cancellation is never swallowed: an OperationCanceledException is treated as control flow, not as an error — it is never offered to IRequestExceptionHandler<,> and propagates straight to the caller, regardless of whether the cancellation originated from the request's own CancellationToken or from a linked/alien token a behavior or handler observed. Likewise, when notification handlers run in Parallel and every faulted handler throws OperationCanceledException while the supplied token is cancelled, Publish surfaces the OperationCanceledException itself rather than an AggregateException wrapping it.

Startup Validation

Configuration mistakes involving a closed request registration (duplicate handlers, or a closed handler also matched by an open-generic handler) otherwise surface only on the first call that hits them. Opt into fail-fast validation so these known conflicts are caught during registration:

services.AddSimpleMediator(options =>
{
    options.RegisterAssembly(typeof(Program).Assembly);
    options.ValidateOnBuild = true; // throws from AddSimpleMediator on a bad configuration
});

// …or validate explicitly, anywhere after registration:
services.ValidateSimpleMediator();

Basic structural validation runs during every AddSimpleMediator call, with no opt-in:

  • open-generic mappings that Microsoft DI cannot close, and scanned open-generic request handlers with no inferable mapping;
  • concrete handler/behavior implementations that are abstract, non-public-constructor, or not activatable by DI;
  • a Singleton custom-mapped open-generic handler with a Scoped or Transient direct constructor dependency (see Lifetimes of custom-mapped open-generic handlers);
  • RequireScopedMediator set to conflicting values by different AddSimpleMediator calls;
  • a behavior registered twice for the same request/response pair with conflicting lifetimes.

ValidateOnBuild (or an explicit services.ValidateSimpleMediator()) additionally enables the conflict checks, which are the ones that would otherwise surface on live traffic:

  • multiple registrations for the same closed IRequestHandler<,> (whether by type, factory, or instance);
  • a closed request handler that is also matched by a scanned SimpleMediator open-generic handler or by a native DI open-generic IRequestHandler<,> registration.

Conflict validation can only see request types that have a closed registration; it cannot predict every request type an application may send. For the complete constructor dependency graph, also enable the host provider's ValidateOnBuild and ValidateScopes.

Recommended for production: set options.ValidateOnBuild = true. Without it, a duplicate handler or an ambiguity surfaces as a RequestHandlerResolutionException on the first request that hits it — i.e. in production, under load, on one endpoint.

Modular registration: AddSimpleMediator may be called more than once — e.g. once per module. Closed handlers accumulate, and scanned open-generic handlers are merged across calls. Scanned types are ordered by FullName within each assembly so composition does not depend on reflection enumeration order. Explicitly configured NotificationPublishStrategy and OpenGenericResolutionCacheCapacity override previous values; a later call that leaves them at their defaults preserves the existing module configuration. RequireScopedMediator follows the same rule — a call that does not set it keeps the earlier value — with one addition: two calls that set it explicitly to different values throw InvalidOperationException, so one module cannot silently disable the guard for the others. Once ValidateOnBuild is enabled by any module—or ValidateSimpleMediator() is called explicitly—subsequent modular calls keep validation enabled so newly added registrations are checked as part of the accumulated composition.

Assembly Scanning Rules

Scanning is indiscriminate within the assemblies you register, so it is worth knowing exactly what is considered and what is skipped.

Registered: closed types implementing IRequestHandler<,>, INotificationHandler<>, IPreRequestHandler<,>, IPostRequestHandler<,>, or IRequestExceptionHandler<,>; open generic implementations of the same interfaces. A type implementing several of them is registered for each.

Skipped silently (never a startup error):

  • an open generic nested inside a generic type — Outer<T>.Handler<TU>. Nothing in the application can supply Outer<T>'s argument, so no caller can ever close it. Such a type used to abort the whole composition root, which meant one unreachable type could take the application down at startup.
  • any other type that still has unbound generic parameters.
  • anything excluded by a type filter.

Rejected: an inferable open-generic request handler whose mapping Microsoft DI cannot close is registered through SimpleMediator's matcher; one that is not inferable is a configuration error.

To exclude types from discovery — generated code, obsolete handlers, a composition-root type that happens to implement a handler interface — pass a filter:

services.AddSimpleMediator(options =>
{
    options.RegisterAssembly(typeof(Program).Assembly, type => !type.IsDefined(typeof(ExcludeFromMediation)));
    options.RegisterAssembly(typeof(Contracts).Assembly, type => type.Namespace?.StartsWith("App.Handlers") == true);
});

Calling RegisterAssembly twice for the same assembly narrows the accepted set (both predicates must pass) rather than replacing the earlier one, so modular composition cannot silently re-admit a type another module excluded.

Observability

SimpleMediator keeps the core limited to the DI abstractions dependency; cross-cutting concerns like logging, metrics, tracing, and correlation IDs are implemented as ordinary pipeline behaviors. A timing + tracing behavior, for example:

public class TracingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private static readonly ActivitySource Source = new("SimpleMediator");
    private readonly ILogger<TracingBehavior<TRequest, TResponse>> _logger;

    public TracingBehavior(ILogger<TracingBehavior<TRequest, TResponse>> logger) => _logger = logger;

    public int Order => 0; // outermost: wraps everything else

    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        using var activity = Source.StartActivity(typeof(TRequest).Name); // OpenTelemetry span
        var sw = Stopwatch.StartNew();
        try
        {
            return await next(ct);
        }
        finally
        {
            _logger.LogInformation("{Request} handled in {Elapsed}ms", typeof(TRequest).Name, sw.ElapsedMilliseconds);
        }
    }
}

// services.AddSimpleMediator(o => o.AddBehavior(typeof(TracingBehavior<,>)));

The same shape covers metrics (increment counters), correlation IDs (read/propagate from the request or an ambient context), and structured error logging (log in a catch before rethrowing, or use an IRequestExceptionHandler<,>).

Benchmarks

Repeatable microbenchmarks are provided in benchmarks/SimpleMediator.Benchmarks. Run them in Release mode with the BenchmarkDotNet harness:

dotnet run -c Release -f net10.0 --project benchmarks/SimpleMediator.Benchmarks -- --filter '*MediatorBenchmarks*'

The suite measures request dispatch against a direct handler call and compares sequential and parallel notification publication. Both the mediator and the baseline handler are resolved from a scope, and the baseline handler is async, so the comparison isolates mediator dispatch overhead instead of measuring a completed task against a state machine. MediatorSend_WithBehaviors measures the cost of a three-behavior chain. BenchmarkDotNet reports runtime, operating system, CPU, throughput, and memory allocation; use its generated reports when comparing changes. Run on an otherwise idle machine and compare results only across matching hardware and runtime configurations. Use net8.0 instead of net10.0 to benchmark that target framework. For a quick harness check (not performance comparisons), append --job Dry.

AOT & Trimming

SimpleMediator has two composition roots with identical configuration and runtime behavior:

AddSimpleMediator (reflection) AddSimpleMediatorGenerated (source generator)
Handler discovery Assembly scanning at startup At compile time
Dispatch wrappers MakeGenericType on first use Pre-generated, closed types
Open generics (handlers, behaviors) Closed at runtime by DI / SimpleMediator Closed at compile time for every request/notification the generator sees
Trimming / Native AOT Not supported (IL2026/IL3050 at AddSimpleMediator) Supported: no trim/AOT warning, verified by a Native AOT binary in CI

Native AOT with the source generator

Install the generator package (it brings s4ndr0ne.SimpleMediator with it) and change one word in the composition root:

dotnet add package s4ndr0ne.SimpleMediator.SourceGenerator
services.AddSimpleMediatorGenerated(options =>   // was: AddSimpleMediator
{
    options.RegisterAssembly(typeof(Program).Assembly);
    options.AddBehavior(typeof(LoggingBehavior<,>));
});

Everything else — DefaultLifetime, NotificationPublishStrategy, ValidateOnBuild, assembly filters, behavior ordering, scopes, ISender/IPublisher/IMediator — works unchanged. The generator emits an internal AddSimpleMediatorGenerated into the project that references it, so reference the generator from the project that composes the container.

What the generator sees, and therefore what works under AOT:

  • Assemblies: the current project, plus every assembly passed to RegisterAssembly as typeof(T).Assembly, typeof(T).GetTypeInfo().Assembly or Assembly.GetExecutingAssembly(). At runtime, handlers are registered only for assemblies passed to RegisterAssembly, in that order, and assembly filters receive the declared type (for an open-generic handler, its generic definition), exactly like scanning. Passing an assembly the generator could not scan throws at startup.
  • Requests and notifications: every concrete message type in a scanned assembly, every message named by a closed handler, and the argument type of every Send/Publish call in the project. Generic messages such as Echo<int> are known from the Send(new Echo<int>(...)) call site.
  • Open-generic handlers and behaviors are closed for each known message that satisfies their constraints. Behaviors remain opt-in: only those passed to AddBehavior are registered.

Limitations of generated mode:

  • A request or notification type that only exists at runtime (for example Send(new Echo<T>(value)) inside a generic method) has no generated wrapper. Generated mode never falls back to reflection: the call fails with an exception that names the missing type (RequestHandlerResolutionException for requests, InvalidOperationException for notifications). Send the closed type somewhere the generator can see it.
  • Handlers must be accessible from the composing project: public, or internal in the same project. Private nested handlers are skipped with a warning; internal types of referenced assemblies are invisible to the generator.
  • Open-generic request handlers that SimpleMediator closes itself in reflection mode (for example EchoHandler<T> : IRequestHandler<Echo<T>, T>) become ordinary closed DI registrations, so their lifetime is owned by Microsoft DI.
  • new Mediator(serviceProvider) takes its dispatch strategy from the container it is given, so it is AOT-safe on a container composed with AddSimpleMediatorGenerated. To register handlers by hand in an AOT application, call services.AddSimpleMediatorGenerated() without options: it registers the generated dispatch table and no handler. A provider with no SimpleMediator registration at all can only use reflection; under Native AOT the Mediator constructor rejects it with an InvalidOperationException that explains the fix, instead of failing later on the first value-type request.
  • Requires Roslyn 4.8 or later (.NET 8 SDK, Visual Studio 17.8). Generated code is C# 7.3 compatible.

Generator diagnostics (reported only in projects that call AddSimpleMediatorGenerated):

ID Severity Meaning
SMG000 Warning The generator failed; no registrations were emitted
SMG001 Warning More than one handler for the same request (fine only if an assembly filter excludes all but one)
SMG002 Warning A handler, behavior or message is not accessible from generated code and was skipped
SMG003 Warning A Send/Publish argument type contains type parameters, so it cannot be pre-generated
SMG004 Info An open-generic handler or behavior matched no known message
SMG005 Warning AddBehavior was not called with a typeof(...) expression
SMG006 Warning The RegisterAssembly argument cannot be resolved at compile time

samples/SimpleMediator.AotSample is published with Native AOT in CI; the publish must produce no trim/AOT warning and the self-checking native binary must pass request/response, void (Unit), value-type and open-generic dispatch, behaviors, pre/post handlers, notifications and exception handlers.

Reflection mode and trimming

AddSimpleMediator and ValidateSimpleMediator are annotated with [RequiresUnreferencedCode] and [RequiresDynamicCode]: scanning, runtime generic instantiation and ActivatorUtilities cannot be made trim-safe. The dispatch APIs (ISender, IPublisher, Mediator) carry no annotation, because whether they are safe depends only on how the container was composed. The library itself is built with IsAotCompatible on net8.0/net10.0, so these are the only places trim/AOT warnings can surface.

Consumer builds with trimming or AOT. A project with PublishTrimmed or PublishAot and TreatWarningsAsErrors fails to compile on AddSimpleMediator with IL2026 and IL3050. Use AddSimpleMediatorGenerated instead. Suppressing the warnings does not make reflection mode work: handlers reachable only through scanning are trimmed away, and void or value-type responses fail at runtime under Native AOT.

The package does not inject transitive global usings into consumer projects. Add using SimpleMediator.Interfaces; explicitly, or enable the namespace in the consuming project if desired.

Why SimpleMediator?

SimpleMediator uses a hybrid approach:

  1. Discovery: Reflection is used once at startup to find handlers.
  2. Wrapper creation: The first time a request or notification type is used, SimpleMediator closes a generic wrapper type (MakeGenericType) and instantiates it once with Activator.CreateInstance; the instance is cached. Concurrent first use is coalesced so only one wrapper is created per cache key.
  3. Execution: Subsequent calls reuse the cached wrapper, which calls your handler through ordinary typed generic code, while handlers and pipeline services are resolved through Microsoft Dependency Injection on every call so lifetimes and scopes remain correct.

What the performance actually consists of

Per Send, on top of your handler's own work, SimpleMediator performs:

  • one bounded-cache lookup keyed by (requestType, responseType) — lock-free on the hot path;
  • GetServices<IRequestHandler<,>>() for handler selection plus one each for pre-handlers, post-handlers and behaviors: four enumerable resolutions, each materialising an array, plus a GetService<MediatorConfiguration>() and an open-generic plan lookup;
  • one handler delegate allocation plus one closure per behavior;
  • one Order snapshot and, only when registration order is not already correct, one sort;
  • the async state machines of the behavior chain.

Resolving the mediator itself (it is transient) costs two more lookups: the configuration and, for the root guard, IServiceScopeFactory.

The cached wrapper removes reflection from the per-call path, but the per-request DI resolutions and delegate allocations dominate. No comparative benchmark against other mediator libraries is published; measure with the suite above rather than assuming a number.

The wrapper caches are unbounded: one small wrapper per request/response pair or notification type actually dispatched, which is a finite set. They live on the container's configuration singleton, never in a static, so they are released with the container — the same lifetime for which Microsoft DI itself retains every service type it has resolved. A plugin host that unloads a collectible AssemblyLoadContext must therefore dispose the container that dispatched that context's types, as it already must for Microsoft DI. (Before 4.0 these caches were bounded at 1024 entries with FIFO eviction, which made dispatch cost jump for applications with more distinct request types than that.) The open-generic resolution-plan cache remains bounded by OpenGenericResolutionCacheCapacity.

Known limitations

  • Native AOT and trimming require the source generator (AddSimpleMediatorGenerated); reflection-based AddSimpleMediator does not support them. See AOT & Trimming for the generated mode's own limits.
  • No IStreamRequest / IAsyncEnumerable request support.
  • No IPipelineContext equivalent, so there is no way to pass per-request services or arguments alongside a request; everything flows through the ambient IServiceProvider of the mediator's scope.
  • No handler decoration: one handler per request and no built-in decorator chain. Use IPipelineBehavior<,> for cross-cutting concerns.
  • Handler and notification matching is exact, not contravariant. A handler registered for a base request or notification does not receive derived ones, even though the interfaces are declared contravariant.
  • No per-request transaction or outbox; see Partial commits.
  • Only Microsoft.Extensions.DependencyInjection is supported (see Supported DI container). The root-mediator guard is advisory and does not fire on other containers.
  • Notifications have no pipeline: behaviors, pre/post handlers and exception handlers apply to requests only.

License

This project is licensed under the MIT License. See the LICENSE file for details.

Contributing

Contributions, pull requests, and corrections are welcome. Please open issues or submit PRs to propose improvements.

Product 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 is compatible.  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 was computed.  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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on s4ndr0ne.SimpleMediator:

Package Downloads
s4ndr0ne.SimpleMediator.SourceGenerator

Compile-time registration for SimpleMediator: discovers handlers and behaviors at build time and emits AddSimpleMediatorGenerated, a reflection-free composition root compatible with trimming and Native AOT.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
4.1.0 107 9/26/2026
4.0.0 83 9/25/2026
3.1.1 92 9/18/2026
3.1.0 127 7/21/2026
3.0.1 133 6/21/2026
2.1.0 121 6/16/2026
2.0.0 119 5/17/2026
1.1.1 126 3/15/2026
1.0.1 133 3/10/2026
1.0.0 182 12/6/2025

See CHANGELOG.md for release history.