ModernMediator
A Comprehensive Tutorial
Version 0.2.2-alphaIntroduction
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()to continue the pipeline and get the response - Add logic before and/or after the
next()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()in try/catch
Key characteristic: Because you control when next() 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 |
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
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