ModernMediator 2.2.1
dotnet add package ModernMediator --version 2.2.1
NuGet\Install-Package ModernMediator -Version 2.2.1
<PackageReference Include="ModernMediator" Version="2.2.1" />
<PackageVersion Include="ModernMediator" Version="2.2.1" />
<PackageReference Include="ModernMediator" />
paket add ModernMediator --version 2.2.1
#r "nuget: ModernMediator, 2.2.1"
#:package ModernMediator@2.2.1
#addin nuget:?package=ModernMediator&version=2.2.1
#tool nuget:?package=ModernMediator&version=2.2.1
ModernMediator
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:
IAsyncEnumerablesupport 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 structwith implicit conversions,Map, andGetValueOrDefaultfor 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 andAddTimeout() - 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 anyIIdempotencyStore; registered viaAddIdempotency() - Built-in CircuitBreakerBehavior: per-request-type circuit breaker via
[CircuitBreaker]attribute; open circuit throwsCircuitBreakerOpenException; registered viaAddCircuitBreaker() - 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 viaRetryOptions; registered viaAddRetry()
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 withMapMediatorEndpoints()for Minimal API integration
Performance
- ValueTask Pipeline:
IValueTaskRequestHandlerandISender.SendAsyncfor 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:
ActivitySourceandMeterwithRequestCounterandRequestDuration. Metric emission is opt-in viaAddTelemetry()and gated at runtime byTelemetryOptions.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(inModernMediator.AspNetCore) resolvesUserIdandUserNamefromIHttpContextAccessor
Async-First Design
- True Async Handlers:
SubscribeAsyncwith properTask.WhenAllaggregation - 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.0andnet8.0-windows - Interface-first:
IMediatorfor 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
Setup with Dependency Injection (Recommended)
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<,>));
});
Recommended Registration Order
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
ErrorPolicyinstead. - Pipeline behaviors don't wrap streaming: Behaviors wrap
Send(), notCreateStream(). - Weak references + lambdas: Closures capture
this, which may prevent GC. Use method references orweak: 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.Instancefor shared subscriptions.
License
MIT License, see LICENSE file.
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
| Product | Versions 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. |
-
net8.0
- Microsoft.Extensions.Caching.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Caching.Memory (>= 8.0.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Hosting.Abstractions (>= 8.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Polly.Core (>= 8.6.0)
-
net8.0-windows7.0
- Microsoft.Extensions.Caching.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Caching.Memory (>= 8.0.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Hosting.Abstractions (>= 8.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Polly.Core (>= 8.6.0)
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 |