Zendiator 0.2.0
dotnet add package Zendiator --version 0.2.0
NuGet\Install-Package Zendiator -Version 0.2.0
<PackageReference Include="Zendiator" Version="0.2.0" />
<PackageVersion Include="Zendiator" Version="0.2.0" />
<PackageReference Include="Zendiator" />
paket add Zendiator --version 0.2.0
#r "nuget: Zendiator, 0.2.0"
#:package Zendiator@0.2.0
#addin nuget:?package=Zendiator&version=0.2.0
#tool nuget:?package=Zendiator&version=0.2.0
Zendiator
日本語 | Design and maintenance principles
A small Mediator for .NET 10 that generates typed dispatch at compile time.
A Roslyn Incremental Source Generator produces per-request SendAsync overloads,
struct continuation nodes, and DI registration. Typed request dispatch needs no
runtime assembly scanning, reflective invocation, or dynamic.
- Targets: .NET 10 (C# 14, nullable enabled)
- Distribution: 2 packages,
Zendiator.AbstractionsandZendiator(versioned together) - Status: pre-1.0 preview; breaking changes are allowed
- Repository: https://github.com/Htkym/zendiator
- License: MIT
Installation
dotnet add package Zendiator
Zendiator includes an Abstractions dependency and the source generator. A
contracts-only project can reference Zendiator.Abstractions instead. Pin the
package versions used by your application and keep both packages aligned.
This README describes the current repository, which may differ from published packages. Consult the release notes for the version you use; do not assume that installing a released package includes every change described here.
Suggested project responsibilities:
| Project | References |
|---|---|
| Contracts (message definitions) | Zendiator.Abstractions only |
| Application (handlers, Behaviors, DI configuration) | Zendiator (includes Abstractions transitively) |
| Host (startup, composition) | Application (calls AddApplication()-style wrappers) |
Place configuration where it won't create a back-reference from the handler side. Roslyn is not a runtime dependency. The generator itself ships inside the Zendiator package under analyzers/dotnet/cs.
Usage
Register the current compilation with one call. No separate initialization or custom provider is required:
using Zendiator.DependencyInjection;
services.AddZendiator();
Use the normal host builder.Build() or standard DI container construction. Use
the configuration lambda shown below when you need other assemblies, behaviors,
or an explicit generated namespace.
Define messages and handlers. You can use class, record, struct, and record struct.
Responses may use your own Result types or nullable types.
using Zendiator;
public sealed record MemorialTargetDto(int Year, IReadOnlyList<string> Names);
public readonly record struct GetTargetYearQuery(int Year) : IQuery<MemorialTargetDto>;
public sealed class GetTargetYearQueryHandler : IQueryHandler<GetTargetYearQuery, MemorialTargetDto>
{
public ValueTask<MemorialTargetDto> HandleAsync(GetTargetYearQuery query, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
return new(new MemorialTargetDto(query.Year, [$"Household-{query.Year}-1"]));
}
}
For a multi-project application, configure from the composition root. Attributes and an empty mediator class are not required:
using Microsoft.Extensions.DependencyInjection;
using Zendiator.DependencyInjection;
namespace MyApp.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddZendiator(static configuration =>
{
configuration.Namespace = "MyApp.Application.Generated";
configuration.RegisterServicesFromAssemblyContaining<ApplicationAssemblyMarker>();
configuration.RegisterServicesFromAssemblyContaining<ContractsAssemblyMarker>();
configuration.AddOpenBehavior(typeof(LoggingBehavior<,>), order: 0);
});
return services;
}
}
services.AddApplication();
IZendiatoris generated into the configured namespace.SendAsyncgenerates request-specific overloads, including supported generic request shapes. There is no generic send API takingIRequest<T>orobject. Sending from a variable declared with a derived contract (such asICommand<T>) is not supported. Only calls whose static type is the concrete type are covered.- Attribute-based configuration (
[GenerateZendiator]class or assembly attributes) remains available as an alternative declaration style; it cannot be combined withAddZendiatorconfiguration lambdas in one compilation.
Calling code:
using MyApp.Application.Generated;
var services = new ServiceCollection();
services.AddApplication();
await using var provider = services.BuildServiceProvider(
new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true });
await using var scope = provider.CreateAsyncScope();
var zendiator = scope.ServiceProvider.GetRequiredService<IZendiator>();
var result = await zendiator.SendAsync(new GetTargetYearQuery(2026));
Registration uses TryAdd. Pre-existing registrations are not replaced.
Pre-registered lifetimes are preserved, but each mediator lazily captures its first Handler and Behavior instance, including Transient dependencies.
Repeated registration does not duplicate. IZendiator resolves the Zendiator with the same lifetime.
Streaming
Define a stream request and handler. One request maps to exactly one handler.
public sealed record GetHouseholdNames(int Count) : IStreamRequest<string>;
public sealed class GetHouseholdNamesHandler : IStreamRequestHandler<GetHouseholdNames, string>
{
public async IAsyncEnumerable<string> HandleAsync(
GetHouseholdNames request,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
for (var i = 0; i < request.Count; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Yield();
yield return $"Household-{i}";
}
}
}
Register an open stream behavior alongside regular behaviors:
configuration.AddOpenBehavior(typeof(LoggingBehavior<,>), order: 0);
configuration.AddOpenStreamBehavior(typeof(StreamLoggingBehavior<,>), order: 2);
Consume lazily. The handler starts on first MoveNextAsync, not on StreamAsync.
Either the API token or WithCancellation can cancel; different tokens are linked only when both are cancelable and different.
await foreach (var name in zendiator.StreamAsync(new GetHouseholdNames(3), cancellationToken))
{
Console.WriteLine(name);
}
ref struct requests use the sync contract (ISyncRequest + SendSync); async routes
and stream items diagnose them (ZEN0012). Re-enumeration is not guaranteed; call StreamAsync again for a fresh stream.
Open-generic handlers closed over a value type need runtime generic construction,
which NativeAOT cannot provide (see known limitations).
Lifetimes
The default is Scoped. Select Singleton or Transient per registration call:
services.AddZendiator(static configuration =>
{
configuration.RegisterServicesFromAssemblyContaining<GetTargetYearQueryHandler>();
configuration.ServiceLifetime = ServiceLifetime.Singleton;
});
Rules:
- The default
ServiceLifetimeis Scoped. The value flows at runtime and never changes the generated structure, so providers may differ. - The specified lifetime applies to newly added Zendiator, handler, and Behavior registrations. Existing registrations and their dependencies retain their own lifetimes; validate scopes to catch Singleton services capturing Scoped dependencies.
- First registration wins. A later registration does not replace existing registrations.
- Out-of-range values are diagnosed at generation (
ZEN0018) when constant. - Each mediator captures dependencies on first use and reuses them, including Transient dependencies. Resolve a new transient mediator for a fresh composition. The no-construction guarantee on short-circuit (downstream Behaviors and handlers are not resolved) is unchanged.
Use Singleton only when the mediator, handlers, Behaviors, and their dependencies are safe to share across concurrent calls. Keep Scoped for services tied to a request scope.
Behavior
Behaviors are DI-resolved classes; continuations are generated readonly structs.
Continuations are never converted to delegates or interface variables.
public interface IRequestContinuation<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
ValueTask<TResponse> InvokeAsync(TRequest request, CancellationToken cancellationToken);
}
public interface IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
ValueTask<TResponse> HandleAsync<TNext>(
TRequest request, TNext next, CancellationToken cancellationToken)
where TNext : struct, IRequestContinuation<TRequest, TResponse>;
}
Rules:
- Smaller
Orderwraps outer. - Duplicating a Behavior type or an
Ordervalue is an error (ZEN0004). - Closed Behaviors and two-argument open Behaviors of the form
Behavior<TRequest, TResponse>are supported. Open Behaviors apply only to requests satisfying the constraints (struct,class,notnull,new(), base/interface, including nested generics). - On short-circuit, downstream Behaviors and handlers are not resolved. A referenced-but-unconstructed handler is never created.
- Requests and
CancellationTokens may be replaced when passed to next. - Retries via sequential repeated
nextcalls are supported. Parallel calls and retention after completion are out of scope. - Null reference-type requests are rejected, cancellation is checked before each node runs. Exceptions are not translated.
See samples/ for a runnable example. Its Contracts/Application/Host 3-project layout shows
a logging Behavior and success/failure handling with your own Result<T, E>.
dotnet run --project samples/Zendiator.Sample.Host -c Release
Multiple assemblies
Only the current compilation and assemblies named by configuration are inspected
via Roslyn symbols (RegisterServicesFromAssemblyContaining<T>() or
RegisterServicesFromAssembly(typeof(X).Assembly)).
Separately from whole-assembly scanning, the exact request types referenced by handler contracts are also picked up.
So even if you forget to include the assembly holding request types in the scan targets,
requests identical to or reachable from handlers become routes. To avoid unintended pickup,
review handler placement and assembly registrations.
Different spellings that resolve to the same assembly set share one generation unit.
Diagnostics
| ID | Condition |
|---|---|
| ZEN0001 | No handler for the target request |
| ZEN0002 | Multiple handler implementations on a single request |
| ZEN0003 | Multiple response contracts, unsupported types (non-public, inaccessible, response mismatch) |
| ZEN0004 | Behavior contract mismatch, duplication, order conflict, closed Behavior matching no route |
| ZEN0005 | Bad generation-target declaration, duplication, reserved collisions |
| ZEN0006 | Conflicting generation modes (class + assembly attributes) |
| ZEN0007 | Invalid generation namespace or generated-name collision |
| ZEN0008 | Conflicting kinds (native-void/Unit, single/multi, sync/async, response/void multi) |
| ZEN0009 | Generic binding not inferable from the request |
| ZEN0010 | Ambiguous closed/open binding (single routes; multi routes fan out instead) |
| ZEN0011 | Incompatible constraints between request, handler, and Behavior |
| ZEN0012 | Invalid ref route (async/ref boxing, ref response, sync guidance) |
| ZEN0013 | Invalid subscriber or registration target |
| ZEN0014 | Reserved (never emitted; the notification erasure route is supported) |
| ZEN0015 | Attribute and DI configuration sources combined in one compilation |
| ZEN0016 | Multiple DI configurations with different structures |
| ZEN0017 | Unsupported configuration expression or callback shape |
| ZEN0018 | Invalid configuration value (namespace, duplicates, lifetime range, order conflicts) |
| ZEN0019 | Ambiguous registration binding (reserved) |
| ZEN0020 | AddZendiator call cannot be connected to generated registration |
Public API
Zendiator.Abstractions (net10.0, no external dependencies) holds the contracts:
- Requests:
IRequest<TResponse>,ICommand<TResponse>,IQuery<TResponse>,IRequest,ICommand,IMultiRequest<TResponse>,IMultiRequest,ISyncRequest<TResponse>,ISyncRequest,ISyncCommand,ISyncMultiRequest<TResponse>,ISyncMultiRequest,INotification,Unit,IStreamRequest<TItem> - Handlers:
IRequestHandler<TRequest, TResponse>,IRequestHandler<TRequest>,ICommandHandler<TCommand, TResponse>,ICommandHandler<TCommand>,IQueryHandler<TQuery, TResponse>,INotificationHandler<TNotification>,ISyncRequestHandler<TRequest, TResponse>,ISyncRequestHandler<TRequest>,IStreamRequestHandler<TRequest, TItem> - Pipelines:
IRequestContinuation<TRequest, TResponse>,IRequestContinuation<TRequest>,IPipelineBehavior<TRequest, TResponse>,IPipelineBehavior<TRequest>,ISyncRequestContinuation<TRequest, TResponse>,ISyncRequestContinuation<TRequest>,ISyncPipelineBehavior<TRequest, TResponse>,ISyncPipelineBehavior<TRequest>,IStreamContinuation<TRequest, TItem>,IStreamPipelineBehavior<TRequest, TItem> - Attributes:
GenerateZendiatorAttribute,IncludeAssemblyAttribute,PipelineBehaviorAttribute,HandlerOrderAttribute,NotificationAttribute
Zendiator (net10.0) holds the DI entry points:
ZendiatorConfiguration:Namespace,ServiceLifetime,RegisterServicesFromAssemblyContaining<T>(),RegisterServicesFromAssembly(Assembly),AddOpenBehavior(Type, int),AddOpenStreamBehavior(Type, int),AddNotification<T>(),ConfigureHandlerOrder(Type, int),Snapshot()ZendiatorConfigurationSnapshot: frozen recorded values plusGetFingerprint()ZendiatorServiceCollectionExtensions.AddZendiator(parameterless and configuration-lambda overloads; unintercepted calls fail fast)
Generated code per consumer compilation (IZendiator, Zendiator, and either
ZendiatorServiceCollectionExtensions.AddZendiator or the DI registrar plus
interceptors) is treated as part of the product.
Before 1.0.0, APIs and architecture may change without a compatibility mode.
Document breaking changes explicitly rather than retaining an obsolete execution path.
Performance
benchmarks/Zendiator.Benchmarks compares direct calls against typed sends.
With warmed-up scopes and synchronously completing allocation-free handlers/Behaviors,
0 B of additional allocation per send is verified (the 0- and 1-stage sync paths are also pinned by tests).
First-time DI resolution, logging, and async suspension are outside that 0 B claim. No latency numbers are guaranteed.
Dispatch uses one lazy, mediator-instance cache with standard DI construction. Use services.AddZendiator() and normal BuildServiceProvider() or host construction; no custom provider or fast-mode switch is required. See construction and dispatch lifetime for the Transient breaking change, disposal rules, and measurement boundaries.
Historical release measurements are in the 0.1.0 release notes and performance record. They describe their measured revisions, not the current lazy-capture architecture. Values apply only to the measured routes and environment; generic response creation, full scope lifecycle, and asynchronously suspending streams have separate allocation costs. No competitor ranking or general allocation-free claim is made.
The generator also uses structural comparison of immutable, symbol-free models to skip template expansion when output is unchanged. Moving a DI registration updates interceptor locations independently of the mediator body. Semantic analysis still runs on compilation changes; this is not per-type incremental analysis. See the constitution for the design and measurement rules.
AOT and trimming
IsAotCompatible is set, and generated code uses no reflection.
AOT/trim warnings are treated as errors, not suppressed.
A full native AOT link needs the native toolchain. For your own app referencing
the NuGet package, publish with PublishAot enabled (replace the example path):
dotnet publish MyApp/MyApp.csproj -c Release -r win-x64 -p:PublishAot=true
CI uses package-based consumers for smoke tests and the Native AOT matrix, rather than treating the in-repository ProjectReference sample as proof of package support. See the CI workflow for the checks and known limitations for release-specific evidence and the open-generic/value-type boundary.
Out of scope (follow-ups)
Parallel publish, fire-and-forget, persistence/outbox, Send(object) for requests,
cycle detection, CodeFix, CodeLens, built-in Result pipeline mapping,
and built-in logging/validation are out of scope for the first release.
Sequential PublishAsync, streams via StreamAsync, and your own
Result types as ordinary TResponse values are supported.
For migrating from MediatR, see the migration guide.
Development
Start with Constitution.md for library and generator design, dependency lifetime, performance acceptance criteria, test scope, and source management. See source layout for file placement. The CI workflow defines integration checks; package versions are defined in Directory.Build.props.
| 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
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- Zendiator.Abstractions (>= 0.2.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
Zendiator 0.2.0: unified lazy dispatch with standard DI and AddZendiator(), incremental source generation improvements, and updated design and migration guides. Breaking change: each mediator reuses the first resolved handler and behavior instance per service type, including Transient registrations. See https://github.com/Htkym/zendiator/blob/v0.2.0/docs/release/0.2.0-release-notes.md