Fast, lightweight mediator for .NET with unified requests, pipelines, and notifications. Dualis uses a Roslyn source generator to emit dispatcher and DI registration code at build time, keeping runtime overhead and allocations low while offering a clean, opinionated API.
- Requests:
IRequest/IRequest<T>withIRequestHandler<>/IRequestHandler<,> - Request exceptions:
IRequestExceptionHandler<,,>andIRequestExceptionAction<,> - Pipelines: request/response and void pipeline behaviors (plus unified behaviour option)
- Notifications: fan-out publish with failure strategies and alternative publishers
- Public
AddDualisentry point for DI; source generator augments it in the host project
- Fixed:
FallbackDualizor.Publish(the reflection fallback's notification path) silently skipped the unifiedIPipelineBehaviour<TNotification, Unit>around notification dispatch — 0.4.0.3 only fixed this forSend/Send<TResponse>. It now chains unified behaviors aroundPublishtoo, matching the generated dispatcher. - Audited
Dualis.Analyzerfor CQRS/Pipeline/Notification/Exception rule coverage — no gaps found requiring a new rule (Pipeline behaviors are intentionally unconstrained, so a DULIS005/DULIS014-style contract check would misfire on generic middleware). Added a missing test file for DULIS013 (ServiceLocatorAnalyzer), which previously had none. See CHANGELOG.md.
- Fixed: the reflection fallback mediator (
FallbackDualizor, used when the generator hasn't run) silently skippedIPipelineBehavior<,>/IPipelineBehavior<>(and the unifiedIPipelineBehaviour<,>) — it dispatched straight to the handler with no error or warning. It now builds the same behavior chain the generated dispatcher builds, includingPipelineOrderAttributeordering. If you're relying onAddDualis()without the generator enabled and behaviors weren't running, upgrade to 0.4.0.3. See CHANGELOG.md.
- Fixed: cross-assembly handler/behavior discovery now honors
InternalsVisibleTo, not just public visibility. If you declareinternalIRequestHandler/INotificationHandler/IPipelineBehaviortypes in a referenced project and grant the host access via[assembly: InternalsVisibleTo("YourHost")], they are now correctly discovered. Previously they were silently skipped, producing a partially-generated dispatcher and a runtimeInvalidOperationException: Unknown request type: ...for any internal handler — even though generation otherwise appeared to succeed. See CHANGELOG.md.
- Fixed: the 0.4.0 package shipped without the source generator (missing
analyzers/dotnet/csassets) due to an MSBuild evaluation-order bug in packaging — no code was generated for any consumer of 0.4.0. See CHANGELOG.md. - Fixed:
AddDualis()'s fallback path (used when the generator hasn't run) silently ignoredDualizorOptions.Pipelines/CQRS/Notificationsmanual registrations and skipped auto-discovery. It now correctly falls back to the existingAddDualisRuntimereflection path. If you previously found thatopts.Pipelines.Register<T>()"didn't work," upgrade to 0.4.0.1 — that was this bug, not the API. - If you're on 0.4.0 and your host project has only a
ProjectReferenceto the project that declares yourPackageReference Include="Dualis"(a common DDD/Clean Architecture split), add a directPackageReference Include="Dualis"to the host project too — see Multi-project (DDD/Clean Architecture) setup.
- Breaking:
IRequestExceptionHandler<,,>/IRequestExceptionAction<,>moved toDualis.Pipeline;RequestExceptionState<T>renamed toRequestExceptionHandlerState<T>, matchingMediatR.Pipelineexactly. See Migrating from MediatR.Pipeline below. - Breaking: removed the
where TRequest : IRequest<TResponse>/IRequestconstraints from those interfaces, unblocking open-generic catch-all exception handlers. - Fixed:
Dualis.AnalyzerDULIS014 no longer false-positives on such handlers. See src/Dualis.Analyzer/README.md.
- Added:
Dualis.Analyzerproject. See installation and rule details in the analyzer README: src/Dualis.Analyzer/README.md. - Changed: tiny performance refactoring in the source generator/dispatcher code paths.
See full details in CHANGELOG.md.
NuGet:
dotnet add package Dualis
The package includes the source generator as analyzer assets. You must opt-in to generation in your host project.
Enable the generator only in the project where you call services.AddDualis() (the composition root: API/Web/Worker). Do not enable it in Domain/Application/Infrastructure projects.
Options to enable in the host:
- MSBuild property (recommended):
<PropertyGroup>
<DualisEnableGenerator>true</DualisEnableGenerator>
</PropertyGroup>
<ItemGroup>
<CompilerVisibleProperty Include="DualisEnableGenerator" />
</ItemGroup>
- Assembly attribute:
using Dualis;
[assembly: EnableDualisGeneration]
- .editorconfig/.globalconfig:
is_global = true
build_property.DualisEnableGenerator = true
Notes:
- Only the host should enable generation.
- The generator output is internal and not an extension method, so it cannot collide with the public entry.
- You can optionally set
EmitCompilerGeneratedFilesto inspect generated files underobj/generated.
- Register in DI. The public
AddDualisis always available; when the generator runs in the host, it augments the registration.
var builder = WebApplication.CreateBuilder(args);
// Dualis auto-discovers and registers handlers, pipeline behaviors, and notifications
// from the host compilation and referenced assemblies. No manual DI registration required.
builder.Services.AddDualis();
var app = builder.Build();
- Define a query and handler:
public sealed record GetUserByNameQuery(string Name) : IRequest<UserDto?>;
public sealed class GetUserByNameQueryHandler : IRequestHandler<GetUserByNameQuery, UserDto?>
{
public Task<UserDto?> Handle(GetUserByNameQuery request, CancellationToken ct)
=> Task.FromResult<UserDto?>(new UserDto(request.Name));
}
- Use
ISenderin Minimal API to send the request:
app.MapGet("/users/{name}", async (string name, ISender sender, CancellationToken ct) =>
{
UserDto? result = await sender.Send(new GetUserByNameQuery(name), ct);
return result is null ? Results.NotFound() : Results.Ok(result);
});
await app.RunAsync();
Note: IDualizor implements both ISender and IPublisher. Inject either if you only need a subset.
By default, AddDualis will automatically register all discovered request handlers, pipeline behaviors, and notification handlers. You can disable any auto-registration and manually register specific pieces using DualizorOptions.
Disable auto-registration and register manually:
builder.Services.AddDualis(opts =>
{
// Disable auto-registration for any component type as needed
opts.RegisterDiscoveredBehaviors = false;
opts.RegisterDiscoveredCqrsHandlers = false;
opts.RegisterDiscoveredNotificationHandlers = false;
// Manually register pipeline behaviors (request/response or void)
opts.Pipelines.Register<LoggingBehavior<GetUserByNameQuery, UserDto?>>();
opts.Pipelines.Register<VoidBehavior<SomeCommand>>();
// Manually register notification handlers
opts.Notifications.Register<UserCreatedEventHandler>();
// You can also register handlers via DI if preferred
// services.AddScoped<IRequestHandler<GetUserByNameQuery, UserDto?>, GetUserByNameQueryHandler>();
// Request exception contracts can also be registered manually
opts.CQRS.Register<GetUserByNameExceptionHandler>();
opts.CQRS.Register<GetUserByNameExceptionAction>();
// Notifications: choose publisher and failure policy
opts.NotificationPublisherFactory = sp => sp.GetRequiredService<ParallelWhenAllNotificationPublisher>();
opts.NotificationFailureBehavior = NotificationFailureBehavior.ContinueAndAggregate;
opts.MaxPublishDegreeOfParallelism = Environment.ProcessorCount;
});
Options overview (non-exhaustive):
RegisterDiscoveredBehaviors/RegisterDiscoveredCqrsHandlers/RegisterDiscoveredNotificationHandlers: toggles for auto-registration.Pipelines: registry for manual behavior registration and pipeline settings (also controls behavior auto-registration enablement).Notifications: registry for manual notification handler registration.NotificationPublisherFactory: selects the publisher implementation (sequential is default; alternatives includeParallelWhenAllNotificationPublisher).NotificationFailureBehavior:ContinueAndAggregate,ContinueAndLog,StopOnFirstException.MaxPublishDegreeOfParallelism: degree of parallelism when using parallel publisher.
Behavior ordering: behaviors run outer ? inner in registration order; annotate with PipelineOrderAttribute to control execution order when using auto-registration (lower runs earlier).
If you cannot or do not want to enable the generator, use:
services.AddDualisRuntime(opts =>
{
// Configure options, registries, and optional runtime discovery flags here.
});
- Uses the generated
Dualis.Dualizorif present; otherwise falls back to a reflection-based mediator. - Applies manual registries (
opts.Pipelines,opts.CQRS,opts.Notifications) and can perform basic runtime discovery when enabled by options flags.
- Reference Dualis abstractions wherever needed.
- Enable generation only in the host project and call
services.AddDualis()there. - The generator scans the host compilation (and referenced assemblies) to discover public
IRequestHandler<>/IRequestHandler<,>and pipeline behaviors.
Requirements:
- Handler and behavior types in referenced assemblies must be
public(or visible viaInternalsVisibleToto the host). - Other projects should NOT enable the generator.
- The host project must have a direct
PackageReference Include="Dualis", even if it also has aProjectReferenceto an Application/Domain layer that already references Dualis. NuGet analyzers (the source generator) do not flow acrossProjectReferenceedges — only the compiled types do. A host with onlyProjectReference Application.csproj -> Dualiswill compile fine and callAddDualis()without errors, but the generator never actually runs in the host's own compilation, so nothing gets generated there. This is the most common cause of "the generator works in my sample but not in my layered app."
Dualis supports MediatR-like request exception contracts, in Dualis.Pipeline (mirroring MediatR.Pipeline):
IRequestExceptionHandler<TRequest, TResponse, TException>IRequestExceptionAction<TRequest, TException>RequestExceptionHandlerState<TResponse>
Runtime behavior for Send:
- Execute handler/pipeline.
- If an exception is thrown, run matching
IRequestExceptionHandlerimplementations from most specific exception type to base types. - If a handler marks
state.SetHandled(response), return that response. - If still unhandled, run matching
IRequestExceptionActionimplementations and rethrow.
Multiple handlers can coexist for unrelated exception types/hierarchies and request shapes — each is resolved independently per exception-type level, so a closed handler for one query and an open-generic catch-all handler for a different exception hierarchy don't interfere with each other.
Open-generic, independently-constrained "catch-all" handlers are supported, e.g.:
using Dualis.Pipeline;
public sealed class ServerExceptionHandler<TRequest, TResponse, TException>
: IRequestExceptionHandler<TRequest, TResponse, TException>
where TRequest : IQuery<Result<int>>
where TResponse : Result<int>
where TException : ServerException
{
public Task Handle(TRequest request, TException exception, RequestExceptionHandlerState<TResponse> state, CancellationToken cancellationToken)
{
state.SetHandled((TResponse)Result.Failure<int>(new Error("ServerExceptionHandler", exception.Message)));
return Task.CompletedTask;
}
}Existing IRequestExceptionHandler/IRequestExceptionAction implementations — including open-generic/constrained catch-all handlers like the one above — can be ported by swapping the using statement only:
-using MediatR.Pipeline;
+using Dualis.Pipeline;No other code changes are needed: type names (IRequestExceptionHandler<,,>, IRequestExceptionAction<,>, RequestExceptionHandlerState<TResponse>) and constraint shapes match MediatR exactly.
Two primary forms are supported:
- Request/response:
IPipelineBehavior<TRequest, TResponse> - Void request:
IPipelineBehavior<TRequest>
A unified form IPipelineBehaviour<TMessage, TResponse> can apply to both requests and notifications (Unit for void).
Behaviors are executed in registration order (outer -> inner). You can annotate behaviors with PipelineOrderAttribute to control ordering when auto-registered. Lower values run earlier.
Define a notification and handlers:
public sealed record UserCreatedEvent(Guid Id) : INotification;
public sealed class UserCreatedEventHandler : INotificationHandler<UserCreatedEvent>
{
public Task HandleAsync(UserCreatedEvent n, CancellationToken ct)
=> Task.CompletedTask;
}
Publish from anywhere you have IPublisher/IDualizor:
await dualizor.Publish(new UserCreatedEvent(id));
Choose failure behavior:
ContinueAndAggregate� run all handlers, throwAggregateExceptionof failuresContinueAndLog� log and swallow failuresStopOnFirstException� stop immediately on first failure (sequential)
Choose publisher implementation via NotificationPublisherFactory (default: SequentialNotificationPublisher; alternatives: ParallelWhenAllNotificationPublisher, ChannelNotificationPublisher).
- Emits
Dualis.Dualizor, the mediator/dispatcher used byIDualizor. - Emits an internal, non-extension DI method in
Dualis.Generated:ServiceCollectionExtensions.AddDualis(IServiceCollection, Action<DualizorOptions>?). - The public runtime extension
Dualis.ServiceCollectionExtensions.AddDualis(this IServiceCollection, Action<DualizorOptions>?)invokes the internal method reflectively when the generator runs in the host; otherwise it falls back to a runtime registration path.
The generator runs when any of these is true in the host project:
- MSBuild property
DualisEnableGeneratoris visible to the compiler and set totrue. - A
.editorconfig/.globalconfigsetsbuild_property.DualisEnableGenerator = true. - An assembly-level attribute
[assembly: Dualis.EnableDualisGeneration]is present.
- "AddDualis not found" or
IServiceCollectionmissing extension:- Ensure the host project references
Dualisand hasusing Dualis;in scope. - Ensure generation is enabled in the host (property, attribute, or
.editorconfig). - Clean bin/obj and rebuild to clear stale analyzer artifacts.
- Ensure the host project references
- Ambiguous
AddDualiscall (CS0121):- Ensure only one Dualis analyzer is active (from the NuGet package you packed). Remove any old/stale analyzers and clean caches.
- Only the host should enable generation.
- Not all handlers are auto-registered:
- Ensure handler/behavior classes are
publicor exposed viaInternalsVisibleToto the host. - Ensure the host references the assemblies containing the handlers.
- Ensure the host project itself has a direct
PackageReference Include="Dualis"— a transitiveProjectReferenceto a project that references Dualis is not enough for the analyzer to load in the host's compilation (see Multi-project setup).
- Ensure handler/behavior classes are
opts.Pipelines.Register<T>()(orCQRS/Notifications) behaviors don't seem to run:- On Dualis < 0.4.0.1, this could happen if the generator wasn't active in the host (see above) — the fallback path silently skipped manual registries. Upgrade to 0.4.0.1+.
- On Dualis < 0.4.0.3, even with manual/auto registries reaching DI correctly, the reflection fallback dispatcher itself never invoked pipeline behaviors at all (handler called directly, no chain) whenever the generator wasn't active — no error, just silently weaker behavior than the generated path. Upgrade to 0.4.0.3+.
- On Dualis < 0.4.0.4, this also applied specifically to
Publish: unifiedIPipelineBehaviour<,>behaviors did not run around notification dispatch through the reflection fallback, even though 0.4.0.3 had already fixedSend/Send<TResponse>. Upgrade to 0.4.0.4+. - Confirm generation actually ran: set
EmitCompilerGeneratedFilesand check forDualizor.g.cs/ServiceCollectionExtensions.g.csunderobj/generated.
- Runtime
InvalidOperationException: Unknown request type: ...even though generation appears to have run (files exist underobj/generated):- On Dualis < 0.4.0.2,
internalhandler/behavior types in a referenced project were silently excluded from cross-assembly discovery even withInternalsVisibleToconfigured correctly — generation succeeded but produced a dispatcher missing those request types specifically. Upgrade to 0.4.0.2+. - Otherwise, double-check the failing request's handler is either
public, orinternalwith a matching[assembly: InternalsVisibleTo("HostAssemblyName")]in the project that declares it — the name must match the host's actual assembly name (AssemblyNameMSBuild property), not just the project/folder name.
- On Dualis < 0.4.0.2,
Basic microbenchmarks live under tests/Dualis.Benchmarks. Run in Release:
dotnet run -c Release --project tests/Dualis.Benchmarks/Dualis.Benchmarks.csproj
- Runtime library targets .NET 9
- Source generator targets .NET Standard 2.0 (works across SDKs/tooling)
Issues and PRs are welcome. Please run unit tests and benchmarks before submitting changes.
MIT
