ModernMediator
A Comprehensive Tutorial
Version 2.0.0Introduction
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 | AddOpenBehavior(typeof(ValidationBehavior<,>)) | Runs FluentValidation validators before the handler (requires ModernMediator.FluentValidation package) |
| TelemetryBehavior | AddTelemetry() | Emits OpenTelemetry traces and metrics for every request |
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.
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, Subscribe |
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.
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 | Meaning | Severity |
|---|---|---|
| MM001 | Request has no handler registered | Warning |
| MM002 | Request has multiple handlers (ambiguous) | Error |
| MM003 | Handler implementation is invalid (signature issues) | Error |
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
A hook that fires whenever a handler throws an exception, regardless of the error policy. This provides a centralized point for logging and monitoring without requiring custom exception handlers.
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.
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
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