ModernMediator
A Comprehensive Tutorial
Version 2.2Introduction
ModernMediator is a feature-rich mediator pattern library for .NET that facilitates decoupled communication between components in your applications. This tutorial will guide you through every major feature of the library, helping you understand when and how to use each capability.
By the end of this tutorial, you will understand:
- Request/Response pattern for commands and queries
- Pipeline Behaviors and Pre/Post Processors for cross-cutting concerns
- Streaming with IAsyncEnumerable for efficient data delivery
- Source Generators for Native AOT compatibility and compile-time safety
- Exception handling strategies and error policies
- Pub/Sub with Callbacks for multi-handler response aggregation
- Subscription options including weak/strong references, filters, and covariance
- UI Dispatchers for thread-safe updates across WPF, WinForms, MAUI, Avalonia, and ASP.NET Core
- Registration strategies including assembly scanning and manual DI registration
- Async-first design with true parallelism and cancellation support
Request/Response Pattern
Request/Response is the foundational pattern in ModernMediator. It models a simple interaction: you want something, and you expect an answer.
Request
A message object carrying the data needed to perform an operation.
Response
The result returned after the operation completes.
Mediator
The intermediary that routes the request to the appropriate handler.
How It Works
- Define a request class implementing
IRequest<TResponse> - Define a handler implementing
IRequestHandler<TRequest, TResponse> - The handler's
Handlemethod receives the request and returns the response - Call
mediator.Send<TResponse>(request)to execute
Benefits
| Benefit | Explanation |
|---|---|
| Decoupling | Caller doesn't know or care which class handles the request |
| Single Responsibility | Each handler does exactly one thing |
| Testability | Handlers are easy to mock and test in isolation |
| Cross-cutting concerns | Pipeline behaviors can wrap all requests |
Streaming
Streaming allows a handler to return results incrementally rather than all at once. Instead of waiting for an entire collection to be built and returned, the consumer receives items as they become available.
In .NET, this is powered by IAsyncEnumerable<T>, a feature that lets you await foreach over items as they're yielded, one at a time.
How It Works
- Define a streaming request implementing
IStreamRequest<TItem> - Define a handler implementing
IStreamRequestHandler<TRequest, TItem> - The handler's method uses
yield returnto emit items - Call
mediator.CreateStream<TItem>(request)to get theIAsyncEnumerable<TItem> - Consume with
await foreach
Benefits
| Benefit | Explanation |
|---|---|
| Memory efficiency | Only one item in memory at a time, not the entire collection |
| Responsiveness | Consumer sees first results immediately, doesn't wait for all |
| Cancellation | Can stop mid-stream without wasting work on remaining items |
| Natural fit | Ideal for database cursors, file processing, real-time feeds |
Key characteristic: Streaming is lazy, the handler doesn't run until the consumer starts iterating, and it pauses between yields while waiting for the consumer to request the next item. This creates natural backpressure.
Pub/Sub with Callbacks
In traditional Publish/Subscribe, a publisher broadcasts a message and multiple subscribers can receive it. It's fire-and-forget, the publisher doesn't know or care who handles the message, and no response comes back.
Pub/Sub with Callbacks extends this pattern by allowing subscribers to respond. The publisher still broadcasts to multiple subscribers, but now it can collect a response from each one.
This is a hybrid: the one-to-many broadcast of pub/sub combined with the response capability of request/response.
Pattern Comparison
| Pattern | Handlers | Response | Use Case |
|---|---|---|---|
| Request/Response | One | Single | Commands, queries |
| Pub/Sub (fire-forget) | Many | None | Events, notifications |
| Pub/Sub with Callbacks | Many | One per handler | Aggregation, voting, multi-source |
Common Use Cases
| Scenario | How Callbacks Help |
|---|---|
| Validation | Multiple validators each return pass/fail, aggregate results |
| Pricing | Multiple pricing strategies respond, choose best or combine |
| Search | Query multiple sources, merge results into unified response |
| Voting/consensus | Collect opinions from multiple handlers, tally them |
| Health checks | Each subsystem reports status, aggregate into overall health |
DI and Scoping: When using dependency injection, IMediator is scoped, Pub/Sub subscriptions are per-scope. For application-wide shared subscriptions, use Mediator.Instance (the static singleton).
Pipeline Behaviors
Pipeline Behaviors are middleware for your mediator. They wrap around the entire request/response flow, allowing you to execute logic both before and after the handler runs, all within a single class.
Think of them like layers of an onion: the request passes through each behavior on the way in, hits the handler at the center, then passes back through each behavior on the way out.
How It Works
- Implement
IPipelineBehavior<TRequest, TResponse> - The
Handlemethod receives the request and anextdelegate - Call
await next(request, cancellationToken)to continue the pipeline and get the response - Add logic before and/or after the
next(request, cancellationToken)call
Common Use Cases
- Logging: log before, log result after
- Validation: reject bad requests before they reach the handler
- Performance timing: start stopwatch before, stop after
- Transaction management: begin before, commit/rollback after
- Exception handling: wrap
next(request, cancellationToken)in try/catch
Key characteristic: Because you control when next(request, cancellationToken) is called, you can short-circuit the pipeline entirely, returning early without ever reaching the handler.
Open vs Closed Generics: Open generic behaviors like LoggingBehavior<,> that apply to all requests must be registered explicitly with AddOpenBehavior(). Assembly scanning only discovers closed generic (request-specific) behaviors.
Pre/Post Processors
Pre/Post Processors are a simpler, more focused alternative to full pipeline behaviors. Instead of wrapping the handler, they run strictly before or strictly after it.
| Processor Type | When It Runs | What It Sees |
|---|---|---|
| Pre-Processor | Before the handler | Request only |
| Post-Processor | After the handler | Request and Response |
When to Use Processors vs Behaviors
| Scenario | Use |
|---|---|
| Need logic both before AND after | Pipeline Behavior |
| Need to short-circuit or modify the response | Pipeline Behavior |
| Only need to run something before | Pre-Processor |
| Only need to run something after | Post-Processor |
| Want simpler, single-purpose classes | Processors |
Exception Handlers
Exception Handlers provide a centralized way to deal with exceptions that occur during request processing. Instead of wrapping every Send call in try/catch blocks throughout your application, you define exception handling logic once and it applies across your mediator pipeline.
How It Works
- Implement
IExceptionHandler<TRequest, TResponse, TException> - The handler receives the request, the exception, and can determine the outcome
- You can suppress the exception, rethrow it, return a fallback response, or transform it
Common Use Cases
| Use Case | What the Handler Does |
|---|---|
| Logging | Log exception details, then rethrow |
| Fallback responses | Return a default or cached value instead of failing |
| Exception translation | Convert internal exceptions to user-friendly ones |
| Retry logic | Attempt the operation again before giving up |
| Metrics | Record exception counts for monitoring, then rethrow |
ValueTask Pipeline
The ValueTask pipeline is a zero-allocation fast path for handlers that complete synchronously. When a handler returns a cached or pre-computed result, the entire pipeline avoids allocating a Task object on the heap.
Implement IValueTaskRequestHandler<TRequest, TResponse> instead of IRequestHandler, and call sender.SendAsync<TResponse>(request) instead of Send. The corresponding pipeline behavior interface is IValueTaskPipelineBehavior<TRequest, TResponse>.
Benefits
| Benefit | Explanation |
|---|---|
| Zero allocation | Synchronous completions avoid heap-allocating a Task |
| Lower latency | Benchmarked at 90ns / 72B vs 241ns / 216B for the Task path |
| Cache-friendly | Ideal for handlers returning cached or in-memory results |
| Full pipeline support | ValueTask behaviors compose identically to Task behaviors |
Built-in Behaviors
ModernMediator ships with four production-ready pipeline behaviors that cover the most common cross-cutting concerns. Each is registered via a single fluent method call on the configuration builder.
| Behavior | Registration | Purpose |
|---|---|---|
| LoggingBehavior | AddLogging() | Logs request type, duration, and optionally the payload before and after handling |
| TimeoutBehavior | AddTimeout() | Enforces per-request timeouts via [Timeout(ms)] attribute |
| ValidationBehavior | services.AddModernMediatorValidation(assembly) | Single call that scans for FluentValidation validators and registers ValidationBehavior<,> (requires ModernMediator.FluentValidation package) |
| TelemetryBehavior | AddTelemetry() | Emits OpenTelemetry traces and metrics for every request |
| AuditBehavior | AddAudit() | Records per-request audit entries (type, user, duration, outcome) dispatched to any IAuditWriter; opt out with [NoAudit] |
| IdempotencyBehavior | AddIdempotency() | Deduplicates requests marked [Idempotent] by key and TTL using any IIdempotencyStore |
| CircuitBreakerBehavior | AddCircuitBreaker() | Per-request-type circuit breaker via [CircuitBreaker] attribute; open circuit throws CircuitBreakerOpenException |
| RetryBehavior | AddRetry() | Automatic retry with configurable count and delay strategy (None, Fixed, Linear, Exponential) via [Retry] attribute |
Recommended Registration Order
Behaviors execute in registration order, first registered is outermost. Register built-in behaviors 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() | 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 | services.AddModernMediatorValidation(assembly) | Rejects invalid requests before handler |
| 8 | Handler | (none) | Executes the business logic |
Result<T> Pattern
Result<T> is a readonly struct that represents either a success value or an error, without throwing exceptions. It enables explicit error handling through return values instead of exceptions, making failure paths visible in method signatures.
Results support implicit conversions from both T (success) and ResultError (failure), so handlers can simply return value or return new ResultError("message"). The Map, GetValueOrDefault, and pattern-matching APIs make consuming results ergonomic.
Benefits
| Benefit | Explanation |
|---|---|
| No exceptions for expected failures | Validation errors, not-found, etc. are values, not exceptions |
| Explicit error paths | Callers must handle the error case: it cannot be accidentally ignored |
| Zero allocation | Readonly struct avoids heap allocation for the result wrapper itself |
| Composable | Map and Bind enable functional-style chaining |
Endpoint Generation
The [Endpoint] attribute on a request record tells the ModernMediator.AspNetCore source generator to emit a Minimal API endpoint at compile time. At startup, a single call to app.MapMediatorEndpoints() wires all generated endpoints into the ASP.NET Core routing pipeline.
The generator validates your endpoint declarations at build time. The MM200 diagnostic warns when an invalid HTTP method is specified, catching configuration errors before deployment. (MM200 is the AspNetCore endpoint generator's compile-time code; the unrelated runtime code [MM201] covers dispatcher overload mismatch and is documented in Dispatcher Overload Mismatch.)
Benefits
| Benefit | Explanation |
|---|---|
| Zero boilerplate | No manual app.MapPost / app.MapGet wiring per endpoint |
| Compile-time safety | Invalid HTTP methods are caught by MM200 diagnostic during build |
| Co-located definition | Route, method, and request type defined in one place |
| AOT compatible | Source-generated: no runtime reflection |
Telemetry
ModernMediator includes built-in OpenTelemetry support via MediatorTelemetry. An ActivitySource emits distributed traces for every request, and a Meter records RequestCounter and RequestDuration metrics. These integrate with any OpenTelemetry-compatible backend (Jaeger, Zipkin, Prometheus, Azure Monitor, etc.).
Enable telemetry by calling AddTelemetry() during registration. The TelemetryBehavior pipeline behavior wraps each request in an Activity span and records timing metrics automatically.
What Gets Recorded
| Signal | Name | Details |
|---|---|---|
| Trace | Activity span per request | Request type, duration, success/failure status |
| Metric | RequestCounter | Count of requests by type and outcome |
| Metric | RequestDuration | Histogram of request latency by type |
ISender / IPublisher / IStreamer
ModernMediator follows the Interface Segregation Principle by splitting capabilities into focused interfaces. Instead of depending on the full IMediator, components can depend only on the capability they need.
| Interface | Capability | Methods |
|---|---|---|
ISender | Request/Response | Send, SendAsync |
IPublisher | Pub/Sub | Publish |
IStreamer | Streaming | CreateStream |
IMediator | All of the above | Composes ISender + IPublisher + IStreamer |
Injecting the narrowest interface makes dependencies explicit and prevents misuse, a service that only sends commands cannot accidentally publish notifications.
ModernMediator routes notifications along two delivery paths: IPublisher.Publish invokes DI-resolved INotificationHandler<T> instances, while the sync IMediator.Publish<T> and async IMediator.PublishAsync<T> overloads invoke runtime Subscribe<T> and SubscribeAsync<T> callbacks. Both paths participate uniformly in the configured ErrorPolicy and observe the HandlerError event with the same HandlerErrorEventArgs shape, so choosing the narrowest interface for a given component does not cost the developer anything in the unified error-handling story.
Source Generators
Source Generators are a C# compiler feature that generates additional code at compile time. Instead of discovering handlers and wiring things up at runtime using reflection, the generator inspects your code during compilation and writes the registration code for you.
The result: your application starts with all mediator wiring already baked in.
Benefits
| Benefit | Explanation |
|---|---|
| Zero reflection | No runtime type scanning, no Assembly.GetTypes() calls |
| Faster startup | Registration is pre-computed, not discovered at runtime |
| Native AOT compatible | AOT compilation requires knowing all types ahead of time |
| Compile-time safety | Errors caught during build, not at runtime |
Compile-Time Diagnostics
ModernMediator's source generator doesn't just wire things up, it validates your code and reports problems as compiler warnings or errors.
| Diagnostic | Channel | Meaning |
|---|---|---|
| MM001 | Compile-time | Duplicate handler: multiple handlers for the same request type |
| MM002 | Compile-time | No handler found: request type has no registered handler |
| MM003 | Compile-time | Abstract handler: handler class cannot be abstract |
| MM004 | Compile-time | Handler in wrong assembly: handler not in scanned assembly |
| MM005 | Compile-time | Missing cancellation token: handler should accept CancellationToken |
| MM006 | Compile-time | Non-public handler: handler class is not public |
| MM007 | Compile-time | Handler implements multiple handler interfaces |
| MM008 | Compile-time | Lambda subscription with weak reference |
| MM009 | Compile-time | Dispatcher overload mismatch: Send called for a request whose handler is registered as IValueTaskRequestHandler, or vice versa. Detected by the analyzer in the same compilation |
| MM100 | Compile-time (info) | Source generator success: reports counts of generated handlers, behaviors, and processors |
| MM200 | Compile-time | Invalid HTTP method on [Endpoint] attribute (ASP.NET Core endpoint generator) |
| MM201 | Runtime | Dispatcher overload mismatch: InvalidOperationException with [MM201] prefix when the dispatcher detects the mismatch at runtime (cross-assembly safety net for MM009) |
| MM202 | Runtime | Generated dispatcher could not resolve a handler: InvalidOperationException with [MM202] prefix thrown by the source-generated Send / CreateStream extensions when IServiceProvider.GetService returns null for the expected IRequestHandler<,> or IStreamRequestHandler<,>. The message points at AddModernMediatorGenerated() registration as the corrective action |
Key characteristic: Traditional mediator libraries fail at runtime when a handler is missing. With compile-time diagnostics, you discover these problems before you even run the application, the build itself tells you something is wrong.
CachingMode (Eager vs Lazy)
CachingMode controls when ModernMediator resolves and caches handler instances. This affects both startup performance and first-request latency.
| Mode | When Handlers Are Resolved | Trade-off |
|---|---|---|
| Eager | At application startup | Slower startup, faster first request |
| Lazy | On first use of each handler | Faster startup, slower first request |
When to Use Each
| Scenario | Recommended Mode |
|---|---|
| Web API with predictable handler usage | Eager |
| Application with many rarely-used handlers | Lazy |
| Want startup validation of all dependencies | Eager |
| Serverless/fast cold-start requirements | Lazy |
| Desktop app where startup time is noticeable | Lazy |
Weak/Strong References
When you subscribe a handler to the mediator, the mediator must hold a reference to that handler so it can invoke it later. The type of reference determines whether the handler can be garbage collected.
| Reference Type | Behavior |
|---|---|
| Strong | Mediator keeps handler alive indefinitely |
| Weak | Handler can be garbage collected if nothing else references it |
In long-lived applications, especially UI applications, handlers are often tied to objects with shorter lifespans (views, view models, components). If the mediator holds strong references, those objects can never be garbage collected, causing memory leaks.
When to Use Each
| Scenario | Recommended |
|---|---|
| Handler is a long-lived singleton service | Strong |
| Handler is tied to a UI component's lifetime | Weak |
| Handler should live as long as the app | Strong |
| Handler is on a view that opens and closes | Weak |
Predicate Filters
Predicate filters allow a subscriber to conditionally receive messages based on the message content. Instead of receiving every published message of a given type, the handler only receives messages that pass its filter.
When subscribing, you provide a predicate function, a condition that evaluates the message and returns true or false. The handler is only invoked when the predicate returns true.
Example Scenarios
| Message Type | Predicate | Result |
|---|---|---|
| OrderPlaced | order => order.Total > 1000 | Only handles high-value orders |
| UserLoggedIn | user => user.IsAdmin | Only handles admin logins |
| SensorReading | reading => reading.Value > 100 | Only handles threshold breaches |
Key characteristic: Predicates are evaluated before invocation. A handler with a predicate that returns false is skipped entirely, it incurs no invocation overhead.
Covariance
Covariance allows a handler subscribed to a base type to receive messages of derived types. It's inheritance-aware message routing.
If you have a class hierarchy (Animal with Dog and Cat as subclasses), a handler subscribed to Animal will receive Dog messages, Cat messages, and Animal messages. The subscription covers the entire hierarchy below it.
Practical Examples
| Handler Subscribes To | Receives |
|---|---|
| Animal | Animal, Dog, Cat, any future subclasses |
| Dog | Dog only |
| Exception | Any exception type |
| EntityChanged | CustomerChanged, OrderChanged, etc. |
Covariance enables powerful patterns: a logging handler subscribed to a base event type logs all events; an audit handler for EntityChanged captures all entity modifications; a base exception handler catches anything more specific handlers miss.
UI Dispatchers
UI frameworks have a fundamental rule: only the main thread (the UI thread) can update the user interface. When work happens on a background thread, as it often does with async handlers, the results must be marshaled back to the UI thread before updating any controls.
Dispatchers automatically route handler responses back to the appropriate thread. Your handlers can run on any thread, but when they complete, the dispatcher ensures the result arrives on the UI thread, ready to bind to your views.
Platform-Specific Threading Models
| Platform | Threading Mechanism | UI Thread Concept |
|---|---|---|
| WPF | Dispatcher.Invoke | Single UI thread |
| WinForms | Control.Invoke / SynchronizationContext | Single UI thread |
| MAUI | MainThread.BeginInvokeOnMainThread | Single main thread |
| Avalonia | Dispatcher.UIThread.Invoke | Single UI thread |
| ASP.NET Core | No UI thread | Request context |
Benefits
| Without Dispatchers | With Dispatchers |
|---|---|
| Manual Dispatcher.Invoke calls everywhere | Automatic thread marshaling |
| Easy to forget and cause crashes | Consistent, safe by default |
| Handler code cluttered with UI concerns | Handlers stay pure and focused |
| Platform-specific code in handlers | Platform abstracted away |
Assembly Scanning and Manual DI Registration
Scoped by Default: IMediator is registered as Scoped, allowing handlers to resolve scoped dependencies like DbContext. For shared Pub/Sub subscriptions across the application, use Mediator.Instance (the static singleton).
Assembly Scanning
Assembly scanning is automatic discovery. At startup, ModernMediator scans your compiled assemblies, finds all classes implementing handler interfaces, and registers them with the dependency injection container automatically.
| Benefit | Explanation |
|---|---|
| Convenience | Add a handler class, it's automatically registered |
| No forgotten handlers | New handlers can't be accidentally left out of registration |
| Less boilerplate | No growing list of services.AddTransient<...>() calls |
| Refactoring safe | Rename or move handlers without updating registration code |
Manual DI Registration
Manual registration is explicit wiring. You write code that specifically registers each handler with the dependency injection container. Nothing is automatic, every handler appears in your configuration.
| Benefit | Explanation |
|---|---|
| Explicit | Configuration shows exactly what's registered |
| Full control | Different lifetimes, conditional registration, ordering |
| No surprises | Only intentionally registered handlers participate |
| Faster startup | No reflection-based scanning overhead |
Choosing Between Them
| Scenario | Recommended Approach |
|---|---|
| Rapid development, handlers change frequently | Assembly scanning |
| Small project with few handlers | Manual registration |
| Need different lifetimes per handler | Manual registration |
| Large team, handlers spread across projects | Assembly scanning |
| Native AOT deployment | Source generators (hybrid) |
| Want compile-time validation | Source generators (hybrid) |
Async-First Design
ModernMediator is built from the ground up for async operations. Async isn't an afterthought or an adapter over synchronous code, it's the primary execution model.
True Async Handlers
When you use SubscribeAsync, handlers are genuinely asynchronous. When multiple handlers exist for a notification, they execute concurrently using Task.WhenAll. The mediator awaits all handlers together, not one after another.
| Approach | 3 Handlers Taking 1 Second Each | Total Time |
|---|---|---|
| Sequential await | Await handler 1, then 2, then 3 | ~3 seconds |
| Task.WhenAll | Await all simultaneously | ~1 second |
Cancellation Support
All async operations in ModernMediator accept and respect CancellationToken. When a cancellation is requested, handlers can exit early and the mediator propagates the cancellation appropriately.
| Scenario | Without Cancellation | With Cancellation |
|---|---|---|
| User navigates away mid-request | Handler keeps running, wastes resources | Handler exits cleanly |
| HTTP request times out | Background work continues | Work stops promptly |
| Application shutdown | Handlers may hang shutdown | Handlers cooperate with shutdown |
Parallel Execution
When a notification is published to multiple subscribers, handlers execute concurrently rather than sequentially. This maximizes throughput for independent operations.
Error Handling
Beyond IExceptionHandler for custom exception handling logic, ModernMediator also provides policies and events for broader error management.
Three Error Policies
Error policies define how the mediator behaves when a handler throws an exception, especially in multi-handler scenarios like pub/sub.
| Policy | Behavior |
|---|---|
| ContinueAndAggregate | Execute all handlers, collect all exceptions, throw as aggregate |
| StopOnFirstError | Stop immediately when any handler throws, skip remaining |
| LogAndContinue | Log exceptions, continue executing remaining handlers, don't throw |
HandlerError Event
HandlerError is a universal observation channel. It fires for every handler exception under every ErrorPolicy, regardless of which dispatch path produced the exception. The policy governs propagation and aggregation; the event governs observation. The same subscription receives events from handlers registered as INotificationHandler<T> and resolved via DI, and from runtime callbacks registered via Subscribe<T> or SubscribeAsync<T>.
Subscribers receive a HandlerErrorEventArgs with the following properties:
| Property | Description |
|---|---|
Exception | The unwrapped handler exception |
Message | The published notification |
MessageType | The runtime type of the notification |
HandlerType | The concrete handler type. On the DI-resolved path, the resolved handler class. On the Subscribe-callback path, Method.DeclaringType with compiler-generated closure types unwrapped to the enclosing user type. (v2.2) |
HandlerInstance | The resolved DI handler instance on the DI-resolved path, or Delegate.Target on the Subscribe-callback path. Null for static delegate subscriptions. (v2.2) |
Cooperative cancellation is treated as distinct from a handler fault. When the publish token's IsCancellationRequested is true and a handler throws OperationCanceledException, the event does not fire and the policy does not apply. The exception propagates from Publish unconditionally.
For consumers who want to route contained subscriber exceptions to a specific observability system rather than the default ILogger route, register an ISubscriberExceptionSink implementation in the service collection. The sink receives the same HandlerErrorEventArgs the HandlerError event fires with, but routes through DI rather than an event subscription, so the implementation is registered once at composition time and can take any constructor dependencies the container can resolve. (v2.2)
Exception Unwrapping
When handlers are invoked through reflection or dynamic dispatch, exceptions get wrapped in TargetInvocationException or AggregateException. Stack traces become cluttered with framework noise, making debugging harder.
Exceptions are automatically unwrapped before being thrown or passed to exception handlers. You see the original exception with a clean stack trace pointing directly to your code.
Dispatcher Overload Mismatch (MM009 / MM201)
A request handler implements either IRequestHandler<TRequest, TResponse> (Task-based) or IValueTaskRequestHandler<TRequest, TResponse> (ValueTask-based). The dispatcher exposes a parallel pair of methods: Send for Task handlers and SendAsync for ValueTask handlers. Calling the wrong method for a given handler is the dispatcher overload mismatch, and v2.2 surfaces it through two complementary channels.
MM009 (compile-time) is a Roslyn analyzer warning emitted by ModernMediator.Generators. The analyzer walks Send / SendAsync invocations, recovers the request type from the argument, and looks it up against a per-compilation registry of handler declarations. When the consumer's call site does not match the handler's interface, the analyzer surfaces the warning at the invocation in the IDE error list, before the runtime check ever fires.
MM201 (runtime) is an InvalidOperationException with a bracketed prefix, thrown by the dispatcher when no handler is registered for the requested interface but a handler exists under the alternate interface. The runtime check covers the cross-assembly case the analyzer cannot see: a handler registered in a precompiled NuGet dependency that the consumer's compilation does not contain a syntactic declaration for.
Both codes describe the same condition; the codes are intentionally distinct because the channels are distinct. MM009 in build output points to the source-gen detector; [MM201] in an exception message points to the runtime check. Consumers who want stricter enforcement can promote MM009 to error via <WarningsAsErrors> or an .editorconfig severity override.
MM202 (runtime) is the second occupant of the MM2xx slot established by ADR-008. It surfaces from a different path than MM201: the source-generator-emitted Send and CreateStream extension methods throw an InvalidOperationException with the [MM202] prefix when IServiceProvider.GetService returns null for the expected IRequestHandler<,> or IStreamRequestHandler<,>. The message points at AddModernMediatorGenerated() as the corrective action, typically the symptom of a missing call to the generated registration helper after assembly scanning was disabled or replaced.
Example
Given a handler registered as IValueTaskRequestHandler:
public sealed record GetUserQuery(int Id) : IRequest<User>;
public sealed class GetUserHandler : IValueTaskRequestHandler<GetUserQuery, User>
{
public ValueTask<User> Handle(GetUserQuery request, CancellationToken ct) => /* ... */;
}
The wrong call site triggers MM009 at compile time:
// MM009: GetUserQuery is registered as IValueTaskRequestHandler;
// call SendAsync instead of Send.
var user = await mediator.Send(new GetUserQuery(42));
If the same condition reaches runtime (for example, the handler lives in a precompiled assembly the analyzer cannot inspect), MM201 fires:
// InvalidOperationException:
// [MM201] No IRequestHandler<GetUserQuery, User> is registered, but
// IValueTaskRequestHandler<GetUserQuery, User> is. Call SendAsync instead of Send.
var user = await mediator.Send(new GetUserQuery(42));
See ADR-009 (runtime check) and ADR-010 (source-gen analyzer) for the design rationale and the relationship between the two codes.
Validation Overloads
v2.2 expands AddModernMediatorValidation with overloads that accept either a single Assembly or a marker type via typeof(T).Assembly, matching the registration ergonomics of RegisterServicesFromAssembly / RegisterServicesFromAssemblyContaining. The single call scans the supplied assembly for FluentValidation validators, registers them in the container, and wires ValidationBehavior<TRequest, TResponse> into the pipeline. Validators are resolved per-request and run before the handler executes; failures short-circuit with a ValidationException (or, when the request returns a Result<T>, a failure result that carries the validation errors).
The overload set lets a consumer register validators from the executing assembly with one line:
services.AddModernMediatorValidation(typeof(Program).Assembly);
Or from a marker type when validators live in a separate project:
services.AddModernMediatorValidation(typeof(MyValidatorMarker).Assembly);
The behavior package (ModernMediator.FluentValidation) is a separate NuGet reference; the core ModernMediator package does not pull in FluentValidation by default.
Audit Behavior
AuditBehavior captures a structured record of every request that passes through the pipeline, including the request type, current user identity, correlation ID, duration, and whether the request succeeded or failed. Records are dispatched asynchronously to any IAuditWriter implementation, so writing to Serilog, a database, or a custom sink requires no changes to the behavior itself.
Individual request types can opt out of auditing by decorating the request record with [NoAudit]. Audit drain is handled by a hosted background service registered automatically by AddAudit<TWriter>(), which processes records from an in-memory channel without blocking the request pipeline.
What Gets Recorded
| Field | Description |
|---|---|
| RequestTypeName | Full name of the request type |
| SerializedPayload | JSON-serialized request payload for diagnostic purposes |
| UserId | Resolved from ICurrentUserAccessor; null if not configured |
| UserName | User display name resolved via ICurrentUserAccessor (nullable string) |
| Timestamp | DateTimeOffset when the audit record was created |
| Succeeded | True if handler returned without exception |
| FailureReason | Exception message if the request failed; null on success |
| Duration | TimeSpan from pipeline entry to handler return (stored as ticks) |
| CorrelationId | Optional correlation identifier for request tracing |
| TraceId | Activity.Current.TraceId.ToString() if available (nullable string) |
Available Writers
| Package | Writer | Notes |
|---|---|---|
| ModernMediator (core) | Custom IAuditWriter | Implement the interface directly |
| ModernMediator.Audit.Serilog | SerilogAuditWriter | Logs at Information on success, Warning on failure |
| ModernMediator.Audit.EntityFramework | EfCoreAuditWriter | Persists to any EF Core-supported database |
Idempotency Behavior
IdempotencyBehavior prevents duplicate processing of requests that have already been handled. A request marked with [Idempotent] is identified by a fingerprint derived from the request content and type. If a matching fingerprint exists in the configured IIdempotencyStore and has not expired, the cached response is returned immediately, the handler never runs.
This is essential for operations that must not be applied twice: payments, order submissions, inventory adjustments, or any command where retries from network failures could cause duplicate side effects.
Store Options
| Package | Store | Notes |
|---|---|---|
| ModernMediator (core) | InMemoryIdempotencyStore | In-process; does not survive restarts |
| ModernMediator (core) | DistributedIdempotencyStore | Backed by IDistributedCache (Redis, SQL Server, etc.) |
| ModernMediator.Idempotency.EntityFramework | EfCoreIdempotencyStore | Persists to any EF Core-supported database with TTL expiry |
Circuit Breaker Behavior
CircuitBreakerBehavior protects your application from repeatedly calling a handler that is known to be failing. When a request type decorated with [CircuitBreaker] accumulates enough consecutive failures, the circuit opens. While open, subsequent requests for that type are rejected immediately with CircuitBreakerOpenException, no handler invocation occurs.
After a configured timeout, the circuit moves to half-open and allows a single probe request through. If it succeeds, the circuit closes and normal operation resumes. If it fails, the circuit reopens.
Circuit States
| State | Behavior |
|---|---|
| Closed | Normal operation: requests pass through to the handler |
| Open | Requests rejected immediately with CircuitBreakerOpenException |
| Half-Open | One probe request allowed through; result determines next state |
Per-request-type isolation: Each request type has its own independent circuit. A failing GetExternalDataQuery does not affect the circuit for CreateOrderCommand.
Retry Behavior
RetryBehavior automatically retries a failing handler a configurable number of times before allowing the exception to propagate. The delay between attempts is controlled by a RetryDelayStrategy, allowing you to implement immediate retry, fixed delay, linear backoff, or exponential backoff depending on the failure characteristics of the handler.
Apply the [Retry] attribute to any request type to opt in. Requests without the attribute are unaffected.
Delay Strategies
| Strategy | Delay Pattern | Best For |
|---|---|---|
| None | Immediate retry, no delay | Transient in-memory failures |
| Fixed | Same delay between every attempt | Rate-limited APIs with known reset intervals |
| Linear | Delay increases by a fixed amount each attempt | Gradually backing off under load |
| Exponential | Delay doubles each attempt | Distributed systems, external services |
Current User Accessor
ICurrentUserAccessor is an abstraction that pipeline behaviors use to resolve the identity of the current user without depending on a specific platform or authentication mechanism. The AuditBehavior uses it to populate the UserId field on every audit record.
HttpContextCurrentUserAccessor (in ModernMediator.AspNetCore) is the built-in implementation for ASP.NET Core. It resolves UserId from the NameIdentifier claim and UserName from the Name claim via IHttpContextAccessor. It is auto-registered when you call config.AddAudit<TWriter>(o => o.UseHttpContextIdentity(services)). For applications that do not use the audit pipeline but still want the accessor available, register it manually via services.AddSingleton<ICurrentUserAccessor, HttpContextCurrentUserAccessor>().
For non-ASP.NET Core applications, implement ICurrentUserAccessor directly and register it with the DI container before calling AddAudit().
| Property | Source Claim | Returns |
|---|---|---|
| UserId | ClaimTypes.NameIdentifier | Claim value or null if absent / no HttpContext |
| UserName | ClaimTypes.Name | Claim value or null if absent / no HttpContext |
Summary
ModernMediator provides a comprehensive set of features for building decoupled, maintainable applications in .NET. This tutorial has covered the core capabilities that make the library powerful and flexible.
Features Covered
- Request/Response Pattern: The foundational one-to-one message handling pattern for commands and queries
- Pipeline Behaviors: Middleware that wraps handler execution for cross-cutting concerns
- Pre/Post Processors: Simpler alternatives to behaviors when you only need before or after logic
- Streaming: Memory-efficient, responsive data delivery using IAsyncEnumerable
- Source Generators: Compile-time handler discovery for Native AOT compatibility
- Exception Handlers: Centralized exception handling without scattered try/catch blocks
- CachingMode: Control over handler resolution timing with Eager and Lazy options
- Pub/Sub with Callbacks: Multi-handler broadcasting with response aggregation
- Weak/Strong References: Memory management control for subscription lifecycles
- Predicate Filters: Conditional message reception based on content
- Covariance: Inheritance-aware message routing for flexible subscriptions
- UI Dispatchers: Thread-safe response delivery across WPF, WinForms, MAUI, Avalonia, and ASP.NET Core
- Assembly Scanning and Manual DI Registration: Flexible handler registration strategies
- Async-First Design: True parallelism with Task.WhenAll and full cancellation support
- Error Handling: Configurable policies, centralized events, and clean exception unwrapping
- ValueTask Pipeline: Zero-allocation fast path via IValueTaskRequestHandler and SendAsync
- Built-in Behaviors: Production-ready Logging, Timeout, Validation, and Telemetry behaviors
- Result<T> Pattern: Readonly struct for explicit success/error handling without exceptions
- Endpoint Generation: Source-generated Minimal API endpoints via [Endpoint] attribute
- Telemetry: OpenTelemetry traces and metrics with ActivitySource and Meter
- Interface Segregation: ISender, IPublisher, and IStreamer for focused dependencies
- Audit Behavior: per-request audit recording dispatched to any IAuditWriter; Serilog and EF Core writers available as separate packages
- Idempotency Behavior: duplicate request prevention via [Idempotent] attribute with pluggable store; in-memory, distributed cache, and EF Core stores included
- Circuit Breaker Behavior: per-request-type circuit breaker via [CircuitBreaker] attribute with closed/open/half-open state management
- Retry Behavior: automatic retry with configurable count and delay strategy (None, Fixed, Linear, Exponential) via [Retry] attribute
- Current User Accessor: ICurrentUserAccessor abstraction with HttpContextCurrentUserAccessor for ASP.NET Core claim resolution
ModernMediator combines these features into a cohesive library that supports applications from simple desktop tools to complex distributed systems, with first-class support for modern .NET capabilities including Native AOT compilation.
For more information, visit: https://github.com/EvanscoApps/ModernMediator