A zero-reflection mediator for .NET powered by C# source generators. DirectMediator generates all dispatcher and publisher code at compile time, so there is no runtime reflection, no dictionary lookups, and no dynamic dispatch — just direct, strongly-typed method calls.
- ⚡ Zero reflection — all routing is generated at compile time
- 🔒 Compile-time safety — errors for duplicate or missing handlers are reported as build errors
- 💉 DI-first — single
AddDirectMediator()call registers every handler and dispatcher - 📦 Lightweight — core depends only on
Microsoft.Extensions.*abstractions (DependencyInjection,Logging.Abstractions,Caching.Abstractions); enabling built-in behaviors may require additional implementation packages (e.g., logging providers,Microsoft.Extensions.Caching.Memory+AddMemoryCache()) - 🔀 CQRS-ready — first-class support for Commands, Queries, and Notifications
- 🎯 Unified interface — single
IMediatorcombining Send and Publish for easy injection and mocking - 🔗 Compile-time pipeline —
IPipelineBehavior<TRequest, TResponse>chains are built once at construction (no per-dispatch reflection or service location) - 📋 Built-in behaviors — opt-in
LoggingBehavior,PerformanceBehavior,CachingBehavior,ValidationBehavior, andCorrelationIdBehaviorready to use - 💾 Response caching — implement
ICacheableRequest<TResponse>on any request (command or query) to get automatic in-memory caching with a per-request configurable TTL - ✅ Request validation — integrate FluentValidation via
AddDirectMediatorValidation(); validators run before the handler and throwFluentValidation.ValidationExceptionon failure - 🔍 Correlation ID — automatic correlation ID generation for distributed tracing via
AddDirectMediatorCorrelationId()andICorrelationContext
DirectMediator uses an Incremental Source Generator (DirectMediator.Generator) to inspect your project at build time. It:
- Discovers every class that implements
IRequestHandler<TRequest, TResponse>orINotificationHandler<TNotification>. - Emits compile-time diagnostics if handlers are duplicated or missing.
- Generates dispatchers (
CommandDispatcher,QueryDispatcher,NotificationPublisher), a unifiedMediator, and anAddDirectMediator()extension method — all inside theDirectMediator.Generatednamespace.
Because the routing code is generated as plain C# switch expressions, the JIT can inline and optimize it just like hand-written code.
Add the DirectMediator NuGet package to your project:
<PackageReference Include="DirectMediator" Version="1.0.2" />Or via the .NET CLI:
dotnet add package DirectMediatorA command performs a side-effectful operation and returns no meaningful value (Unit).
using DirectMediator;
public record CreateOrderCommand(string Product) : ICommand;using DirectMediator;
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Unit>
{
public Task<Unit> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{
Console.WriteLine($"Order created: {request.Product}");
return Task.FromResult(Unit.Value);
}
}A query reads data and returns a typed result.
using DirectMediator;
public record GetOrderQuery(int Id) : IQuery<string>;using DirectMediator;
public class GetOrderHandler : IRequestHandler<GetOrderQuery, string>
{
public Task<string> Handle(GetOrderQuery request, CancellationToken cancellationToken)
{
return Task.FromResult($"Order #{request.Id}");
}
}A notification broadcasts an event to multiple handlers.
using DirectMediator;
public record OrderCreatedNotification(string Product) : INotification;using DirectMediator;
public class OrderCreatedHandler : INotificationHandler<OrderCreatedNotification>
{
public Task Handle(OrderCreatedNotification notification, CancellationToken cancellationToken)
{
Console.WriteLine($"Notification received: {notification.Product}");
return Task.CompletedTask;
}
}Call AddDirectMediator() once during startup. The source generator automatically includes every handler it discovers.
using DirectMediator;
using DirectMediator.Generated;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
// Registers all handlers, CommandDispatcher, QueryDispatcher, NotificationPublisher, and IMediator
services.AddDirectMediator();
var provider = services.BuildServiceProvider();
// --- Recommended: inject the unified IMediator interface ---
var mediator = provider.GetRequiredService<IMediator>();
// Send a command (returns Unit)
await mediator.Send(new CreateOrderCommand("Tile"));
// Execute a query (returns the typed response)
string result = await mediator.Send(new GetOrderQuery(123));
Console.WriteLine($"Query result: {result}");
// Publish a notification
await mediator.Publish(new OrderCreatedNotification("Tile"));The individual dispatchers are still available if you need to inject only part of the API:
var commandDispatcher = provider.GetRequiredService<CommandDispatcher>();
var queryDispatcher = provider.GetRequiredService<QueryDispatcher>();
var publisher = provider.GetRequiredService<NotificationPublisher>();A command represents an intention to change state. It implements ICommand, which in turn extends IRequest<Unit>. Commands produce no return value — the Unit type serves as a stand-in for void.
ICommand ──extends──▶ IRequest<Unit>
Commands are dispatched through ICommandDispatcher.Send<TCommand>(command, cancellationToken).
A query retrieves data without modifying state. It implements IQuery<TResponse>, which extends IRequest<TResponse>.
IQuery<TResponse> ──extends──▶ IRequest<TResponse>
Queries are dispatched through the generated QueryDispatcher.Query(query, cancellationToken) method, which is typed to the specific request/response pair.
A notification broadcasts an event to zero or more handlers. It implements INotification. Multiple handlers for the same notification type are all invoked in sequence.
Notifications are published through INotificationPublisher.Publish<TNotification>(notification, cancellationToken).
| Interface | Purpose |
|---|---|
IRequestHandler<TRequest, TResponse> |
Handles both commands (TResponse = Unit) and queries |
INotificationHandler<TNotification> |
Handles a specific notification type |
Unit is a value type used as the return type for commands (equivalent to void). Access the singleton value via Unit.Value.
IMediator is the unified injectable abstraction. It combines command dispatch, query dispatch, and notification publishing into a single interface, which is especially useful for:
- Controller / service injection — inject one interface instead of three
- Unit testing — mock only
IMediatorrather than multiple dispatchers
public interface IMediator : INotificationPublisher
{
Task<TResponse> Send<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken = default);
}Commands produce a Unit result; queries produce their typed response. Both go through the same Send method.
// In a controller or service:
public class OrdersController
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator) => _mediator = mediator;
public async Task Create(string product)
=> await _mediator.Send(new CreateOrderCommand(product));
public async Task<string> Get(int id)
=> await _mediator.Send(new GetOrderQuery(id));
}Pipeline behaviors let you add cross-cutting concerns that wrap every request passing through the mediator — logging, validation, caching, exception handling, and more. They are inspired by ASP.NET Core middleware.
The pipeline chain is built once at dispatcher construction time as a delegate chain. There is no per-dispatch reflection or GetServices call; dispatching is a direct delegate invocation.
Implement IPipelineBehavior<TRequest, TResponse> and register it with DI:
using DirectMediator;
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> Handle(
TRequest request,
CancellationToken cancellationToken,
RequestHandlerDelegate<TResponse> next)
{
// pre-processing (e.g., validate request)
var response = await next();
// post-processing
return response;
}
}Register with DI (open-generic covers all request types):
services.AddDirectMediator();
// ⚠️ Behaviors must be registered as singletons — the pipeline is built once at
// dispatcher construction time, so behaviors live for the lifetime of the singleton.
services.AddSingleton(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));Multiple behaviors execute in registration order (first registered = outermost wrapper).
DirectMediator ships six ready-to-use behaviors in the DirectMediator.Abstractions package:
| Behavior | Description |
|---|---|
LoggingBehavior<TRequest,TResponse> |
Logs Handling / Handled messages and errors via ILogger<TRequest> |
PerformanceBehavior<TRequest,TResponse> |
Logs a warning when a request exceeds a configurable threshold (default: 500 ms) |
CachingBehavior<TRequest,TResponse> |
Caches responses in IMemoryCache for requests that implement ICacheableRequest<TResponse>; non-cacheable requests pass through unchanged |
ValidationBehavior<TRequest,TResponse> |
Runs all registered IValidator<TRequest> instances before the handler; throws FluentValidation.ValidationException if any rule fails; requests with no validators pass through unchanged |
CorrelationIdBehavior<TRequest,TResponse> |
Assigns a unique correlation ID (GUID) to each request for distributed tracing; the ID is available via ICorrelationContext after the request completes |
RetryBehavior<TRequest,TResponse> |
Automatically retries failed requests with configurable retry policies, exponential backoff, and jitter; supports custom exception filtering and callbacks |
TelemetryBehavior<TRequest,TResponse> |
Instruments requests with OpenTelemetry tracing and metrics; tracks request duration, success/failure rates, and integrates with ActivitySource |
Opt-in with the provided extension methods:
services.AddMemoryCache(); // required if using AddDirectMediatorCaching()
services.AddDirectMediator()
.AddDirectMediatorLogging() // ILogger-based request tracing
.AddDirectMediatorPerformanceBehavior() // warns on slow requests
.AddDirectMediatorCaching() // in-memory response caching (default TTL: 5 min)
.AddDirectMediatorValidation() // FluentValidation request validation
.AddDirectMediatorCorrelationId() // correlation ID for distributed tracing
.AddDirectMediatorRetry() // automatic retry for transient failures
.AddDirectMediatorTelemetry(); // OpenTelemetry tracing and metricsThe RetryBehavior provides automatic retry functionality for transient failures. Configure it with the AddDirectMediatorRetry extension:
services.AddDirectMediator()
.AddDirectMediatorRetry(options =>
{
options.MaxRetryCount = 3; // Number of retry attempts (default: 3)
options.BaseDelay = TimeSpan.FromSeconds(1); // Initial delay between retries
options.MaxDelay = TimeSpan.FromSeconds(30); // Maximum delay cap
options.BackoffMultiplier = 2.0; // Exponential multiplier
options.JitterFactor = 0.3; // Random jitter factor (0-1)
options.Strategy = RetryStrategy.ExponentialBackoffWithJitter;
// Only retry specific exception types
options.ShouldRetryOnException = ex => ex is TransientException || ex is TimeoutException;
// Callback invoked on each retry
options.OnRetry = (requestType, attempt, delay, exception) =>
{
Console.WriteLine($"Retrying {requestType.Name} (attempt {attempt}) after {delay}");
};
// Callback invoked when retries are exhausted
options.OnRetryExhausted = (requestType, attempts, exception) =>
{
Console.WriteLine($"Retry exhausted for {requestType.Name} after {attempts} attempts");
};
});Retry Strategies:
FixedDelay- Constant delay between retriesLinearBackoff- Delay increases linearly (baseDelay × attemptNumber)ExponentialBackoff- Delay grows exponentially (baseDelay × multiplier^attempt)ExponentialBackoffWithJitter- Exponential backoff with random jitter to prevent thundering herd
The TelemetryBehavior provides OpenTelemetry integration for distributed tracing and metrics:
services.AddDirectMediator()
.AddDirectMediatorTelemetry(options =>
{
options.ActivitySourceName = "MyApplication"; // Name for ActivitySource
options.EnableTracing = true; // Enable distributed tracing
options.EnableMetrics = true; // Enable metrics collection
// Or provide a custom ActivitySource
options.ActivitySource = myActivitySource;
});Features:
- Distributed Tracing: Creates OpenTelemetry activities via
System.Diagnostics.ActivitySource - Request Metrics: Tracks request count and duration
- Error Tracking: Counts failed requests
- Custom Activity Names: Uses request type name as the activity name
The CorrelationIdBehavior generates a unique correlation ID for each request and stores it in ICorrelationContext. Access it after sending a request:
var mediator = services.BuildServiceProvider().GetRequiredService<IMediator>();
var correlationContext = services.BuildServiceProvider().GetRequiredService<ICorrelationContext>();
await mediator.Send(new CreateOrderCommand("Product"));
Console.WriteLine($"Correlation ID: {correlationContext.CorrelationId}"); // e.g., "a1b2c3d4e5f6..."The correlation ID persists on the ICorrelationContext singleton instance, allowing you to access it after the request completes.
Override the global default TTL by passing a defaultCacheDuration to AddDirectMediatorCaching():
services.AddMemoryCache(); // required if using AddDirectMediatorCaching()
services.AddDirectMediator()
.AddDirectMediatorCaching(defaultCacheDuration: TimeSpan.FromMinutes(10));Adjust the slow-request threshold per-instance via the constructor:
// Register with a custom 200 ms threshold for CreateOrderCommand
services.AddSingleton<IPipelineBehavior<CreateOrderCommand, Unit>>(sp =>
new PerformanceBehavior<CreateOrderCommand, Unit>(
sp.GetRequiredService<ILogger<CreateOrderCommand>>(),
slowThresholdMs: 200));Implement ICacheableRequest<TResponse> on any query to have its response cached automatically:
public record GetProductQuery(int ProductId) : ICacheableRequest<Product>
{
// Unique key used to store/retrieve the cached value
public string CacheKey => $"product:{ProductId}";
// Per-request TTL; null = use the default configured in AddDirectMediatorCaching()
public TimeSpan? CacheDuration => TimeSpan.FromMinutes(10);
}To invalidate a cache entry, call IMemoryCache.Remove(key) with the same key used by the request.
_cache.Remove($"product:{productId}");ValidationBehavior<TRequest, TResponse> integrates FluentValidation into the DirectMediator pipeline. It runs every registered IValidator<TRequest> before the handler is invoked. If any rule fails a FluentValidation.ValidationException is thrown and the handler is never called. Requests that have no registered validators pass through unchanged.
Installation — add the FluentValidation package to your project:
<PackageReference Include="FluentValidation" Version="11.11.0" />Or via the .NET CLI:
dotnet add package FluentValidation --version 11.11.0Define a validator for a request:
using FluentValidation;
public record CreateOrderCommand(string Product) : ICommand;
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.Product).NotEmpty().WithMessage("Product name is required.");
RuleFor(x => x.Product).MaximumLength(100).WithMessage("Product name must not exceed 100 characters.");
}
}Register the validator and enable the behavior:
using FluentValidation;
// Register individual validators as singletons — only safe when they are
// stateless and do not depend on scoped services (see thread-safety note below)
services.AddSingleton<IValidator<CreateOrderCommand>, CreateOrderCommandValidator>();
// Enable the validation behavior
services.AddDirectMediator()
.AddDirectMediatorValidation();Thread-safety note: Because dispatchers are singletons and the pipeline is built once at construction,
ValidationBehavioris registered as a singleton and holds onto itsIValidator<T>instances for the lifetime of the application. Validators must therefore be thread-safe and must not depend on scoped services (e.g.DbContext). Only register a validator as a singleton if it is stateless or all of its dependencies are also singleton-safe. If a validator needs scoped services, do not use singleton registration — consider a factory/transient approach or restructure to keep the validator stateless.
Handle validation failures in your application code:
using FluentValidation;
try
{
await mediator.Send(new CreateOrderCommand(""));
}
catch (ValidationException ex)
{
foreach (var failure in ex.Errors)
Console.WriteLine($"{failure.PropertyName}: {failure.ErrorMessage}");
}Multiple validators for the same request type are all executed; failures from every validator are aggregated into a single ValidationException:
services.AddSingleton<IValidator<CreateOrderCommand>, CreateOrderCommandValidator>();
services.AddSingleton<IValidator<CreateOrderCommand>, AnotherCreateOrderCommandValidator>();The generated AddDirectMediator() extension method on IServiceCollection:
- Registers every discovered handler as transient
- Registers
CommandDispatcher,QueryDispatcher, andNotificationPublisheras singletons - Registers
IMediator(implemented byMediator) as a singleton
services.AddDirectMediator();All dispatchers and the unified mediator are available from the DI container:
// Unified interface (recommended)
var mediator = provider.GetRequiredService<IMediator>();
// Individual dispatchers (still fully supported)
var commandDispatcher = provider.GetRequiredService<CommandDispatcher>();
var queryDispatcher = provider.GetRequiredService<QueryDispatcher>();
var publisher = provider.GetRequiredService<NotificationPublisher>();The source generator validates your handler registrations at build time and reports the following errors:
| Code | Description |
|---|---|
FM001 |
Multiple handlers found for the same command type |
FM002 |
Multiple handlers found for the same query type |
FM003 |
No handler found for a notification type |
These errors appear as standard MSBuild/IDE errors — you will see them in the Error List before your application ever runs.
At build time, the generator emits a file called DirectMediator.Generated.g.cs inside the DirectMediator.Generated namespace. It contains:
CommandDispatcher— implementsICommandDispatcher, routes each command type through the behavior pipeline to its handler via aswitchexpression.QueryDispatcher— implementsIQueryDispatcherMarker, exposes a strongly-typedQuery(...)method per query type, wrapped in the behavior pipeline.NotificationPublisher— implementsINotificationPublisher, fans out each notification to all registered handlers via aswitchstatement.Mediator— implementsIMediator, provides a unifiedSend<TResponse>that dispatches both commands and queries through the behavior pipeline, plusPublishfor notifications.DirectMediatorServiceCollectionExtensions— provides theAddDirectMediator()extension method.
Example (abbreviated) for the sample project:
// Auto-generated — do not edit
namespace DirectMediator.Generated
{
public sealed class CommandDispatcher : ICommandDispatcher
{
private readonly CreateOrderHandler _createOrderHandler;
// Pre-built delegate chain — built ONCE at construction, zero per-dispatch overhead
private readonly System.Func<CreateOrderCommand, CancellationToken, Task<Unit>> _createOrderHandlerPipeline;
public CommandDispatcher(
CreateOrderHandler createOrderHandler,
IEnumerable<IPipelineBehavior<CreateOrderCommand, Unit>> createOrderCommandBehaviors = null!)
{
_createOrderHandler = createOrderHandler;
_createOrderHandlerPipeline = BuildPipeline<CreateOrderCommand, Unit>(
createOrderHandler, createOrderCommandBehaviors);
}
public Task Send<TCommand>(TCommand command, CancellationToken ct = default)
where TCommand : ICommand
{
return command switch
{
CreateOrderCommand c => (Task)_createOrderHandlerPipeline(c, ct),
_ => throw new InvalidOperationException(...)
};
}
// Chains behaviors into a delegate once; first-registered = outermost wrapper.
private static System.Func<TReq, CancellationToken, Task<TResp>> BuildPipeline<TReq, TResp>(
IRequestHandler<TReq, TResp> handler,
IEnumerable<IPipelineBehavior<TReq, TResp>> behaviors)
where TReq : IRequest<TResp>
{
var list = new List<IPipelineBehavior<TReq, TResp>>(
behaviors ?? Enumerable.Empty<IPipelineBehavior<TReq, TResp>>());
System.Func<TReq, CancellationToken, Task<TResp>> chain = (req, ct) => handler.Handle(req, ct);
for (var i = list.Count - 1; i >= 0; i--)
{
var b = list[i];
var inner = chain;
chain = (req, ct) => b.Handle(req, ct, () => inner(req, ct));
}
return chain;
}
}
public sealed class Mediator : IMediator
{
// constructor omitted for brevity — same pattern as CommandDispatcher
public Task<TResponse> Send<TResponse>(IRequest<TResponse> request, CancellationToken ct = default)
{
return request switch
{
CreateOrderCommand r => (Task<TResponse>)(object)_createOrderHandlerPipeline(r, ct),
GetOrderQuery r => (Task<TResponse>)(object)_getOrderHandlerPipeline(r, ct),
_ => throw new InvalidOperationException(...)
};
}
public async Task Publish<TNotification>(TNotification notification, CancellationToken ct = default)
where TNotification : INotification { /* switch over notification handlers */ }
}
}DirectMediator/
├── DirectMediator.Abstractions/ # Public interfaces, value types, and built-in behaviors
│ ├── IRequest.cs # Base request interface
│ ├── ICommand.cs # Command marker interface
│ ├── IQuery.cs # Query interface
│ ├── IRequestHandler.cs # Handler interface
│ ├── INotification.cs # Notification marker interface
│ ├── INotificationHandler.cs # Notification handler interface
│ ├── ICommandDispatcher.cs # Command dispatcher interface
│ ├── INotificationPublisher.cs # Notification publisher interface
│ ├── IQueryDispatcherMarker.cs # Query dispatcher marker interface
│ ├── IMediator.cs # Unified mediator interface (Send + Publish)
│ ├── IPipelineBehavior.cs # Pipeline behavior interface
│ ├── RequestHandlerDelegate.cs # Delegate used in pipeline behaviors
│ ├── LoggingBehavior.cs # Built-in: logs Handling/Handled/Error via ILogger
│ ├── PerformanceBehavior.cs # Built-in: warns when request exceeds configurable threshold
│ ├── CachingBehavior.cs # Built-in: caches responses via IMemoryCache for ICacheableRequest
│ ├── ICacheableRequest.cs # Opt-in marker for cacheable requests (CacheKey + CacheDuration)
│ ├── CachingBehaviorOptions.cs # Default TTL options for CachingBehavior
│ ├── ValidationBehavior.cs # Built-in: validates requests via IValidator<TRequest> (FluentValidation)
│ ├── CorrelationIdBehavior.cs # Built-in: assigns unique correlation ID for distributed tracing
│ ├── ICorrelationContext.cs # Interface for accessing correlation ID
│ ├── RetryBehavior.cs # Built-in: automatic retry with exponential backoff
│ ├── RetryBehaviorOptions.cs # Configuration options for RetryBehavior
│ ├── TelemetryBehavior.cs # Built-in: OpenTelemetry tracing and metrics
│ ├── TelemetryBehaviorOptions.cs # Configuration options for TelemetryBehavior
│ ├── BehaviorServiceCollectionExtensions.cs # AddDirectMediator*() extension methods
│ └── Unit.cs # Unit value type
│
├── DirectMediator.Generator/ # Roslyn incremental source generator
│ └── MediatorGenerator.cs # Discovers handlers, validates, and emits code
│
├── DirectMediator.Analyzers/ # Roslyn analyzers for compile-time diagnostics
│ └── ...
│
├── DirectMediator/ # Main NuGet package project
│ └── DirectMediator.csproj
│
├── DirectMediator.Sample/ # Example console application
│ ├── CreateOrderCommand.cs # Command with FluentValidation
│ ├── CreateOrderHandler.cs # Command handler
│ ├── CreateOrderCommandValidator.cs # FluentValidation validator
│ ├── GetOrderQuery.cs # Simple query
│ ├── GetOrderHandler.cs # Query handler
│ ├── GetProductQuery.cs # Cacheable query (ICacheableRequest)
│ ├── GetProductHandler.cs # Cacheable query handler
│ ├── Product.cs # Product model
│ ├── OrderCreatedNotification.cs # Notification
│ ├── OrderCreatedHandler.cs # Notification handler
│ ├── RetryCommand.cs # Command demonstrating retry behavior
│ ├── RetryCommandHandler.cs # Handler that simulates transient failures
│ └── Program.cs # Demo application showcasing all features
│
└── DirectMediator.Tests/ # Unit tests (xUnit)
├── CommandDispatcherTests.cs
├── QueryDispatcherTests.cs
├── NotificationPublisherTests.cs
├── PipelineBehaviorTests.cs # Custom behaviors + built-in LoggingBehavior/PerformanceBehavior
├── ValidationBehaviorTests.cs # ValidationBehavior + AddDirectMediatorValidation()
├── RetryBehaviorTests.cs # RetryBehavior + AddDirectMediatorRetry()
└── TelemetryBehaviorTests.cs # TelemetryBehavior + AddDirectMediatorTelemetry()
└── MediatorTests.cs
This project is open source. See the repository for license details.