Zendiator 0.2.0

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

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.Abstractions and Zendiator (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();
  • IZendiator is generated into the configured namespace.
  • SendAsync generates request-specific overloads, including supported generic request shapes. There is no generic send API taking IRequest<T> or object. Sending from a variable declared with a derived contract (such as ICommand<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 with AddZendiator configuration 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 ServiceLifetime is 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 Order wraps outer.
  • Duplicating a Behavior type or an Order value 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 next calls 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 plus GetFingerprint()
  • 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 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.

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
0.2.0 83 9/13/2026
0.1.2 98 9/11/2026
0.1.1 97 9/10/2026
0.1.0 96 9/10/2026

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