Skip to content
TurgayTurkPublic

About

Fast, lightweight mediator for .NET with CQRS, pipelines, notifications, and a Roslyn source generator. Includes optional analyzers.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

49 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Dualis

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> with IRequestHandler<>/IRequestHandler<,>
  • Request exceptions: IRequestExceptionHandler<,,> and IRequestExceptionAction<,>
  • Pipelines: request/response and void pipeline behaviors (plus unified behaviour option)
  • Notifications: fan-out publish with failure strategies and alternative publishers
  • Public AddDualis entry point for DI; source generator augments it in the host project

What's new (0.4.0.4)

  • Fixed: FallbackDualizor.Publish (the reflection fallback's notification path) silently skipped the unified IPipelineBehaviour<TNotification, Unit> around notification dispatch — 0.4.0.3 only fixed this for Send/Send<TResponse>. It now chains unified behaviors around Publish too, matching the generated dispatcher.
  • Audited Dualis.Analyzer for 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.

What's new (0.4.0.3)

  • Fixed: the reflection fallback mediator (FallbackDualizor, used when the generator hasn't run) silently skipped IPipelineBehavior<,>/IPipelineBehavior<> (and the unified IPipelineBehaviour<,>) — it dispatched straight to the handler with no error or warning. It now builds the same behavior chain the generated dispatcher builds, including PipelineOrderAttribute ordering. If you're relying on AddDualis() without the generator enabled and behaviors weren't running, upgrade to 0.4.0.3. See CHANGELOG.md.

What's new (0.4.0.2)

  • Fixed: cross-assembly handler/behavior discovery now honors InternalsVisibleTo, not just public visibility. If you declare internal IRequestHandler/INotificationHandler/IPipelineBehavior types 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 runtime InvalidOperationException: Unknown request type: ... for any internal handler — even though generation otherwise appeared to succeed. See CHANGELOG.md.

What's new (0.4.0.1)

  • Fixed: the 0.4.0 package shipped without the source generator (missing analyzers/dotnet/cs assets) 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 ignored DualizorOptions.Pipelines/CQRS/Notifications manual registrations and skipped auto-discovery. It now correctly falls back to the existing AddDualisRuntime reflection path. If you previously found that opts.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 ProjectReference to the project that declares your PackageReference Include="Dualis" (a common DDD/Clean Architecture split), add a direct PackageReference Include="Dualis" to the host project too — see Multi-project (DDD/Clean Architecture) setup.

What's new (0.4.0)

  • Breaking: IRequestExceptionHandler<,,>/IRequestExceptionAction<,> moved to Dualis.Pipeline; RequestExceptionState<T> renamed to RequestExceptionHandlerState<T>, matching MediatR.Pipeline exactly. See Migrating from MediatR.Pipeline below.
  • Breaking: removed the where TRequest : IRequest<TResponse>/IRequest constraints from those interfaces, unblocking open-generic catch-all exception handlers.
  • Fixed: Dualis.Analyzer DULIS014 no longer false-positives on such handlers. See src/Dualis.Analyzer/README.md.

What's new (0.2.5)

  • Added: Dualis.Analyzer project. 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.

Install

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 generation (host-only)

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 EmitCompilerGeneratedFiles to inspect generated files under obj/generated.

Quick start

  1. Register in DI. The public AddDualis is 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();
  1. 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));
}
  1. Use ISender in 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.

Auto-registration, manual registration, and options

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 include ParallelWhenAllNotificationPublisher).
  • 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).

Using Dualis without the generator (runtime path)

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.Dualizor if 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.

Multi-project (DDD/Clean Architecture) setup

  • 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 via InternalsVisibleTo to the host).
  • Other projects should NOT enable the generator.
  • The host project must have a direct PackageReference Include="Dualis", even if it also has a ProjectReference to an Application/Domain layer that already references Dualis. NuGet analyzers (the source generator) do not flow across ProjectReference edges — only the compiled types do. A host with only ProjectReference Application.csproj -> Dualis will compile fine and call AddDualis() 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."

Request exception handling

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:

  1. Execute handler/pipeline.
  2. If an exception is thrown, run matching IRequestExceptionHandler implementations from most specific exception type to base types.
  3. If a handler marks state.SetHandled(response), return that response.
  4. If still unhandled, run matching IRequestExceptionAction implementations 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;
    }
}

Migrating from MediatR.Pipeline

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.

Pipelines

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.

Notifications

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, throw AggregateException of failures
  • ContinueAndLog � log and swallow failures
  • StopOnFirstException � stop immediately on first failure (sequential)

Choose publisher implementation via NotificationPublisherFactory (default: SequentialNotificationPublisher; alternatives: ParallelWhenAllNotificationPublisher, ChannelNotificationPublisher).

Source generator details

  • Emits Dualis.Dualizor, the mediator/dispatcher used by IDualizor.
  • 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.

Gating

The generator runs when any of these is true in the host project:

  • MSBuild property DualisEnableGenerator is visible to the compiler and set to true.
  • A .editorconfig/.globalconfig sets build_property.DualisEnableGenerator = true.
  • An assembly-level attribute [assembly: Dualis.EnableDualisGeneration] is present.

Troubleshooting

  • "AddDualis not found" or IServiceCollection missing extension:
    • Ensure the host project references Dualis and has using Dualis; in scope.
    • Ensure generation is enabled in the host (property, attribute, or .editorconfig).
    • Clean bin/obj and rebuild to clear stale analyzer artifacts.
  • Ambiguous AddDualis call (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 public or exposed via InternalsVisibleTo to the host.
    • Ensure the host references the assemblies containing the handlers.
    • Ensure the host project itself has a direct PackageReference Include="Dualis" — a transitive ProjectReference to a project that references Dualis is not enough for the analyzer to load in the host's compilation (see Multi-project setup).
  • opts.Pipelines.Register<T>() (or CQRS/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: unified IPipelineBehaviour<,> behaviors did not run around notification dispatch through the reflection fallback, even though 0.4.0.3 had already fixed Send/Send<TResponse>. Upgrade to 0.4.0.4+.
    • Confirm generation actually ran: set EmitCompilerGeneratedFiles and check for Dualizor.g.cs/ServiceCollectionExtensions.g.cs under obj/generated.
  • Runtime InvalidOperationException: Unknown request type: ... even though generation appears to have run (files exist under obj/generated):
    • On Dualis < 0.4.0.2, internal handler/behavior types in a referenced project were silently excluded from cross-assembly discovery even with InternalsVisibleTo configured 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, or internal with a matching [assembly: InternalsVisibleTo("HostAssemblyName")] in the project that declares it — the name must match the host's actual assembly name (AssemblyName MSBuild property), not just the project/folder name.

Benchmarks

Basic microbenchmarks live under tests/Dualis.Benchmarks. Run in Release:

dotnet run -c Release --project tests/Dualis.Benchmarks/Dualis.Benchmarks.csproj

Requirements

  • Runtime library targets .NET 9
  • Source generator targets .NET Standard 2.0 (works across SDKs/tooling)

Contributing

Issues and PRs are welcome. Please run unit tests and benchmarks before submitting changes.

License

MIT

About

Fast, lightweight mediator for .NET with CQRS, pipelines, notifications, and a Roslyn source generator. Includes optional analyzers.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages