Davish.Sendr 3.0.0

dotnet add package Davish.Sendr --version 3.0.0
                    
NuGet\Install-Package Davish.Sendr -Version 3.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Davish.Sendr" Version="3.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Davish.Sendr" Version="3.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Davish.Sendr" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Davish.Sendr --version 3.0.0
                    
#r "nuget: Davish.Sendr, 3.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Davish.Sendr@3.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Davish.Sendr&version=3.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Davish.Sendr&version=3.0.0
                    
Install as a Cake Tool

<div align="center">

Davish.Sendr

A free, lightweight mediator for .NET — explicit, no assembly scanning.

NuGet NuGet NuGet License: MIT

</div>

Sendr keeps the ergonomics you expect from a mediator — send a request, let a handler resolve it, wrap it in cross-cutting behaviour — while staying small, allocation-conscious, and fully explicit about what is registered. It covers request/response dispatching, async streams, notification fan-out, and a decorator pipeline that works across all three.

Features

  • Request/response dispatchingIRequest for commands, IRequest<TResponse> for queries, each resolved to exactly one handler.
  • CQRS contractsICommand / ICommand<TResponse> and IQuery<TResponse> name the command/query split explicitly, while still dispatching through the same ISender.
  • Async streamsIStreamRequest<TResponse> dispatched lazily as IAsyncEnumerable<T>.
  • Notification fan-outINotification published to any number of handlers, arranged into an ordered Sequence group and a concurrent Parallel group.
  • Non-generic decorators — a single decorator type wraps any compatible request, stream, or notification handler; no per-type boilerplate.
  • Explicit registration — every handler is registered by hand. No reflection-based assembly scanning, no surprises at startup.
  • Multi-target — builds for netstandard2.0 and net10.0.
  • Split packages — depend only on the abstractions package from your domain layer; request/response and notification each ship as their own pair of packages, with an optional CQRS-flavored package on top.

Unlike scanning-based mediators, Sendr never discovers handlers implicitly. Registration is a compile-time-checked call, so a missing handler is obvious at the composition root.

Install

dotnet add package Davish.Sendr

The contracts (IRequest, IRequestHandler, IRequestDecorator, ISender, …) also ship on their own so your domain assemblies can reference them without pulling in the DI implementation:

dotnet add package Davish.Sendr.Abstractions

Notification publishing is a separate pair of packages — it doesn't depend on Davish.Sendr, so you can add it on its own:

dotnet add package Davish.Sendr.Notification
dotnet add package Davish.Sendr.Notification.Abstractions

If you want the CQRS naming (ICommand, IQuery, …) instead of the plain IRequest contracts, add its own package too:

dotnet add package Davish.Sendr.Message

Getting started

Call AddSendr once, then register each handler explicitly.

builder.Services
    .AddSendr()
    .AddRequestHandler<CreateOrder, CreateOrderHandler>()
    .AddRequestHandler<GetOrder, OrderDto, GetOrderHandler>()
    .AddStreamRequestHandler<ListOrders, OrderDto, ListOrdersHandler>();

Requests

Use IRequest for commands that do not return a value.

public sealed record CreateOrder(Guid Id) : IRequest;

public sealed class CreateOrderHandler : IRequestHandler<CreateOrder>
{
    public Task HandleAsync(CreateOrder request, CancellationToken cancellationToken = default)
    {
        return Task.CompletedTask;
    }
}

Use IRequest<TResponse> for request/response dispatching.

public sealed record GetOrder(Guid Id) : IRequest<OrderDto>;

public sealed record OrderDto(Guid Id, string Number);

public sealed class GetOrderHandler : IRequestHandler<GetOrder, OrderDto>
{
    public Task<OrderDto> HandleAsync(GetOrder request, CancellationToken cancellationToken = default)
    {
        return Task.FromResult(new OrderDto(request.Id, "SO-001"));
    }
}

Resolve ISender and call SendAsync.

var sender = serviceProvider.GetRequiredService<ISender>();

await sender.SendAsync(new CreateOrder(Guid.NewGuid()));

var order = await sender.SendAsync(new GetOrder(Guid.NewGuid()));

Commands and queries (CQRS)

Davish.Sendr.Message adds ICommand / ICommand<TResponse> and IQuery<TResponse> on top of IRequest / IRequest<TResponse> — same dispatch, same decorators, just names that say which side of CQRS a request belongs to.

public sealed record CreateOrder(Guid Id) : ICommand;

public sealed class CreateOrderHandler : ICommandHandler<CreateOrder>
{
    public Task HandleAsync(CreateOrder request, CancellationToken cancellationToken = default)
    {
        return Task.CompletedTask;
    }
}

public sealed record GetOrder(Guid Id) : IQuery<OrderDto>;

public sealed record OrderDto(Guid Id, string Number);

public sealed class GetOrderHandler : IQueryHandler<GetOrder, OrderDto>
{
    public Task<OrderDto> HandleAsync(GetOrder request, CancellationToken cancellationToken = default)
    {
        return Task.FromResult(new OrderDto(request.Id, "SO-001"));
    }
}

Registration and dispatch are unchanged — ICommand/IQuery are IRequest/IRequest<TResponse> under the hood, so they go through the same AddRequestHandler and ISender.SendAsync you already use.

builder.Services
    .AddSendr()
    .AddRequestHandler<CreateOrder, CreateOrderHandler>()
    .AddRequestHandler<GetOrder, OrderDto, GetOrderHandler>();

Decorators

Decorators are non-generic pipeline behaviours. A single decorator type can wrap any compatible request type — implement IRequestDecorator for commands and IRequestDecorator.WithResponse for queries.

builder.Services
    .AddSendr()
    .AddRequestHandler<GetOrder, OrderDto, GetOrderHandler>(x => x.Decorator
        .With<TransactionDecorator>()
        .With<LoggingDecorator>());

Decorators execute in the order they are added: the first With<> is the outermost layer. In the example above, TransactionDecorator runs first and last, with LoggingDecorator nested inside it.

public sealed class LoggingDecorator(ILogger<LoggingDecorator> logger)
    : IRequestDecorator, IRequestDecorator.WithResponse
{
    public async Task HandleAsync<TRequest>(
        TRequest request,
        RequestHandlerDelegate next,
        CancellationToken cancellationToken = default)
        where TRequest : IRequest
    {
        logger.LogInformation("Handling {Request}", typeof(TRequest).Name);
        await next();
        logger.LogInformation("Handled {Request}", typeof(TRequest).Name);
    }

    public async Task<TResponse> HandleAsync<TRequest, TResponse>(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken = default)
        where TRequest : IRequest<TResponse>
    {
        logger.LogInformation("Handling {Request}", typeof(TRequest).Name);
        var response = await next();
        logger.LogInformation("Handled {Request}", typeof(TRequest).Name);
        return response;
    }
}

Streams

Use IStreamRequest<TResponse> and IStreamRequestHandler<TRequest, TResponse> for async streams. The sequence is lazy — handling begins when enumeration starts.

public sealed record ListOrders : IStreamRequest<OrderDto>;

public sealed class ListOrdersHandler : IStreamRequestHandler<ListOrders, OrderDto>
{
    public async IAsyncEnumerable<OrderDto> HandleAsync(
        ListOrders request,
        CancellationToken cancellationToken = default)
    {
        yield return new OrderDto(Guid.NewGuid(), "SO-001");
        await Task.Delay(10, cancellationToken);
        yield return new OrderDto(Guid.NewGuid(), "SO-002");
    }
}

Resolve IStreamSender and call SendStream.

var streamSender = serviceProvider.GetRequiredService<IStreamSender>();

await foreach (var order in streamSender.SendStream(new ListOrders()))
{
    Console.WriteLine(order.Number);
}

Stream handlers support decorators too, via IStreamRequestDecorator.

builder.Services
    .AddSendr()
    .AddStreamRequestHandler<ListOrders, OrderDto, ListOrdersHandler>(x =>
        x.Decorator.With<LoggingStreamDecorator>());
public sealed class LoggingStreamDecorator(ILogger<LoggingStreamDecorator> logger)
    : IStreamRequestDecorator
{
    public IAsyncEnumerable<TResponse> HandleAsync<TRequest, TResponse>(
        TRequest request,
        StreamHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken = default)
        where TRequest : IStreamRequest<TResponse>
    {
        logger.LogInformation("Streaming {Request}", typeof(TRequest).Name);
        return next();
    }
}

A stream decorator that uses yield should wrap the enumeration in try/finally and forward the token via [EnumeratorCancellation] so cancellation and disposal propagate correctly.

Notifications

Unlike a request, a notification can have any number of handlers — including zero. Use INotification for events you want to fan out, INotificationHandler<TNotification> for each handler, and IPublisher to publish.

public sealed record OrderPlaced(Guid OrderId) : INotification;

public sealed class ReserveInventoryHandler : INotificationHandler<OrderPlaced>
{
    public Task HandleAsync(OrderPlaced notification, CancellationToken cancellationToken = default)
    {
        return Task.CompletedTask;
    }
}

public sealed class SendConfirmationEmailHandler : INotificationHandler<OrderPlaced>
{
    public Task HandleAsync(OrderPlaced notification, CancellationToken cancellationToken = default)
    {
        return Task.CompletedTask;
    }
}

Call AddSendrNotification once, then register every handler for a notification type in a single AddNotificationHandler call, arranging them into a Sequence (run one after another, in order, stopping if one throws) and/or a Parallel group (run concurrently).

builder.Services
    .AddSendrNotification()
    .AddNotificationHandler<OrderPlaced>(x =>
    {
        x.Handler.Sequence.With<ReserveInventoryHandler>();
        x.Handler.Parallel.With<SendConfirmationEmailHandler>();
    });

Resolve IPublisher and call PublishAsync. It takes the non-generic INotification, so a batch collected polymorphically — for example from an outbox — can be published without knowing each concrete type; publishing a notification with no registered handlers is a no-op.

var publisher = serviceProvider.GetRequiredService<IPublisher>();

await publisher.PublishAsync(new OrderPlaced(order.Id));

Each handler entry can have its own decorator pipeline via INotificationDecorator, configured the same way as request decorators.

builder.Services
    .AddSendrNotification()
    .AddNotificationHandler<OrderPlaced>(x => x.Handler.Sequence
        .With<ReserveInventoryHandler>(h => h.Decorator.With<LoggingNotificationDecorator>()));
public sealed class LoggingNotificationDecorator(ILogger<LoggingNotificationDecorator> logger)
    : INotificationDecorator
{
    public async Task HandleAsync<TNotification>(
        TNotification notification,
        NotificationHandlerDelegate next,
        CancellationToken cancellationToken = default)
        where TNotification : INotification
    {
        logger.LogInformation("Handling {Notification}", typeof(TNotification).Name);
        await next();
        logger.LogInformation("Handled {Notification}", typeof(TNotification).Name);
    }
}

The Parallel group is still evolving. Handlers run concurrently via Task.WhenAll, so write them as proper async methods — a handler that throws synchronously instead of through an awaited Task can prevent handlers queued after it from running. If more than one handler fails, only the first exception surfaces from PublishAsync. Handlers don't get an isolated DI scope either, so avoid sharing a non-thread-safe scoped service (such as a DbContext) across Parallel entries.

AddNotificationHandler<TNotification> can only be called once per notification type — it throws on a second call, since the Sequence's order is only meaningful when every handler for that notification is declared together.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.0.0 115 8/12/2026
2.0.0 182 7/17/2026
1.0.1 111 7/9/2026
1.0.0 101 7/7/2026