A lightweight and efficient .NET implementation of the Mediator pattern for in-process messaging and communication between components.
- Introduction
- Installation
- Quick Start
- Usage Examples
- Framework Support
- Companion Guides
- Contributing
- License
NetMediate is a mediator pattern library for .NET that enables decoupled communication between components in your application. It provides a simple and flexible way to send commands, publish notifications, make requests, and handle streaming responses while maintaining clean architecture principles.
- ✅
dotnet add package NetMediate.SourceGenerationis now the recommended entrypoint for application/startup projects. - 📦
NetMediate.Corenow carries the contracts, whileNetMediate.SourceGenerationinjectsNetMediateandGenDI.SourceGeneratorthroughbuildTransitive. - ✨ New generated typed dispatch extensions (for commands, notifications, requests, and streams) reduce boilerplate and improve call-site readability.
- 🔁
buildTransitivepropagation keeps generator behavior consistent in larger multi-project solutions when you intentionally allow transitive flow.
- Faster onboarding: fewer setup decisions and less “it works on my machine” friction.
- Cleaner organization: generated typed APIs make mediator usage explicit and easier to navigate in large solutions.
- More predictable architecture: compile-time registration and transitive analyzer behavior keep projects aligned as teams scale.
- Commands: Send one-way messages to all registered handlers sequentially
- Notifications: Publish messages to multiple handlers — all handlers started in parallel (
Task.WhenAll); handler results and exceptions are discarded (fire-and-forget). Batch notifications (IEnumerable) are also dispatched in parallel. - Requests: Send a message to a single handler and receive a typed response
- Streaming: Handle requests that return multiple responses over time via
IAsyncEnumerable - Pipeline Behaviors: Interceptors with pre/post flow for every message kind
- Optional resilience package: Retry, timeout, and circuit-breaker behaviors in
NetMediate.Resilience - OpenTelemetry-ready diagnostics: Built-in
ActivitySource/Meterfor Send/Request/Notify/Stream - Keyed handler routing: Register handlers under named keys and dispatch to specific subsets at runtime — fully NativeAOT + Trimming compatible via source-generated
KeyedHandlerRegistry<T> - Streaming fan-out: Multiple
IStreamHandlerregistrations supported — their items are merged sequentially - Cancellation Support: Full cancellation token support across all operations
- Broad runtime compatibility: Multi-targeted for
net10.0,netstandard2.0, andnetstandard2.1
Install-Package NetMediate.CoreInstall-Package NetMediate.SourceGenerationNote: Install
NetMediate.Corewhere you only need the contracts (IMediator, handlers, behaviors). InstallNetMediate.SourceGenerationin the executable/startup project that callsAddNetMediate(). ItsbuildTransitivefile adds the requiredPackageReferenceentries forNetMediateandGenDI.SourceGenerator.
dotnet add package NetMediate.Core
dotnet add package NetMediate.SourceGenerationNote: If you are publishing your own library, you may add
PrivateAssets="all"to theNetMediate.SourceGenerationreference to avoid flowing the generator package transitively. The startup project can keep the default behavior.
<PackageReference Include="NetMediate.Core" Version="x.x.x" />
<PackageReference Include="NetMediate.SourceGeneration" Version="x.x.x.x">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>Note:
NetMediate.SourceGenerationshould be referenced withIncludeAssets+PrivateAssets="all". It addsNetMediateandGenDI.SourceGeneratorindirectly viabuildTransitive.
NetMediate.SourceGeneration also activates GenDI in the startup project. Prefer the GenDI style for your application services and supporting implementations:
using GenDI;
using Microsoft.Extensions.DependencyInjection;
[ServiceInjection]
public interface IEmailService
{
Task SendWelcomeEmailAsync(string email, CancellationToken cancellationToken);
}
[Injectable(ServiceLifetime.Scoped, Group = 10, Order = 1, Key = "primary")]
public sealed class SmtpEmailService : IEmailService
{
public Task SendWelcomeEmailAsync(string email, CancellationToken cancellationToken) =>
Task.CompletedTask;
}
[Injectable(ServiceLifetime.Scoped)]
public sealed class UserFacade
{
[Inject] public required IEmailService EmailService { get; init; }
[Inject] public required ILogger<UserFacade> Logger { get; init; }
}With GenDI the consumer chooses the ServiceLifetime, Group, Order, and Key. Use [Injectable<TService>] only when you need to force a specific non-generic contract and contract discovery does not already find [ServiceInjection]. Concrete non-generic classes that implement closed generic contracts can still use [Injectable]. Only generic/open service implementations (for example AuditBehavior<TMessage, TResponse>) should be registered manually in builder.Services for the AOT-oriented path. AddNetMediate() already calls AddGenDIServices() for you.
<PackageReference Include="NetMediate.Moq" Version="x.x.x" />
<PackageReference Include="NetMediate.Resilience" Version="x.x.x" />
<PackageReference Include="NetMediate.Quartz" Version="x.x.x" />- NetMediate.Moq: lightweight Moq helpers for unit and integration tests (
Mocking.Create,AddMockSingleton, async setup extensions). - NetMediate.Resilience: optional retry, timeout, and circuit-breaker pipeline behaviors for request and notification flows.
- NetMediate.Quartz: persists notifications as Quartz.NET jobs, enabling crash recovery and cluster-distributed notification execution.
- Full documentation website
- NetMediate.Moq recipes
- API/Worker/Minimal API samples
- Diagnostics (traces + metrics)
- Resilience package guide
- Benchmark results
- Quartz persistent notifications
- Source generation guide
- AOT / NativeAOT and trimming guide
- Wiki index
- Validation behavior sample
Here's a minimal example to get you started with NetMediate:
using GenDI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NetMediate;
public record UserCreated(string UserId, string Email);
[Injectable(ServiceLifetime.Scoped, Group = 100, Order = 1)]
public class UserCreatedHandler : INotificationHandler<UserCreated>
{
[Inject] public required ILogger<UserCreatedHandler> Logger { get; init; }
public Task Handle(UserCreated notification, CancellationToken cancellationToken = default)
{
Logger.LogInformation("User {UserId} was created", notification.UserId);
return Task.CompletedTask;
}
}
public static class QuickStartExample
{
public static async Task RunAsync()
{
// 1. Install the package
// Shared contracts: dotnet add package NetMediate.Core
// Startup/app project: dotnet add package NetMediate.SourceGeneration
// 2. Register services — source generator discovers all handlers automatically
var builder = Host.CreateApplicationBuilder();
builder.Services.AddNetMediate(); // all handlers in your project are registered here
// 3. Use the mediator
var host = builder.Build();
await host.StartAsync();
var mediator = host.Services.GetRequiredService<IMediator>();
await mediator.NotifyUserCreatedAsync(new("123", "user@example.com"));
}
}For more detailed examples, see the Usage Examples section below.
Register NetMediate services using the source generator:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NetMediate;
var builder = Host.CreateApplicationBuilder();
// NetMediate.SourceGeneration discovers handlers automatically at compile time
// and registers all handlers in your project.
builder.Services.AddNetMediate();
var host = builder.Build();
var mediator = host.Services.GetRequiredService<IMediator>();Notify runs the notification pipeline (behaviors are fully awaited and their exceptions propagate to the caller). When the pipeline reaches the handler dispatch step, all registered handlers are started simultaneously via Task.WhenAll and the result is discarded — handlers are fire-and-forget. Handler exceptions and completion timing have no effect on the pipeline or the caller. When sending a batch of notifications (IEnumerable), each message's pipeline is dispatched in parallel (Task.WhenAll across messages).
// No marker interface required — any plain class or record works
public record UserRegistered(string UserId, string Email, DateTime RegisteredAt);[Injectable(ServiceLifetime.Scoped, Group = 100, Order = 1)]
public class EmailNotificationHandler : INotificationHandler<UserRegistered>
{
[Inject] public required IEmailService EmailService { get; init; }
// Handle must return Task, not Task
public async Task Handle(UserRegistered notification, CancellationToken cancellationToken = default)
{
await EmailService.SendWelcomeEmailAsync(notification.Email, cancellationToken);
}
}
[Injectable(ServiceLifetime.Scoped, Group = 100, Order = 2)]
public class AuditLogHandler : INotificationHandler<UserRegistered>
{
[Inject] public required IAuditService AuditService { get; init; }
public async Task Handle(UserRegistered notification, CancellationToken cancellationToken = default)
{
await AuditService.LogEventAsync(
$"User {notification.UserId} registered",
cancellationToken
);
}
}var notification = new UserRegistered("user123", "user@example.com", DateTime.UtcNow);
await mediator.NotifyUserRegisteredAsync(notification, cancellationToken);Batch notifications in one call:
var notifications = new[]
{
new UserRegistered("user123", "user@example.com", DateTime.UtcNow),
new UserRegistered("user321", "user2@example.com", DateTime.UtcNow)
};
await mediator.NotifyUserRegisteredAsync(notifications, cancellationToken);Commands are dispatched to all registered handlers sequentially (one after another in registration order). Use Send when you want to trigger a side-effect across multiple consumers with no return value.
// No marker interface required — any plain class or record works
public record CreateUserCommand(string Email, string FirstName, string LastName);Multiple handlers can be registered for the same command type — all run sequentially on each Send call.
[Injectable(ServiceLifetime.Scoped, Group = 100, Order = 1)]
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand>
{
[Inject] public required IUserRepository UserRepository { get; init; }
// Handle must return Task
public async Task Handle(CreateUserCommand command, CancellationToken cancellationToken = default)
{
var user = new User
{
Email = command.Email,
FirstName = command.FirstName,
LastName = command.LastName
};
await UserRepository.CreateAsync(user, cancellationToken);
}
}var command = new CreateUserCommand("user@example.com", "John", "Doe");
await mediator.SendCreateUserCommandAsync(command);Requests are sent to a handler and return a response.
// No marker interface required
public record GetUserQuery(string UserId);
public record UserDto(string Id, string Email, string FirstName, string LastName);[Injectable(ServiceLifetime.Scoped, Group = 100, Order = 1)]
public class GetUserQueryHandler : IRequestHandler<GetUserQuery, UserDto>
{
[Inject] public required IUserRepository UserRepository { get; init; }
// Handle must return Task<TResponse>
public async Task<UserDto> Handle(GetUserQuery query, CancellationToken cancellationToken = default)
{
var user = await UserRepository.GetByIdAsync(query.UserId, cancellationToken);
return new UserDto(user.Id, user.Email, user.FirstName, user.LastName);
}
}var query = new GetUserQuery("user123");
var userDto = await mediator.RequestGetUserQueryAsync(query);Streams allow handlers to return multiple responses over time.
// No marker interface required
public record GetUserActivityQuery(string UserId, DateTime FromDate);
public record ActivityDto(string Id, string Action, DateTime Timestamp);[Injectable(ServiceLifetime.Scoped, Group = 100, Order = 1)]
public class GetUserActivityQueryHandler : IStreamHandler<GetUserActivityQuery, ActivityDto>
{
[Inject] public required IActivityRepository ActivityRepository { get; init; }
public async IAsyncEnumerable<ActivityDto> Handle(
GetUserActivityQuery query,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var activity in ActivityRepository.GetUserActivityStreamAsync(
query.UserId, query.FromDate, cancellationToken))
{
yield return new ActivityDto(activity.Id, activity.Action, activity.Timestamp);
}
}
}var query = new GetUserActivityQuery("user123", DateTime.UtcNow.AddDays(-30));
await foreach (var activity in mediator.StreamGetUserActivityQueryAsync(query))
{
Console.WriteLine($"{activity.Timestamp}: {activity.Action}");
}NetMediate messages are plain records or classes — no marker interfaces are required. The message type and the handler type are always separate.
| Message kind | Handler interface | Dispatch semantics |
|---|---|---|
| Command | ICommandHandler<TMessage> |
All registered handlers, sequential in registration order |
| Request | IRequestHandler<TMessage, TResponse> |
First registered handler only; returns TResponse |
| Notification | INotificationHandler<TMessage> |
All handlers started in parallel (fire-and-forget via Task.WhenAll); handler exceptions unobserved |
| Stream | IStreamHandler<TMessage, TResponse> |
All registered handlers, items merged sequentially (handler A items first, then handler B) |
// Command — no return value, dispatched to all registered handlers sequentially
public record DeleteUserCommand(string UserId);
// Request — single handler, returns a response
public record GetUserQuery(string UserId);
// Notification — all handlers started in parallel (fire-and-forget); handler exceptions unobserved
public record UserDeleted(string UserId);
// Stream — all registered handlers, items merged sequentially
public record GetRecentEventsQuery(int MaxItems);Register handlers under routing keys and dispatch to a specific subset at runtime. This is useful for scenarios such as queue/topic routing, tenant isolation, or environment-specific handling:
[Injectable(ServiceLifetime.Scoped, Group = 100, Order = 1)]
public sealed class DefaultHandler : ICommandHandler<MyCommand>
{
public Task Handle(MyCommand message, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
}
[Injectable(ServiceLifetime.Scoped, Group = 100, Order = 2, Key = "audit")]
public sealed class AuditHandler : ICommandHandler<MyCommand>
{
public Task Handle(MyCommand message, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
}
builder.Services.AddNetMediate();
// Dispatch to null-key (default) handlers
await mediator.SendMyCommandAsync(new MyCommand(), cancellationToken);
// Dispatch only to "audit" handlers
await mediator.SendMyCommandAsync("audit", new MyCommand(), cancellationToken);The key is propagated through the entire pipeline — behaviors receive it in their Handle(object? key, ...) signature and can use it for routing, logging, or conditional logic.
Keyless dispatch: A
nullkey (the default when no key is passed) flows through the pipeline unchanged.mediator.SendMyCommandAsync(command, ct)andmediator.SendMyCommandAsync(null, command, ct)are exactly equivalent and target the non-keyed handlers registered in the container.
NativeAOT: Keyed dispatch is fully NativeAOT + Trimming compatible. The source generator emits a
KeyedHandlerRegistry<T>at compile time — no reflection, noIKeyedServiceProvideris used at runtime. Both keyed and non-keyed dispatch are safe for NativeAOT and trimmed deployments.
Behaviors wrap the handler pipeline and run in registration order. Concrete non-generic behavior classes can use [Injectable] because the closed pipeline interfaces already carry [ServiceInjection]. Only generic/open behavior implementations should be registered manually in builder.Services.
[Injectable(ServiceLifetime.Singleton, Group = 10, Order = 1)]
public sealed class AuditCommandBehavior : IPipelineCommandBehavior<CreateUserCommand>
{
public Task Handle(
object? key,
CreateUserCommand message,
PipelineBehaviorDelegate<CreateUserCommand, Task> next,
CancellationToken cancellationToken) =>
next(key, message, cancellationToken);
}
[Injectable(ServiceLifetime.Singleton, Group = 10, Order = 2)]
public sealed class AuditRequestBehavior : IPipelineRequestBehavior<GetUserQuery, UserDto>
{
public Task<UserDto> Handle(
object? key,
GetUserQuery message,
PipelineBehaviorDelegate<GetUserQuery, Task<UserDto>> next,
CancellationToken cancellationToken) =>
next(key, message, cancellationToken);
}
[Injectable(ServiceLifetime.Singleton, Group = 10, Order = 3)]
public sealed class LogNotificationBehavior : IPipelineNotificationBehavior<UserCreatedNotification>
{
public Task Handle(
object? key,
UserCreatedNotification message,
PipelineBehaviorDelegate<UserCreatedNotification, Task> next,
CancellationToken cancellationToken) =>
next(key, message, cancellationToken);
}
builder.Services.AddNetMediate();Example behavior — audit timing for requests:
[Injectable(ServiceLifetime.Singleton, Group = 10, Order = 1)]
public sealed class AuditRequestBehavior : IPipelineRequestBehavior<GetUserQuery, UserDto>
{
// Handle receives object? key — the same key passed to the dispatch call.
// Use it for routing (e.g. queue/topic selection) or contextual filtering.
public async Task<UserDto> Handle(
object? key,
GetUserQuery message,
PipelineBehaviorDelegate<GetUserQuery, Task<UserDto>> next,
CancellationToken cancellationToken)
{
var startedAt = DateTimeOffset.UtcNow;
var response = await next(key, message, cancellationToken);
Console.WriteLine($"{nameof(GetUserQuery)} handled in {DateTimeOffset.UtcNow - startedAt}");
return response;
}
}Example notification behavior:
[Injectable(ServiceLifetime.Singleton, Group = 10, Order = 1)]
public sealed class LogNotificationBehavior : IPipelineNotificationBehavior<UserCreatedNotification>
{
public async Task Handle(
object? key,
UserCreatedNotification message,
PipelineBehaviorDelegate<UserCreatedNotification, Task> next,
CancellationToken cancellationToken)
{
Console.WriteLine($"Dispatching {nameof(UserCreatedNotification)} (key={key})");
await next(key, message, cancellationToken);
Console.WriteLine($"Dispatched {nameof(UserCreatedNotification)}");
}
}Note on validation: NetMediate does not include a built-in validation layer. Implement validation as a pipeline behavior. See docs/VALIDATION_BEHAVIOR_SAMPLE.md for an example.
All runtime packages are published with:
net10.0netstandard2.0netstandard2.1
NetMediate.SourceGeneration is shipped as its own package (netstandard2.0 analyzer). When installed directly, its buildTransitive file adds the required NetMediate runtime and GenDI.SourceGenerator dependencies automatically.
Because packages expose netstandard2.0 and netstandard2.1 assets they can be consumed by desktop, CLI, mobile, MAUI, and server/web applications.
Contributions are welcome! Please read our Contributing Guidelines and Code of Conduct.
This project is licensed under the MIT License - see the LICENSE file for details.