Zenvoy.Abstractions
10.0.0
dotnet add package Zenvoy.Abstractions --version 10.0.0
NuGet\Install-Package Zenvoy.Abstractions -Version 10.0.0
<PackageReference Include="Zenvoy.Abstractions" Version="10.0.0" />
<PackageVersion Include="Zenvoy.Abstractions" Version="10.0.0" />
<PackageReference Include="Zenvoy.Abstractions" />
paket add Zenvoy.Abstractions --version 10.0.0
#r "nuget: Zenvoy.Abstractions, 10.0.0"
#:package Zenvoy.Abstractions@10.0.0
#addin nuget:?package=Zenvoy.Abstractions&version=10.0.0
#tool nuget:?package=Zenvoy.Abstractions&version=10.0.0
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 —
Sendthrough the generatedMediatorcompiles 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
- Quick start
- Features
- Which mediator type to inject
- Configuration
- Diagnostics
- Migrating from MediatR
- Native AOT
- Benchmarks
- Limitations
- Building from source
- CI/CD
- License
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 fromDomainEvent, andINotificationHandler<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);
}
}
nexttakes 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 : ICommandis 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](default0), 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:
Add a partial declaration so the type is visible to the Request Delegate Generator:
namespace Zenvoy.Generated; public sealed partial class Mediator;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:
- Replace the
MediatRpackage withZenvoy(host) /Zenvoy.Abstractions(libraries) andusing MediatR;withusing Zenvoy;. - Change handler return types from
TasktoValueTask(Task.FromResult(x)→new(x),Task.CompletedTask→ValueTask.CompletedTask). - Update behaviors to call
next(request, cancellationToken). - Replace
AddMediatR(...)withAddZenvoy()and move lifetime / publisher settings to[assembly: ZenvoyOptions]. - Optionally inject
Zenvoy.Generated.Mediator(orIZenvoyMediator) 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 throughISenderthrowsHandlerNotFoundException. - 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 likeIPipelineBehavior<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 declaringIRequest<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 | Versions 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. |
-
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 |