Zenvoy.Abstractions 10.0.0

dotnet add package Zenvoy.Abstractions --version 10.0.0
                    
NuGet\Install-Package Zenvoy.Abstractions -Version 10.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="Zenvoy.Abstractions" Version="10.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Zenvoy.Abstractions" Version="10.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Zenvoy.Abstractions" />
                    
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 Zenvoy.Abstractions --version 10.0.0
                    
#r "nuget: Zenvoy.Abstractions, 10.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 Zenvoy.Abstractions@10.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=Zenvoy.Abstractions&version=10.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Zenvoy.Abstractions&version=10.0.0
                    
Install as a Cake Tool

Zenvoy

A high-performance, zero-allocation, reflection-free mediator for .NET 10 — powered by a C# source generator.

Zenvoy gives you a MediatR-style API (Send, Publish, CreateStream, pipeline behaviors) while generating all dispatch code at compile time:

  • Fast — Send through the generated Mediator compiles down to a direct handler call (sub-nanosecond overhead).
  • Zero allocations on the hot path (send, publish, pipelines) with singleton handlers.
  • Native AOT and trimming compatible — no reflection, no MakeGenericType, no assembly scanning.
  • Compile-time safety — a missing or duplicate handler is a build error, not a runtime surprise.
  • Easy migration from MediatR.

Contents

Installation

Package Reference it from Contents
Zenvoy The host project (Web API, worker, console) Runtime helpers + the source generator. Depends on Zenvoy.Abstractions and Microsoft.Extensions.DependencyInjection.Abstractions.
Zenvoy.Abstractions Domain / application class libraries Interfaces, Unit, attributes. No dependencies.
dotnet add package Zenvoy                # host project
dotnet add package Zenvoy.Abstractions   # class libraries that only declare requests/handlers

The generator runs only in projects that reference Zenvoy. It discovers handlers in the current project and in every referenced assembly that references Zenvoy.Abstractions.

Quick start

// Program.cs
using Zenvoy;

[assembly: ZenvoyOptions(DefaultLifetime = ServiceLifetimeKind.Singleton)]

var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddZenvoy();          // generated: registers the mediator and every handler
var app = builder.Build();

app.MapGet("/ping", (Zenvoy.Generated.Mediator mediator, CancellationToken ct)
    => mediator.Send(new Ping("hello"), ct));   // concrete overload — the fastest path

app.Run();

// Request + handler
public sealed record Ping(string Message) : IRequest<Pong>;
public sealed record Pong(string Message);

public sealed class PingHandler : IRequestHandler<Ping, Pong>
{
    public ValueTask<Pong> Handle(Ping request, CancellationToken ct)
        => new(new Pong($"Pong: {request.Message}"));
}

A complete Minimal API sample lives in samples/Zenvoy.Sample.WebApi.

Features

Requests

// Request with a response
public sealed record GetUser(int Id) : IRequest<User?>;

public sealed class GetUserHandler(AppDb db) : IRequestHandler<GetUser, User?>
{
    public ValueTask<User?> Handle(GetUser request, CancellationToken ct) => db.FindUserAsync(request.Id, ct);
}

// Request without a response (command) — responds with Unit
public sealed record DeleteUser(int Id) : IRequest;

public sealed class DeleteUserHandler(AppDb db) : RequestHandler<DeleteUser>
{
    protected override ValueTask HandleCore(DeleteUser request, CancellationToken ct) => db.DeleteUserAsync(request.Id, ct);
}

RequestHandler<TRequest> is a convenience base class: implement HandleCore returning a plain ValueTask. When it completes synchronously no async state machine is created. You can also implement IRequestHandler<TRequest> directly and return Unit.Task.

Value-type requests are supported without boxing:

public readonly record struct Add(int Left, int Right) : IRequest<int>;

await mediator.Send(new Add(1, 2));               // concrete overload, no boxing
await mediator.Send<Add, int>(new Add(1, 2));     // generic, still no boxing

Sending through ISender/IMediator uses a generated type switch (or a FrozenDictionary above 16 request types). An unknown request type throws HandlerNotFoundException; a null request throws ArgumentNullException.

Notifications

public sealed record UserCreated(int Id) : INotification;

public sealed class SendWelcomeEmail : INotificationHandler<UserCreated>
{
    public ValueTask Handle(UserCreated notification, CancellationToken ct) => ValueTask.CompletedTask;
}

await mediator.Publish(new UserCreated(42), ct);
  • Handlers are matched at compile time, including polymorphic handlers: INotificationHandler<DomainEvent> receives every event deriving from DomainEvent, and INotificationHandler<INotification> receives everything.
  • Handlers run in a deterministic order (by full type name).
  • Sequential (default): handlers run one after another. Synchronously completed handlers are not awaited; the first exception stops publishing.
  • Parallel: all handlers are started, then the pending ones are awaited. Exceptions are collected into an AggregateException (when there are two or more handlers).
  • A notification without handlers is a no-op (reported as the informational diagnostic ZEN007).
  • Zero and one handler get dedicated code paths (no arrays).

Publish<TNotification> through IPublisher dispatches on the runtime type, so publishing an event typed as its base class still reaches the handlers of the concrete type.

Streams

public sealed record Countdown(int From) : IStreamRequest<int>;

public sealed class CountdownHandler : IStreamRequestHandler<Countdown, int>
{
    public async IAsyncEnumerable<int> Handle(Countdown request, [EnumeratorCancellation] CancellationToken ct)
    {
        for (var i = request.From; i >= 0; i--)
        {
            await Task.Delay(100, ct);
            yield return i;
        }
    }
}

await foreach (var n in mediator.CreateStream(new Countdown(3), ct)) { /* ... */ }

Stream pipeline behaviors implement IStreamPipelineBehavior<TRequest, TResponse>.

Pipeline behaviors

[PipelineOrder(0)]   // lower = outer (runs first)
public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    public ValueTask<TResponse> Handle(TRequest request, RequestHandlerDelegate<TRequest, TResponse> next, CancellationToken ct)
    {
        logger.LogDebug("Handling {Request}", typeof(TRequest).Name);
        return next(request, ct);
    }
}
  • next takes the request and the token as parameters: pipelines are built once and reused, with no closures and no per-call allocations.
  • Open generic behaviors are closed over every matching request at compile time. Generic constraints are honored: a behavior declared where TRequest : ICommand is only applied to commands.
  • Closed behaviors (IPipelineBehavior<CreateOrder, int>) apply to a single request.
  • Behaviors may fix the response type: ValidationBehavior<TRequest> : IPipelineBehavior<TRequest, Result>.
  • Ordering: [PipelineOrder] (default 0), then the full type name.
  • A behavior can short-circuit by returning without calling next.
  • Requests without behaviors call the handler directly — no pipeline object is generated.

Lifetimes

Setting Effect
[assembly: ZenvoyOptions(DefaultLifetime = ...)] Default lifetime of handlers and behaviors (default: Singleton).
[ZenvoyHandler(Lifetime = ...)] on a handler/behavior Overrides the lifetime for that type.
  • Singleton: resolved once (lazily, thread-safe) and cached in a field of the mediator.
  • Scoped: resolved once per scope; the mediator itself becomes scoped.
  • Transient: resolved from the container on every call.

The mediator is registered as Singleton when every handler and behavior is a singleton, and as Scoped otherwise. Resolving a scoped mediator from the root provider fails with ValidateScopes = true, as expected.

Multi-project solutions

Only the host project references Zenvoy; class libraries reference Zenvoy.Abstractions. The generator includes all public requests, handlers, behaviors and notifications from referenced assemblies that reference Zenvoy.Abstractions.

Which mediator type to inject

AddZenvoy() registers a single instance under all of these contracts:

Type Dispatch Use it when
Zenvoy.Generated.Mediator Compile-time overload resolution, direct handler call You want the fastest path.
Zenvoy.Generated.IZenvoyMediator Same strongly typed overloads, via an interface You want the fast path and mockability.
Zenvoy.IMediator, ISender, IPublisher Generated type switch / FrozenDictionary Libraries that only know Zenvoy.Abstractions, or MediatR-style code.

Requests whose types are not public get internal overloads on Mediator and are not part of IZenvoyMediator.

ASP.NET Core Minimal APIs with Native AOT

Source generators cannot see each other's output, so ASP.NET Core's Request Delegate Generator (enabled with PublishAot) does not know the generated Mediator type. When you inject Mediator/IZenvoyMediator into minimal API endpoints in an AOT app:

  1. Add a partial declaration so the type is visible to the Request Delegate Generator:

    namespace Zenvoy.Generated;
    
    public sealed partial class Mediator;
    
  2. Give endpoint lambdas an explicit return type:

    app.MapGet("/ping/{message}", ValueTask<Pong> (string message, Mediator mediator, CancellationToken ct)
        => mediator.Send(new Ping(message), ct));
    

Injecting IMediator/ISender needs neither step. Without Native AOT (no Request Delegate Generator) neither is needed.

Configuration

Configuration is done with attributes because the generator reads it at compile time.

[assembly: ZenvoyOptions(
    DefaultLifetime = ServiceLifetimeKind.Singleton,                  // Singleton | Scoped | Transient
    NotificationStrategy = NotificationPublishStrategy.Sequential,    // Sequential | Parallel
    Namespace = "Zenvoy.Generated")]                                  // namespace of the generated Mediator

[ZenvoyHandler(Lifetime = ServiceLifetimeKind.Scoped)]
public sealed class SaveOrderHandler(AppDb db) : IRequestHandler<SaveOrder, int> { /* ... */ }

[PipelineOrder(-10)]
public sealed class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> /* ... */

Diagnostics

All diagnostics are reported at build time by the generator.

ID Severity Description
ZEN001 Error No handler was found for a request.
ZEN002 Error A request has more than one handler.
ZEN003 Error A handler cannot be used (abstract, not accessible, no public constructor, value type).
ZEN004 Error Open generic handlers are not supported (only behaviors may be open generic).
ZEN005 Warning A singleton handler/behavior depends on a type marked [ZenvoyHandler(Lifetime = Scoped)].
ZEN006 Error A stream request has no handler or more than one.
ZEN007 Info A notification has no handlers.

ZEN001

A concrete type implements IRequest<T> but no IRequestHandler<TRequest, T> exists for it (in the project or in a referenced assembly). Add a handler, or make the request abstract if it is only a base type. A handler whose response type differs from the request's declared response type does not count.

ZEN002

Exactly one handler per request is allowed. Remove or merge the extra handlers listed in the message.

ZEN003

Handlers must be non-abstract classes that the generated code can reference (public or internal, not private, not file-local) and that have a public constructor. Abstract base handlers are fine as long as a concrete subclass exists.

ZEN004

Open generic handlers such as class Handler<T> : INotificationHandler<T> are not supported in v1. Use a closed handler, a polymorphic handler (INotificationHandler<IMyEvent>) or an open generic pipeline behavior.

ZEN005

A singleton would capture a scoped dependency. Mark the handler or behavior with [ZenvoyHandler(Lifetime = ServiceLifetimeKind.Scoped)].

ZEN006

Every stream request needs exactly one IStreamRequestHandler<TRequest, T>.

ZEN007

Publishing the notification does nothing. Informational only.

Migrating from MediatR

MediatR Zenvoy
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(...)) services.AddZenvoy() (generated, no scanning)
Task<TResponse> Handle(...) ValueTask<TResponse> Handle(...)
IRequestHandler<TRequest> returning Task RequestHandler<TRequest> with ValueTask HandleCore(...), or IRequestHandler<TRequest> returning ValueTask<Unit>
INotificationHandler<T>.Handle returning Task returns ValueTask
RequestHandlerDelegate<TResponse> next → await next() RequestHandlerDelegate<TRequest, TResponse> next → await next(request, ct)
IStreamPipelineBehavior<TRequest, TResponse> with StreamHandlerDelegate<TResponse> StreamHandlerDelegate<TRequest, TResponse> taking (request, ct)
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)) Nothing to register — open behaviors are discovered and closed at compile time
INotificationPublisher (ForeachAwaitPublisher, TaskWhenAllPublisher) [assembly: ZenvoyOptions(NotificationStrategy = Sequential \| Parallel)]
cfg.Lifetime = ServiceLifetime.Scoped [assembly: ZenvoyOptions(DefaultLifetime = ServiceLifetimeKind.Scoped)]
Pre/post processors, exception handlers Write a pipeline behavior
Missing handler → runtime exception Missing handler → build error ZEN001

Steps:

  1. Replace the MediatR package with Zenvoy (host) / Zenvoy.Abstractions (libraries) and using MediatR; with using Zenvoy;.
  2. Change handler return types from Task to ValueTask (Task.FromResult(x) → new(x), Task.CompletedTask → ValueTask.CompletedTask).
  3. Update behaviors to call next(request, cancellationToken).
  4. Replace AddMediatR(...) with AddZenvoy() and move lifetime / publisher settings to [assembly: ZenvoyOptions].
  5. Optionally inject Zenvoy.Generated.Mediator (or IZenvoyMediator) in hot paths.

Native AOT

Zenvoy is annotated IsAotCompatible and the generated code uses no reflection. The repository verifies this with tests/Zenvoy.AotTests, a console app published with PublishAot=true that exercises send (concrete, generic, struct), void requests, pipelines, publish, streams and scoped handlers; publishing must produce zero IL2xxx/IL3xxx warnings:

scripts/aot-check.sh              # current machine
scripts/aot-check.sh linux-x64    # on Linux (cross-OS Native AOT publishing is not supported by the SDK)

Benchmarks

Apple M5 Pro, .NET 10.0.9, BenchmarkDotNet 0.15.7. Full results and methodology: benchmarks/RESULTS.md.

Scenario Zenvoy (concrete) Zenvoy (IMediator) Mediator 3.0.2 MediatR 12.5.0
Send 0.37 ns / 0 B 2.49 ns / 0 B 0.39 ns (concrete), 2.62 ns (interface) / 0 B 24.4 ns / 128 B
Send + 3 behaviors 1.37 ns / 0 B 3.54 ns / 0 B 4.22 ns / 0 B 75.5 ns / 536 B
Publish, 2 handlers 1.65 ns / 0 B 4.91 ns / 0 B 3.59 ns / 0 B 60.5 ns / 440 B
Cold start (median) 14 µs / 10 KB 49 µs / 48 KB 180 µs / 246 KB

A direct handler call is fully inlined by the JIT (0 ns). Sub-nanosecond numbers vary by about ±0.3 ns between runs.

Limitations

  • Open generic handlers are not supported (open generic behaviors are).
  • Generic request types (Query<T> : IRequest<T>) are not discovered, because the generator cannot enumerate their closed forms. Sending such a request through ISender throws HandlerNotFoundException.
  • Open generic behaviors are closed when each interface type argument is either a concrete type or exactly one of the behavior's type parameters (IPipelineBehavior<TRequest, TResponse>, IPipelineBehavior<TRequest, Result>). Shapes like IPipelineBehavior<TRequest, List<T>> are ignored.
  • Requests are matched to handlers by exact type. Through ISender, a request instance whose runtime type was not discovered falls back to the nearest discovered base request type.
  • Value-type notifications only reach handlers declared for that exact type (contravariance does not apply to value types).
  • Only public types of referenced assemblies are discovered.
  • Sending through a covariant interface (IRequest<object> for a request declaring IRequest<string>) works but takes a slower path (it allocates when the handler completes asynchronously).
  • No pre/post processors or exception handlers — use pipeline behaviors.
  • Targets .NET 10 only.

Building from source

dotnet build -c Release -warnaserror
dotnet test -c Release
scripts/aot-check.sh
dotnet run -c Release --project benchmarks/Zenvoy.Benchmarks -- --filter '*'
dotnet pack src/Zenvoy.Abstractions -c Release -o artifacts/packages && dotnet pack src/Zenvoy -c Release -o artifacts/packages
scripts/verify-packages.sh
scripts/test-local-package.sh     # builds the sample against the local .nupkg files

Snapshot tests store expected generator output in tests/Zenvoy.SourceGenerator.Tests/Snapshots. When output changes intentionally, review the .received.cs files or run the tests with ZENVOY_ACCEPT_SNAPSHOTS=1.

Versions come from git tags via MinVer: tag v10.0.0 produces packages 10.0.0.

The .NET SDK version is pinned in global.json (10.0.403 or a later feature band).

CI/CD

CI runs on GitLab (.gitlab-ci.yml):

Trigger Jobs
Commits land on main (a merge request from master is merged) build: build with -warnaserror, tests, Native AOT check on linux-x64, pack, package verification, sample built from the local packages
v* tag, when the NUGET_API_KEY variable is available release: verifies the tagged commit is on main, then packs and pushes to nuget.org

Pushes to master (or any other branch) and merge request pipelines do not run CI.

License

MIT © Maxsud Mutalov

Product Compatible and additional computed target framework versions.
.NET 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on Zenvoy.Abstractions:

Package Downloads
Zenvoy

Zenvoy: a high-performance, zero-allocation, reflection-free mediator powered by a C# source generator. Native AOT and trimming friendly.

Zenvoy.Results.Mediator

Zenvoy mediator integration for Zenvoy.Results: command/query interfaces, a validation pipeline behavior and an exception-to-Result behavior that return failures without throwing. Reflection-free and Native AOT friendly.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
10.0.0 107 11/8/2026