ModernMediator 2.2.1

dotnet add package ModernMediator --version 2.2.1
                    
NuGet\Install-Package ModernMediator -Version 2.2.1
                    
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="ModernMediator" Version="2.2.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ModernMediator" Version="2.2.1" />
                    
Directory.Packages.props
<PackageReference Include="ModernMediator" />
                    
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 ModernMediator --version 2.2.1
                    
#r "nuget: ModernMediator, 2.2.1"
                    
#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 ModernMediator@2.2.1
                    
#: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=ModernMediator&version=2.2.1
                    
Install as a Cake Addin
#tool nuget:?package=ModernMediator&version=2.2.1
                    
Install as a Cake Tool

ModernMediator

NuGet

Version Status

This README describes the contract delivered by v2.2.1. Version 2.2.0 was withdrawn from NuGet due to a source generator packaging defect that prevented AddModernMediatorGenerated() from being callable from consuming projects via PackageReference; migrate directly from v2.1.0 to v2.2.1, and do not depend on v2.2.0.

A modern, feature-rich mediator library for .NET 8 that combines the best of pub/sub and request/response patterns with advanced features for real-world applications. Zero reflection on the request/response and ValueTask dispatch paths, Native AOT compatible, compile-time diagnostics, and a ValueTask pipeline for zero-allocation dispatch.

Status: Stable

Production-ready. Please report issues on GitHub.

Documentation

📖 Interactive Tutorial 📊 Benchmarks

Do You Need a Mediator?

Some developers argue that the mediator pattern is unnecessary indirection. They're not wrong, if all you're doing is routing a request to a single handler with no cross-cutting concerns, a mediator adds complexity without value. You can inject a service directly, call a method on it, and it works fine.

The pattern earns its keep in two situations.

The first is pipeline behaviors. When every command needs validation, logging, telemetry, timeout enforcement, and eventually caching and retry, and you want those concerns applied consistently without every handler author remembering to wire them up manually, a mediator stops being ceremony and starts being infrastructure. The alternative is decorators or hand-rolled middleware, which either requires manual wiring per handler or introduces the same registration complexity the mediator solves more cleanly.

The second is plugin architectures. When you need to discover and dispatch to handlers that don't exist at compile time in the host application, the mediator pattern isn't a convenience, it's the natural solution. Runtime subscribe/unsubscribe, weak references for handler lifecycle management, and string key routing all support this use case.

If your project doesn't benefit from either of these, don't use a mediator. If it does, ModernMediator is designed to make that choice pay off.

When Do You Need Pipeline Behaviors?

When you have logic that applies across many handlers and you don't want to repeat it inside each one.

Validation is the simplest example. Every command that accepts user input needs validation. Without a pipeline behavior, every handler starts with the same boilerplate, check the input, throw if it's bad, then do the actual work. Multiply that across fifty handlers and you have fifty places to forget, fifty places to get wrong, and fifty places to update when your validation strategy changes. A ValidationBehavior runs before every handler automatically. The handler author writes a FluentValidation validator, registers it, and never thinks about the plumbing.

Logging is the same story. You want to know that a request entered the pipeline, how long it took, and whether it succeeded or failed. Without a behavior, you're scattering logger calls across every handler. With a LoggingBehavior, it's applied uniformly and the handler code stays focused on business logic.

Then it cascades: telemetry, you want ActivitySource traces and duration metrics on every dispatch without instrumenting each handler individually. Timeout enforcement, you want a hard ceiling on handler execution time, applied via a [Timeout] attribute rather than each handler managing its own CancellationTokenSource. Authorization, you want policy checks before the handler even runs, not buried inside it.

The pattern is always the same: a concern that is orthogonal to the handler's purpose, that applies to many or all handlers, and that you want enforced consistently rather than relying on each developer to remember to include it. One behavior class, registered once, applied everywhere.

ModernMediator ships built-in behaviors for validation (via FluentValidation), logging, telemetry, and timeout enforcement. You don't need to write these yourself.

Features

Core Patterns

  • Request/Response: Send requests and receive typed responses
  • Streaming: IAsyncEnumerable support for large datasets
  • Pub/Sub (Notifications): DI-based notification dispatch via IPublisher
  • Pub/Sub with Callbacks: Collect responses from multiple subscribers
  • Result<T> Pattern: readonly struct with implicit conversions, Map, and GetValueOrDefault for railway-oriented error handling

Pipeline

  • Pipeline Behaviors: Wrap handler execution for cross-cutting concerns
  • Pre-Processors: Run logic before handlers execute
  • Post-Processors: Run logic after handlers complete
  • Exception Handlers: Clean, typed exception handling separate from business logic
  • Built-in LoggingBehavior: Request/response logging with configurable levels via AddLogging()
  • Built-in TimeoutBehavior: Per-request timeout via [Timeout(ms)] attribute and AddTimeout()
  • Built-in ValidationBehavior: FluentValidation integration via ModernMediator.FluentValidation
  • Built-in AuditBehavior: per-request audit recording (type, user, correlation ID, duration, outcome) dispatched to any IAuditWriter; opt out with [NoAudit]; registered via AddAudit<TWriter>()
  • Built-in IdempotencyBehavior: deduplicates requests marked [Idempotent] by key and TTL using any IIdempotencyStore; registered via AddIdempotency()
  • Built-in CircuitBreakerBehavior: per-request-type circuit breaker via [CircuitBreaker] attribute; open circuit throws CircuitBreakerOpenException; registered via AddCircuitBreaker()
  • Built-in RetryBehavior: automatic retry with configurable count and delay strategy (Fixed, ExponentialBackoff) via [Retry] attribute; retries any exception by default, narrowable to specific exception types via RetryOptions; registered via AddRetry()

Source Generators & AOT

  • Source Generators: Compile-time code generation eliminates reflection
  • Native AOT Compatible: Full support for ahead-of-time compilation
  • Compile-Time Diagnostics: Compile-time diagnostic rules catch handler and pipeline problems during build before they reach runtime
  • Zero Reflection: Generated AddModernMediatorGenerated() for maximum performance
  • CachingMode: Eager (default) or Lazy initialization for cold start optimization
  • ASP.NET Core Endpoint Generation: [Endpoint] attribute with MapMediatorEndpoints() for Minimal API integration

Performance

  • ValueTask Pipeline: IValueTaskRequestHandler and ISender.SendAsync for zero-allocation dispatch
  • Closure Elimination: RequestHandlerDelegate<TRequest, TResponse> passes request and token explicitly
  • Lower allocations than MediatR on every benchmark, see 📊 Benchmarks
  • 4.3x faster cold start than MediatR via source-generated registration

Observability

  • OpenTelemetry Integration: ActivitySource and Meter with RequestCounter and RequestDuration. Metric emission is opt-in via AddTelemetry() and gated at runtime by TelemetryOptions.Enabled. See ADR-012 for the rationale behind the opt-in design.

Interface Segregation

  • ISender: Request/response dispatch
  • IPublisher: Notification publishing
  • IStreamer: Streaming dispatch
  • IMediator: Composes all three; all registered in DI as forwarding aliases

Advanced Capabilities

  • Weak References: Handlers can be garbage collected, preventing memory leaks
  • Strong References: Opt-in for handlers that must persist
  • Runtime Subscribe/Unsubscribe: Dynamic handler registration (perfect for plugins)
  • Predicate Filters: Filter messages at subscription time
  • Covariance: Subscribe to base types, receive derived messages
  • String Key Routing: Topic-based subscriptions alongside type-based
  • ICurrentUserAccessor: abstraction for resolving current user identity in pipeline behaviors; HttpContextCurrentUserAccessor (in ModernMediator.AspNetCore) resolves UserId and UserName from IHttpContextAccessor

Async-First Design

  • True Async Handlers: SubscribeAsync with proper Task.WhenAll aggregation
  • Cancellation Support: All async operations respect CancellationToken
  • Parallel Execution: Notifications execute handlers concurrently

Error Policies

  • Three Policies: ContinueAndAggregate, StopOnFirstError, LogAndContinue
  • HandlerError Event: Hook for logging and monitoring
  • Exception Unwrapping: Clean stack traces without reflection noise

UI Thread Support

  • Built-in Dispatchers: WPF, WinForms, MAUI, ASP.NET Core
  • SubscribeOnMainThread: Automatic UI thread marshalling

Modern .NET Integration

  • Dependency Injection: services.AddModernMediator()
  • Assembly Scanning: Auto-discover handlers, behaviors, and processors
  • Multi-target: net8.0 and net8.0-windows
  • Interface-first: IMediator for testability and mocking

Installation

dotnet add package ModernMediator

Optional packages:

dotnet add package ModernMediator.FluentValidation
dotnet add package ModernMediator.AspNetCore
dotnet add package ModernMediator.Audit.Serilog
dotnet add package ModernMediator.Audit.EntityFramework
dotnet add package ModernMediator.Idempotency.EntityFramework

Types Used in Examples

The examples throughout this README share a small set of domain types: a User entity, a UserDto projection, a GetUserQuery request, an OrderCreatedEvent, a generic Event, an IUserCache cache abstraction, an AppDbContext, and an AvaloniaDispatcher placeholder. They are collected here so each example can stay focused on the feature it illustrates. Skip ahead and refer back as needed.

// Domain entities
public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Email { get; set; } = "";

    public User() { }
    public User(string name, string email) { Name = name; Email = email; }
}

public record Order(int Id, decimal Total);

// Request/response DTOs
public record GetUserQuery(int UserId) : IRequest<UserDto>;
public record UserDto(int Id, string Name, string Email);

// Pub/sub events
public record OrderCreatedEvent(int OrderId, decimal Total);
public record Event(int Id);

// Cache abstraction used by handler and behavior examples
public interface IUserCache
{
    bool TryGet(int userId, out UserDto cached);
    void Set(int userId, UserDto user);
}

public sealed class UserCache : IUserCache
{
    private readonly Dictionary<int, UserDto> _store = new();
    public bool TryGet(int userId, out UserDto cached) => _store.TryGetValue(userId, out cached!);
    public void Set(int userId, UserDto user) => _store[userId] = user;
}

// EF Core context used by handler examples
public class AppDbContext : DbContext
{
    public DbSet<User> Users => Set<User>();
    public DbSet<Order> Orders => Set<Order>();

    public AppDbContext()
        : base(new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase("readme-examples").Options) { }
}

// Avalonia dispatcher placeholder. Avalonia is not a runtime dependency
// of ModernMediator. Copy the real implementation from
// docs/AvaloniaDispatcher.cs into your Avalonia project when wiring it up.
public sealed class AvaloniaDispatcher : IDispatcher
{
    public bool CheckAccess() => true;
    public void Invoke(Action action) => action();
    public Task InvokeAsync(Func<Task> func) => func();
}

Quick Start

IMediator is registered as Scoped by default, allowing handlers to resolve scoped dependencies like DbContext.

// Program.cs - with assembly scanning (uses reflection)
services.AddModernMediator(config =>
{
    config.RegisterServicesFromAssemblyContaining<Program>();
});

// Or use source-generated registration (AOT-compatible, no reflection)
services.AddModernMediatorGenerated();

// Or with configuration
services.AddModernMediator(config =>
{
    config.RegisterServicesFromAssemblyContaining<Program>();
    config.ErrorPolicy = ErrorPolicy.LogAndContinue;
    config.Configure(m => m.SetDispatcher(new WpfDispatcher()));
});

Setup without DI

// Singleton (shared instance) - ideal for Pub/Sub across the application
IMediator mediatorInstance = Mediator.Instance;

// Or create isolated instance
IMediator mediatorIsolated = Mediator.Create();

Source Generators

ModernMediator includes a source generator that discovers handlers at compile time and generates registration code. This provides:

  • Zero reflection at runtime: All handler discovery happens during compilation
  • Native AOT support: Works with ahead-of-time compilation
  • Faster startup: No assembly scanning at runtime
  • Compile-time diagnostics: Missing or duplicate handlers detected during build

Generated Code

The source generator creates two files:

ModernMediator.Generated.g.cs: DI registration without reflection:

// Auto-generated - use instead of assembly scanning
services.AddModernMediatorGenerated();

// With configuration (ErrorPolicy, CachingMode, Dispatcher)
services.AddModernMediatorGenerated(config =>
{
    config.ErrorPolicy = ErrorPolicy.LogAndContinue;
    config.CachingMode = CachingMode.Lazy;
    config.Configure(m => m.SetDispatcher(new WpfDispatcher()));
});

ModernMediator.SendExtensions.g.cs: Strongly-typed Send methods:

// Generated extension methods bypass reflection entirely
var user = await mediator.Send(new GetUserQuery(42)); // No reflection!

Diagnostics

Code Description
MM001 Duplicate handler: multiple handlers for same request
MM002 No handler found: request type has no registered handler
MM003 Abstract handler: handler class cannot be abstract
MM004 Handler return type mismatch: handler returns a type that does not match the request's declared response type
MM005 Notification handler has return value: type implements INotificationHandler but its Handle method returns
a value, which is likely unintentional
MM006 Open generic behavior may need explicit registration: open generic IPipelineBehavior<,> is not discovered
by assembly scanning; register it with AddOpenBehavior()
MM007 Handler has no matching request type: type implements IRequestHandler<TRequest, TResponse> but no request
type matching TRequest was found in the assembly
MM008 Lambda with weak reference subscription
MM009 Dispatcher overload mismatch: handler is registered under one dispatch interface but invoked via the other
(e.g. IValueTaskRequestHandler invoked via Send instead of SendAsync); the compile-time counterpart to
runtime MM201
MM010 Pre/post-processor will not run on ValueTask-handled request: a processor is registered for a request whose
only handler is IValueTaskRequestHandler; the ValueTask dispatch path does not execute pre- or post-
processors. Companion runtime log warning fires on the assembly-scanning registration path
MM100 ModernMediator registration generated: reports counts of generated handlers, stream handlers, behaviors,
pre-processors, and post-processors
MM200 Invalid HTTP method on [Endpoint] attribute
MM201 Dispatcher overload mismatch (runtime): handler is registered under one dispatch interface but the dispatcher
was called via the other (e.g. IValueTaskRequestHandler invoked via Send instead of SendAsync); thrown as
InvalidOperationException, not a Roslyn diagnostic
MM202 Generated handler not resolved (runtime): source-generated Send / CreateStream extensions could not resolve
a handler at dispatch time; thrown as InvalidOperationException, not a Roslyn diagnostic

CachingMode

Control when handler wrappers and lookups are initialized:

services.AddModernMediator(config =>
{
    // Eager (default) - initialize everything on first mediator access
    // Best for long-running applications where startup cost is amortized
    config.CachingMode = CachingMode.Eager;
    
    // Lazy - initialize handlers on-demand as messages are processed
    // Best for serverless, Native AOT, or cold start scenarios
    config.CachingMode = CachingMode.Lazy;
});

Usage

Request/Response

// GetUserQuery and UserDto are defined in "Types Used in Examples" above.

// Define handler
public class GetUserHandler(AppDbContext db) : IRequestHandler<GetUserQuery, UserDto>
{
    public async Task<UserDto> Handle(GetUserQuery request, CancellationToken ct = default)
    {
        var user = await db.Users.FindAsync(new object?[] { request.UserId }, ct);
        return new UserDto(user!.Id, user.Name, user.Email);
    }
}

// Send request
var user = await mediator.Send(new GetUserQuery(42));

ValueTask Handlers (Zero-Allocation Path)

For performance-critical paths, use IValueTaskRequestHandler with SendAsync to avoid the Task allocation:

// Define a ValueTask handler
public class GetCachedUserHandler(IUserCache cache, AppDbContext db)
    : IValueTaskRequestHandler<GetUserQuery, UserDto>
{
    public ValueTask<UserDto> Handle(GetUserQuery request, CancellationToken ct = default)
    {
        if (cache.TryGet(request.UserId, out var cached))
            return ValueTask.FromResult(cached);  // Zero allocation

        return new ValueTask<UserDto>(LoadFromDbAsync(request, ct));
    }

    private async Task<UserDto> LoadFromDbAsync(GetUserQuery request, CancellationToken ct)
    {
        var user = await db.Users.FindAsync(new object?[] { request.UserId }, ct);
        var dto = new UserDto(user!.Id, user.Name, user.Email);
        cache.Set(request.UserId, dto);
        return dto;
    }
}

// Dispatch via the zero-allocation path
var user = await sender.SendAsync<UserDto>(new GetUserQuery(42));

Commands (No Return Value)

// Define command
public record CreateUserCommand(string Name, string Email) : IRequest;

// Define handler
public class CreateUserHandler(AppDbContext db) : IRequestHandler<CreateUserCommand, Unit>
{
    public async Task<Unit> Handle(CreateUserCommand request, CancellationToken ct = default)
    {
        await db.Users.AddAsync(new User(request.Name, request.Email), ct);
        await db.SaveChangesAsync(ct);
        return Unit.Value;
    }
}

// Send command
await mediator.Send(new CreateUserCommand("John", "john@example.com"));

Streaming

// Define stream request
public record GetAllUsersRequest(int PageSize) : IStreamRequest<UserDto>;

// Define stream handler
public class GetAllUsersHandler(AppDbContext db)
    : IStreamRequestHandler<GetAllUsersRequest, UserDto>
{
    public async IAsyncEnumerable<UserDto> Handle(
        GetAllUsersRequest request,
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        await foreach (var user in db.Users.AsAsyncEnumerable().WithCancellation(ct))
        {
            yield return new UserDto(user.Id, user.Name, user.Email);
        }
    }
}

// Consume stream
await foreach (var user in mediator.CreateStream(new GetAllUsersRequest(100), ct))
{
    Console.WriteLine(user.Name);
}

Pipeline Behaviors

Pipeline behaviors wrap handler execution for cross-cutting concerns like logging, validation, and transactions.

Built-in Behaviors

ModernMediator ships behaviors for common cross-cutting concerns:

services.AddModernMediator(config =>
{
    config.RegisterServicesFromAssemblyContaining<Program>();

    // Built-in logging with configurable levels
    config.AddLogging();

    // Per-request timeout enforcement via [Timeout(ms)] attribute
    config.AddTimeout();

    // OpenTelemetry traces and metrics
    config.AddTelemetry();

    // Audit recording: pluggable IAuditWriter, opt out with [NoAudit]
    config.AddAudit<SerilogAuditWriter>();

    // Request deduplication: pluggable IIdempotencyStore, opt in with [Idempotent]
    config.AddIdempotency();

    // Per-request-type circuit breaker via [CircuitBreaker] attribute
    config.AddCircuitBreaker();

    // Automatic retry with configurable delay strategy via [Retry] attribute
    config.AddRetry();
});

// FluentValidation integration (separate package)
services.AddModernMediatorValidation();
Custom Open Generic Behaviors (Apply to All Requests)

Open generic behaviors must be registered explicitly with AddOpenBehavior():

// Transaction behavior that wraps all requests
public class TransactionBehavior<TRequest, TResponse>(AppDbContext db)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TRequest, TResponse> next,
        CancellationToken ct)
    {
        await using var tx = await db.Database.BeginTransactionAsync(ct);
        var response = await next(request, ct);
        await tx.CommitAsync(ct);
        return response;
    }
}

// Register open generic behaviors explicitly
services.AddModernMediator(config =>
{
    config.RegisterServicesFromAssemblyContaining<Program>();
    config.AddOpenBehavior(typeof(TransactionBehavior<,>));
});

Behaviors execute in registration order (first registered = outermost). For the built-in behaviors, register them in this sequence so each layer wraps the one inside it correctly:

Order Behavior Registration Reason
1 RetryBehavior AddRetry() Outermost: retries the entire inner pipeline
2 CircuitBreakerBehavior AddCircuitBreaker() Fails fast before attempting work
3 TimeoutBehavior AddTimeout() Enforces ceiling on each attempt
4 AuditBehavior AddAudit<SerilogAuditWriter>() Records outcome of each attempt
5 IdempotencyBehavior AddIdempotency() Short-circuits before validation if already handled
6 LoggingBehavior AddLogging() Logs the request entering the inner pipeline
7 ValidationBehavior AddModernMediatorValidation() Rejects invalid requests before handler
8 Handler (none) Executes the business logic

You are not required to register all behaviors, only the ones your application needs.

Note: Assembly scanning skips open generic types. Always use AddOpenBehavior() for behaviors that apply to all request types.

Closed Generic Behaviors (Apply to Specific Requests)

Behaviors for specific request types are discovered by assembly scanning:

// Behavior for a specific request type
public class GetUserCachingBehavior(IUserCache cache)
    : IPipelineBehavior<GetUserQuery, UserDto>
{
    public async Task<UserDto> Handle(
        GetUserQuery request,
        RequestHandlerDelegate<GetUserQuery, UserDto> next,
        CancellationToken ct)
    {
        if (cache.TryGet(request.UserId, out var cached))
            return cached;

        var result = await next(request, ct);
        cache.Set(request.UserId, result);
        return result;
    }
}

Result<T> Pattern

For operations that can fail without exceptions:

public record CreateOrderCommand(IReadOnlyList<int> Items) : IRequest<Result<OrderDto>>;
public record OrderDto(int Id, decimal Total);

public class CreateOrderHandler(AppDbContext db)
    : IRequestHandler<CreateOrderCommand, Result<OrderDto>>
{
    public async Task<Result<OrderDto>> Handle(CreateOrderCommand request, CancellationToken ct)
    {
        if (request.Items.Count == 0)
            return new ResultError("ORDER_EMPTY", "Order must have at least one item");

        var order = new Order(0, request.Items.Count * 9.99m);
        await db.Orders.AddAsync(order, ct);
        await db.SaveChangesAsync(ct);
        return new OrderDto(order.Id, order.Total);  // Implicit conversion
    }
}

// Consuming results
var items = new[] { 1, 2, 3 };
var fallback = new OrderDto(0, 0m);
var result = await mediator.Send(new CreateOrderCommand(items));
var dto = result.GetValueOrDefault(fallback);
var mapped = result.Map(order => order.Total);

ASP.NET Core Endpoint Generation

Generate Minimal API endpoints directly from request handlers:

[Endpoint("/api/users", "POST")]
public record CreateUserCommand(string Name, string Email) : IRequest<UserDto>;

// In Program.cs
app.MapMediatorEndpoints();

Pre/Post Processors

public interface IAuthorizationService { bool IsAuthorized(object request); }
public class UnauthorizedException : Exception { }

// Pre-processor runs before handler (closed-generic over a request type)
public class AuthorizationPreProcessor(IAuthorizationService auth)
    : IRequestPreProcessor<GetUserQuery>
{
    public Task Process(GetUserQuery request, CancellationToken ct)
    {
        if (!auth.IsAuthorized(request))
            throw new UnauthorizedException();
        return Task.CompletedTask;
    }
}

// Post-processor runs after handler (closed-generic over request and response types)
public class CachingPostProcessor(IUserCache cache)
    : IRequestPostProcessor<GetUserQuery, UserDto>
{
    public Task Process(GetUserQuery request, UserDto response, CancellationToken ct)
    {
        cache.Set(request.UserId, response);
        return Task.CompletedTask;
    }
}

// Register processors
services.AddModernMediator(config =>
{
    config.RegisterServicesFromAssemblyContaining<Program>();
    config.AddRequestPreProcessor<AuthorizationPreProcessor>();
    config.AddRequestPostProcessor<CachingPostProcessor>();
});

Pre- and post-processors run on the Send (Task) dispatch path only. They are not invoked on the SendAsync (ValueTask) path; a request handled by IValueTaskRequestHandler<TRequest, TResponse> dispatches through SendAsync and skips the processor stage. A processor wired against a ValueTask-only request is surfaced at compile time by the MM010 analyzer warning, and at startup by a logged warning on the assembly-scanning registration path. To compose with pre- or post-processors, register the request's handler as IRequestHandler<TRequest, TResponse> (Task) instead.

Exception Handlers

Exception handlers provide clean, typed exception handling separate from your business logic. They can return an alternate response or let the exception bubble up.

public class NotFoundException : Exception { }

// Define an exception handler for a specific exception type
public class NotFoundExceptionHandler
    : RequestExceptionHandler<GetUserQuery, UserDto, NotFoundException>
{
    protected override Task<ExceptionHandlingResult<UserDto>> Handle(
        GetUserQuery request,
        NotFoundException exception,
        CancellationToken ct)
    {
        // Return an alternate response
        return Handled(new UserDto(0, "Unknown User", ""));

        // Or let the exception bubble up
        // return NotHandled;
    }
}

// Register exception handlers
services.AddModernMediator(config =>
{
    config.RegisterServicesFromAssemblyContaining<Program>();
    config.AddExceptionHandler<NotFoundExceptionHandler>();
});

Exception handlers walk the exception type hierarchy, so a handler for Exception will catch all exceptions if no more specific handler is registered.

Pub/Sub (Notifications)

// OrderCreatedEvent is defined in "Types Used in Examples" above.

// Subscribe
mediator.Subscribe<OrderCreatedEvent>(e =>
    Console.WriteLine($"Order {e.OrderId} created: ${e.Total}"));

// Subscribe with filter
mediator.Subscribe<OrderCreatedEvent>(
    e => Console.WriteLine($"VIP order: {e.OrderId}"),
    filter: e => e.Total > 10000);

// Publish
mediator.Publish(new OrderCreatedEvent(123, 599.99m));

ModernMediator routes notifications along two delivery paths. The IMediator.Publish<T>(T) and PublishAsync<T>(T) overloads shown above invoke Subscribe<T> and SubscribeAsync<T> callbacks; the IPublisher.Publish<TNotification>(notification, ct) overload, shown in the next subsection, invokes DI-resolved INotificationHandler<TNotification> instances. The two paths are independent: a single publish call reaches one path, not both.

DI-Based Notifications

For CQRS-style notification handlers resolved from the DI container:

public interface IEmailService
{
    Task SendOrderConfirmation(int orderId, CancellationToken ct);
}

public record OrderCreatedNotification(int OrderId) : INotification;

public class SendOrderEmailHandler(IEmailService emailService)
    : INotificationHandler<OrderCreatedNotification>
{
    public Task Handle(OrderCreatedNotification notification, CancellationToken ct)
    {
        // Send confirmation email
        return emailService.SendOrderConfirmation(notification.OrderId, ct);
    }
}

// Publish through IPublisher (resolved from DI)
await publisher.Publish(new OrderCreatedNotification(123), ct);
Pub/Sub and DI Scoping

When using dependency injection, IMediator is registered as Scoped. This means Pub/Sub subscriptions are per-scope:

public record SomeEvent(int Id);

// Subscriptions on DI-injected IMediator are scoped to that request/scope
public class MyService
{
    public MyService(IMediator mediator)
    {
        // This subscription lives only as long as this scope
        mediator.Subscribe<SomeEvent>(e => HandleEvent(e));
    }

    private void HandleEvent(SomeEvent e) { /* handle event */ }
}

// For application-wide shared subscriptions, use the static singleton:
Mediator.Instance.Subscribe<OrderCreatedEvent>(e => GlobalHandler(e));

static void GlobalHandler(OrderCreatedEvent e) { /* handle globally */ }

Async Handlers

// Subscribe async
mediator.SubscribeAsync<OrderCreatedEvent>(async e =>
{
    await SaveToDatabase(e);
    await SendEmailNotification(e);
});

// Publish and await all handlers
await mediator.PublishAsyncTrue(new OrderCreatedEvent(123, 599.99m));

static Task SaveToDatabase(OrderCreatedEvent e) => Task.CompletedTask;
static Task SendEmailNotification(OrderCreatedEvent e) => Task.CompletedTask;

Pub/Sub with Callbacks

Collect responses from multiple subscribers, perfect for confirmation dialogs, validation, or aggregating data from multiple sources:

// Define message and response types
public record ConfirmationRequest(string Message);
public record ConfirmationResponse(bool Confirmed, string Source);

// Subscribe with a response
mediator.Subscribe<ConfirmationRequest, ConfirmationResponse>(
    request => new ConfirmationResponse(
        Confirmed: ShowDialog(request.Message),
        Source: "DialogService"),
    weak: false);

// Publish and collect all responses
var responses = mediator.Publish<ConfirmationRequest, ConfirmationResponse>(
    new ConfirmationRequest("Delete this item?"));

if (responses.Any(r => r.Confirmed))
{
    DeleteItem();
}

static bool ShowDialog(string message) => true;
static void DeleteItem() { /* delete the selected item */ }
Async Callbacks
public record ValidateRequest(string Input);
public record ValidationResult(bool IsValid, string? Error = null);

// Multiple async validators
mediator.SubscribeAsync<ValidateRequest, ValidationResult>(
    async request => await ValidateLengthAsync(request));

mediator.SubscribeAsync<ValidateRequest, ValidationResult>(
    async request => await ValidateFormatAsync(request));

// Publish and await all responses
var results = await mediator.PublishAsync<ValidateRequest, ValidationResult>(
    new ValidateRequest("user input"));

var errors = results.Where(r => !r.IsValid).ToList();

static Task<ValidationResult> ValidateLengthAsync(ValidateRequest request) =>
    Task.FromResult(new ValidationResult(request.Input.Length > 0));
static Task<ValidationResult> ValidateFormatAsync(ValidateRequest request) =>
    Task.FromResult(new ValidationResult(true));
Key Differences from Request/Response
Pattern Handlers Use Case
Send<TResponse> Exactly 1 CQRS commands/queries
Publish<TMsg, TResp> 0 to N Collect responses from multiple subscribers

Covariance (Polymorphic Dispatch)

public record AnimalEvent(string Name);
public record DogEvent(string Name, string Breed) : AnimalEvent(Name);
public record CatEvent(string Name, int LivesRemaining) : AnimalEvent(Name);

// This handler receives ALL animal events
mediator.Subscribe<AnimalEvent>(e => Console.WriteLine($"Animal: {e.Name}"));

// These are also delivered to the AnimalEvent handler
mediator.Publish(new DogEvent("Rex", "German Shepherd"));
mediator.Publish(new CatEvent("Whiskers", 9));

String Key Routing

public record OrderEvent(int OrderId, string Status);

// Subscribe to specific topics
mediator.Subscribe<OrderEvent>("orders.created", e => HandleNewOrder(e));
mediator.Subscribe<OrderEvent>("orders.shipped", e => HandleShippedOrder(e));
mediator.Subscribe<OrderEvent>("orders.cancelled", e => HandleCancelledOrder(e));

// Publish to specific topic
mediator.Publish("orders.created", new OrderEvent(123, "created"));

static void HandleNewOrder(OrderEvent e) { /* handle new order */ }
static void HandleShippedOrder(OrderEvent e) { /* handle shipped */ }
static void HandleCancelledOrder(OrderEvent e) { /* handle cancelled */ }

Weak vs Strong References

// 'handler' is any object that defines void OnEvent(Event e)
var handler = new EventHandlerExample();

// Weak reference (default) - handler can be GC'd
mediator.Subscribe<Event>(handler.OnEvent, weak: true);

// Strong reference - handler persists until unsubscribed
mediator.Subscribe<Event>(handler.OnEvent, weak: false);

class EventHandlerExample
{
    public void OnEvent(Event e) { /* handle event */ }
}

Note: weak: true is unreliable when the handler is an instance method on a value type (struct). The subscription tracks the handler's target via a weak reference, which holds a boxed copy of the struct. That box typically has no other strong reference and may be collected almost immediately after subscription, leaving the subscription effectively dead on arrival. For struct receivers, pass weak: false, or subscribe a class instance or a static method instead.

Unsubscribing

// Subscribe returns a disposable token
var subscription = mediator.Subscribe<Event>(OnEvent);

// Unsubscribe when done
subscription.Dispose();

// Or use with 'using' for scoped subscriptions
using (mediator.Subscribe<Event>(OnEvent))
{
    // Handler is active here
}
// Handler is automatically unsubscribed

static void OnEvent(Event e) { /* handle event */ }

UI Thread Dispatching

public record DataChangedEvent(int Id);

// Set dispatcher once at startup
mediator.SetDispatcher(new WpfDispatcher());                 // WPF

// WinForms requires a Control for UI-thread marshalling
var formControl = new System.Windows.Forms.Form();
mediator.SetDispatcher(new WinFormsDispatcher(formControl)); // WinForms
mediator.SetDispatcher(new MauiDispatcher());                // MAUI
mediator.SetDispatcher(new AvaloniaDispatcher());            // Avalonia (see below)

// Subscribe to receive on UI thread
mediator.SubscribeOnMainThread<DataChangedEvent>(e =>
    UpdateUI(e)); // Safe to update UI here

// Async version
mediator.SubscribeAsyncOnMainThread<DataChangedEvent>(async e =>
{
    await ProcessData(e);
    UpdateUI(e); // Safe to update UI
});

static void UpdateUI(DataChangedEvent e) { /* update UI */ }
static Task ProcessData(DataChangedEvent e) => Task.CompletedTask;
Avalonia Support

For Avalonia, copy the AvaloniaDispatcher.cs file into your project:

// In your Avalonia App.axaml.cs or startup
mediator.SetDispatcher(new AvaloniaDispatcher());

Note: The Avalonia dispatcher is community-tested. Please report any issues on GitHub.

Error Handling

// Set error policy
mediator.ErrorPolicy = ErrorPolicy.LogAndContinue;

// 'logger' here is any Microsoft.Extensions.Logging.ILogger instance
ILogger logger = NullLogger.Instance;

// Subscribe to errors
mediator.HandlerError += (sender, args) =>
{
    logger.LogError(args.Exception,
        "Handler error for {MessageType}",
        args.MessageType.Name);
};

Comparisons

ModernMediator vs MediatR

MediatR v12.x is the last open-source release under the Apache 2.0 license. MediatR v13+ requires a commercial license from Lucky Penny Software. The comparison below reflects capabilities as of MediatR 12.x.

Feature ModernMediator MediatR 12.x (Apache 2.0)
Request/Response ✅ Yes ✅ Yes
Notifications (Pub/Sub) ✅ Yes ✅ Yes
Pub/Sub with Callbacks ✅ Yes ❌ No
Pipeline Behaviors ✅ Yes ✅ Yes
Streaming ✅ Yes ✅ Yes
Assembly Scanning ✅ Yes ✅ Yes
Exception Handlers ✅ Yes ✅ Yes
Source Generators ✅ Yes ❌ No
Native AOT ✅ Yes ❌ No
ValueTask Pipeline ✅ SendAsync ❌ No
Result<T> Pattern ✅ Built-in ❌ No
Built-in Logging Behavior ✅ AddLogging() ❌ No
Built-in Timeout Behavior ✅ AddTimeout() ❌ No
Built-in Validation Behavior ✅ FluentValidation ❌ No
OpenTelemetry Integration ✅ AddTelemetry() ❌ No
Built-in Audit Behavior ✅ AddAudit() ❌ No
Built-in Idempotency Behavior ✅ AddIdempotency() ❌ No
Built-in Circuit Breaker ✅ AddCircuitBreaker() ❌ No
Built-in Retry Behavior ✅ AddRetry() ❌ No
Current User Accessor ✅ ICurrentUserAccessor ❌ No
Endpoint Generation ✅ [Endpoint] ❌ No
ISender/IPublisher/IStreamer ✅ Segregated ✅ ISender + IPublisher
Weak References ✅ Yes ❌ No
Runtime Subscribe/Unsubscribe ✅ Yes ❌ No
UI Thread Dispatch ✅ Built-in ❌ Manual
Contravariant Notifications ✅ Yes ✅ Yes
Predicate Filters ✅ Yes ❌ No
String Key Routing ✅ Yes ❌ No
Parallel Notifications ✅ Default ✅ Opt-in
Compile-time Diagnostics ✅ 12 rules ❌ No
License ✅ MIT Apache 2.0 (v13+ commercial)

Performance vs MediatR

ModernMediator allocates less memory than MediatR 12.4.1 on every benchmark. The SendAsync ValueTask path is nearly 2x faster with 80% fewer allocations. MediatR v13+ may have different performance characteristics. See 📊 Benchmarks for full three-way results including martinothamar/Mediator.

ModernMediator vs Prism EventAggregator

For desktop developers using Prism, ModernMediator can replace EventAggregator while adding MediatR-style request/response:

Feature Prism EventAggregator ModernMediator
Pub/Sub PubSubEvent<T> Publish<T> / Subscribe<T>
Pub/Sub w/ Callbacks ❌ Manual (callback in payload) Publish<TMsg, TResp>
Weak References keepSubscriberReferenceAlive: false (default) weak: true (default)
Strong References keepSubscriberReferenceAlive: true weak: false
Filter Subscriptions .Subscribe(handler, filter) filter: predicate
UI Thread ThreadOption.UIThread SubscribeOnMainThread
Background Thread ThreadOption.BackgroundThread ❌ Publish-side only (PublishAsync)
Unsubscribe SubscriptionToken IDisposable
Request/Response ❌ No Send<TResponse>
Pipeline Behaviors ❌ No ✅ Yes
Exception Handlers ❌ No ✅ Yes
Streaming ❌ No CreateStream
Source Generators ❌ No ✅ Yes
Native AOT ❌ No ✅ Yes

Bottom line: If you're using Prism EventAggregator for pub/sub AND MediatR for CQRS, ModernMediator replaces both with one library.

Samples

ModernMediator includes 14 cross-platform samples and a dotnet new template:

dotnet new modernmediator -n MyProject

Sample projects: Console (Basic, Domain, PubSub), WPF (Basic, PubSub), WinForms (Basic), MAUI (Basic, Validation), Avalonia (Basic, PubSub), Blazor (Server, WASM), Worker Service, WebApi, and WebApi.Advanced. The WinForms sample exercises WinFormsDispatcher for cross-thread notification dispatch from a background task to the UI thread.

Use Cases

Plugin Systems

ModernMediator excels at plugin architectures where plugins load/unload at runtime. Weak references prevent memory leaks when plugins unload. Runtime subscribe/unsubscribe enables dynamic registration. String key routing supports topic-based communication.

Desktop Applications (WPF, WinForms, MAUI)

Built-in UI thread dispatchers, memory-efficient weak references, easy decoupling of components. Replaces both EventAggregator and MediatR.

ASP.NET Core

Full DI integration with proper scoped service support. Request/response for CQRS patterns. Built-in pipeline behaviors for validation, logging, telemetry, and timeout. [Endpoint] attribute for Minimal API generation. Handlers can inject scoped services like DbContext.

Large Dataset Processing

Streaming with IAsyncEnumerable for memory efficiency. Cancellation support for long-running operations. Backpressure-friendly enumeration.

Serverless & Native AOT

Source generators eliminate reflection overhead. CachingMode.Lazy for fast cold start times. Full Native AOT compatibility. Compile-time handler discovery. ValueTask pipeline for minimal allocation overhead.

Known Limitations

  • In-process only: No distributed messaging. For microservices, combine with MassTransit or Wolverine for transport.
  • One handler per request: Request/Response expects exactly one handler per request type.
  • No generic request handlers: Each closed generic type needs its own handler.
  • Exception handlers for Request/Response only: Pub/Sub notifications use ErrorPolicy instead.
  • Pipeline behaviors don't wrap streaming: Behaviors wrap Send(), not CreateStream().
  • Weak references + lambdas: Closures capture this, which may prevent GC. Use method references or weak: false.
  • Behavior order = registration order: First registered behavior executes first (outermost).
  • Native AOT requires source generator: Use AddModernMediatorGenerated() instead of assembly scanning.
  • Open generics require explicit registration: Assembly scanning skips open generic behaviors; use AddOpenBehavior().
  • Scoped IMediator for DI: Pub/Sub subscriptions via DI are per-scope; use Mediator.Instance for shared subscriptions.

License

MIT License, see LICENSE file.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

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

NuGet packages (5)

Showing the top 5 NuGet packages that depend on ModernMediator:

Package Downloads
ModernMediator.FluentValidation

FluentValidation integration for ModernMediator. Adds ValidationBehavior pipeline behavior with ModernValidationException.

ModernMediator.AspNetCore

ASP.NET Core integration for ModernMediator. Compile-time Minimal API endpoint generation via [Endpoint] attribute.

ModernMediator.Idempotency.EntityFramework

Entity Framework Core idempotency store for ModernMediator. Provides durable exactly-once execution guarantees backed by a unique database constraint on the fingerprint column. See ADR-004.

ModernMediator.Audit.Serilog

Serilog audit writer for ModernMediator. Writes AuditRecord instances as structured log events via Serilog.

ModernMediator.Audit.EntityFramework

Entity Framework Core audit writer for ModernMediator. Persists AuditRecord instances to a dedicated AuditDbContext using a separate database context to avoid transactional coupling with application data. See ADR-003.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.2.1 225 5/24/2026
2.1.0 655 3/22/2026
2.0.0 431 3/8/2026
1.0.0 135 1/31/2026
0.2.2-alpha 592 1/8/2026
0.2.1-alpha 143 12/28/2025
0.2.0-alpha 203 12/24/2025
0.1.0-alpha 204 12/23/2025