Skip to content

schivei/net-mediate

NetMediate

CI/CD Pipeline NuGet Documentation

A lightweight and efficient .NET implementation of the Mediator pattern for in-process messaging and communication between components.

Table of Contents

Introduction

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.

Key Features

  • 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/Meter for Send/Request/Notify/Stream
  • Optional DataDog integrations: OpenTelemetry, Serilog, and ILogger support packages
  • Keyed handler routing: Register handlers under named keys and dispatch to specific subsets at runtime
  • Streaming fan-out: Multiple IStreamHandler registrations 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, and netstandard2.1

Installation

Package Manager Console

Install-Package NetMediate

Note: After installing via Package Manager Console or .NET CLI, you may add PrivateAssets="all" to the PackageReference element if you are building a library and want to prevent NetMediate and its bundled source generator from flowing as a transitive dependency to consumers of your package. For application projects this is optional — the analyzer runs for direct references automatically.

.NET CLI

dotnet add package NetMediate

Note: After running the CLI command, you may add PrivateAssets="all" to the PackageReference element if you are building a library and want to prevent NetMediate from flowing transitively to downstream consumers. For application projects this is optional.

PackageReference

<PackageReference Include="NetMediate" Version="x.x.x" PrivateAssets="all" />

Note: PrivateAssets="all" is recommended for library/NuGet projects to prevent NetMediate and its bundled source generator from flowing as a transitive dependency to consumers of your library. For application projects (e.g., ASP.NET Core apps, console apps) it is optional — the analyzer runs for direct references regardless. Without PrivateAssets, downstream consumers of your library will also receive the NetMediate package and its analyzer, which may or may not be desired.

Optional companion packages

<PackageReference Include="NetMediate.Moq" Version="x.x.x" />
<PackageReference Include="NetMediate.Resilience" Version="x.x.x" />
<PackageReference Include="NetMediate.Quartz" Version="x.x.x" />
<PackageReference Include="NetMediate.DataDog.OpenTelemetry" Version="x.x.x" />
<PackageReference Include="NetMediate.DataDog.Serilog" Version="x.x.x" />
<PackageReference Include="NetMediate.DataDog.ILogger" 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.
  • NetMediate.DataDog.OpenTelemetry: wires NetMediate traces/metrics to DataDog through OpenTelemetry OTLP exporters.
  • NetMediate.DataDog.Serilog: attaches the DataDog Serilog sink and enriches logs with NetMediate activity fields.
  • NetMediate.DataDog.ILogger: ILogger scope helpers with DataDog-compatible fields and NetMediate correlation values.

Companion Guides

Quick Start

Here's a minimal example to get you started with NetMediate:

// 1. Install the package (with PrivateAssets="all" — required for the bundled source generator)
// dotnet add package NetMediate
// Then set PrivateAssets="all" in the PackageReference in your .csproj.

// 2. Register services — source generator discovers all handlers automatically
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NetMediate;

var builder = Host.CreateApplicationBuilder();
builder.Services.AddNetMediate(); // all handlers in your project are registered here

// 3. Define a notification (no marker interface required)
public record UserCreated(string UserId, string Email);

// 4. Create a handler (Handle returns Task)
public class UserCreatedHandler : INotificationHandler<UserCreated>
{
    public Task Handle(UserCreated notification, CancellationToken cancellationToken = default)
    {
        Console.WriteLine($"User {notification.UserId} was created!");
        return Task.CompletedTask;
    }
}

// 5. Use the mediator
var host = builder.Build();
await host.StartAsync();
var mediator = host.Services.GetRequiredService<IMediator>();
await mediator.Notify(new UserCreated("123", "user@example.com"));

For more detailed examples, see the Usage Examples section below.

Usage Examples

Basic Setup

Register NetMediate services using the source generator:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NetMediate;

var builder = Host.CreateApplicationBuilder();

// The bundled source generator (activated by PrivateAssets="all" in your PackageReference)
// automatically discovers and registers all handlers at compile time.
builder.Services.AddNetMediate();

var host = builder.Build();
var mediator = host.Services.GetRequiredService<IMediator>();

Notifications

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).

Define a Notification Message

// No marker interface required — any plain class or record works
public record UserRegistered(string UserId, string Email, DateTime RegisteredAt);

Create Notification Handlers

public class EmailNotificationHandler : INotificationHandler<UserRegistered>
{
    private readonly IEmailService _emailService;

    public EmailNotificationHandler(IEmailService emailService)
    {
        _emailService = emailService;
    }

    // Handle must return Task, not Task
    public async Task Handle(UserRegistered notification, CancellationToken cancellationToken = default)
    {
        await _emailService.SendWelcomeEmailAsync(notification.Email, cancellationToken);
    }
}

public class AuditLogHandler : INotificationHandler<UserRegistered>
{
    private readonly IAuditService _auditService;

    public AuditLogHandler(IAuditService auditService)
    {
        _auditService = auditService;
    }

    public async Task Handle(UserRegistered notification, CancellationToken cancellationToken = default)
    {
        await _auditService.LogEventAsync($"User {notification.UserId} registered", cancellationToken);
    }
}

Publish Notifications

var notification = new UserRegistered("user123", "user@example.com", DateTime.UtcNow);
await mediator.Notify(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.Notify(notifications, cancellationToken);

Commands

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.

Define a Command

// No marker interface required — any plain class or record works
public record CreateUserCommand(string Email, string FirstName, string LastName);

Create a Command Handler

Multiple handlers can be registered for the same command type — all run sequentially on each Send call.

public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand>
{
    private readonly IUserRepository _userRepository;

    public CreateUserCommandHandler(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }

    // 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);
    }
}

Send Commands

var command = new CreateUserCommand("user@example.com", "John", "Doe");
await mediator.Send(command);

Requests

Requests are sent to a handler and return a response.

Define a Request and Response

// No marker interface required
public record GetUserQuery(string UserId);
public record UserDto(string Id, string Email, string FirstName, string LastName);

Create a Request Handler

public class GetUserQueryHandler : IRequestHandler<GetUserQuery, UserDto>
{
    private readonly IUserRepository _userRepository;

    public GetUserQueryHandler(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }

    // 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);
    }
}

Send Requests

var query = new GetUserQuery("user123");
var userDto = await mediator.Request<GetUserQuery, UserDto>(query);

Streams

Streams allow handlers to return multiple responses over time.

Define a Stream Request

// No marker interface required
public record GetUserActivityQuery(string UserId, DateTime FromDate);
public record ActivityDto(string Id, string Action, DateTime Timestamp);

Create a Stream Handler

public class GetUserActivityQueryHandler : IStreamHandler<GetUserActivityQuery, ActivityDto>
{
    private readonly IActivityRepository _activityRepository;
    
    public GetUserActivityQueryHandler(IActivityRepository activityRepository)
    {
        _activityRepository = activityRepository;
    }
    
    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);
        }
    }
}

Process Streams

var query = new GetUserActivityQuery("user123", DateTime.UtcNow.AddDays(-30));

await foreach (var activity in mediator.RequestStream<GetUserActivityQuery, ActivityDto>(query))
{
    Console.WriteLine($"{activity.Timestamp}: {activity.Action}");
}

Message type summary

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);

Keyed Dispatch

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:

// Registration — same message type, different keys
builder.Services.AddNetMediate(configure =>
{
    configure.RegisterCommandHandler<DefaultHandler, MyCommand>();        // null key → "__default"
    configure.RegisterCommandHandler<AuditHandler, MyCommand>("audit");  // keyed
});

// Dispatch to null-key (default) handlers
await mediator.Send(new MyCommand(), cancellationToken);

// Dispatch only to "audit" handlers
await mediator.Send("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.

Default routing key: A null key (the default when no key is passed) is normalized internally to the constant Extensions.DEFAULT_ROUTING_KEY = "__default". This means mediator.Send(command, ct) and mediator.Send(null, command, ct) are exactly equivalent. Avoid using the literal string "__default" as your own routing key to prevent conflicts.

NativeAOT: Non-keyed registration and dispatch remain fully NativeAOT-compatible. Keyed registration uses IKeyedServiceProvider internally, which is not NativeAOT-compatible; use it only when NativeAOT is not required.

Pipeline Behaviors / Interceptors

Behaviors wrap the handler pipeline and run in registration order. Register them via the builder using closed types — this is the only supported pattern, and it is fully AOT-safe:

builder.Services.UseNetMediate(configure =>
{
    configure.RegisterBehavior<AuditCommandBehavior, CreateUserCommand, Task>();
    configure.RegisterBehavior<AuditRequestBehavior<GetUserQuery, UserDto>, GetUserQuery, Task<UserDto>>();
    configure.RegisterBehavior<LogNotificationBehavior<UserCreatedNotification>, UserCreatedNotification, Task>();
});

Example behavior — audit timing for requests:

public sealed class AuditRequestBehavior<TMessage, TResponse>
    : IPipelineRequestBehavior<TMessage, TResponse>
    where TMessage : notnull
{
    // 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<TResponse> Handle(
        object? key,
        TMessage message,
        PipelineBehaviorDelegate<TMessage, Task<TResponse>> next,
        CancellationToken cancellationToken)
    {
        var startedAt = DateTimeOffset.UtcNow;
        var response = await next(key, message, cancellationToken);
        Console.WriteLine($"{typeof(TMessage).Name} handled in {DateTimeOffset.UtcNow - startedAt}");
        return response;
    }
}

Example notification behavior:

public sealed class LogNotificationBehavior<TMessage>
    : IPipelineBehavior<TMessage>
    where TMessage : notnull
{
    public async Task Handle(
        object? key,
        TMessage message,
        PipelineBehaviorDelegate<TMessage, Task> next,
        CancellationToken cancellationToken)
    {
        Console.WriteLine($"Dispatching {typeof(TMessage).Name} (key={key})");
        await next(key, message, cancellationToken);
        Console.WriteLine($"Dispatched {typeof(TMessage).Name}");
    }
}

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.

Framework Support

Supported package TFMs

All runtime packages are published with:

  • net10.0
  • netstandard2.0
  • netstandard2.1

NetMediate.SourceGeneration is bundled inside the NetMediate package as an analyzer (netstandard2.0) and is activated by setting PrivateAssets="all" on the PackageReference.

Application types covered

Because packages expose netstandard2.0 and netstandard2.1 assets they can be consumed by desktop, CLI, mobile, MAUI, and server/web applications.

Contributing

Contributions are welcome! Please read our Contributing Guidelines and Code of Conduct.

License

This project is licensed under the MIT License - see the LICENSE file for details.

About

A lightweight and efficient .NET implementation of the Mediator pattern, providing a clean alternative to MediatR for in-process messaging and communication between components.

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors