DisPipe 1.3.0

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

DisPipe

Lightweight operation dispatcher for .NET. Automatically pairs IOperation callers with their IOperationHandler implementations at startup using reflection, then dispatches calls through a DI-scoped pipeline.

See CHANGELOG

New feature added: BROADCASTING. A way to have 1 caller with n handlers

Installation

dotnet add package DisPipe

Quick start

1. Define a result DTO

public record UserDto(Guid Id, string Name);

2. Define an operation (query or command)

public class GetUserQuery : IOperation<Result<UserDto>>
{
    public Guid UserId { get; init; }
}

3. Implement its handler

public class GetUserQueryHandler : IOperationHandler<GetUserQuery, Result<UserDto>>
{
    public async Task<Result<UserDto>> Handle(GetUserQuery caller, CancellationToken cancellationToken)
    {
        // ... fetch user ...
        if (user is null)
            return Result.Failure<UserDto>("USER_NOT_FOUND", "No user with that ID exists.");

        return Result.Success(new UserDto(user.Id, user.Name));
    }
}

4. Register DisPipe

// Program.cs
builder.Services.AddDisPipeMediatorPattern(
    assemblies: [typeof(Program).Assembly, typeof(Handlers).Assembly]
);

AddDisPipeMediatorPattern accepts the following arguments:

Parameter Required Description
assemblies Yes Assemblies to scan for IOperationHandler implementations. Pass every assembly that contains your handlers.
configurePipelines No Callback to configure before/after pipeline behaviors. See Pipeline.
assemblyValidator No Predicate to filter which assemblies are scanned. Return true to include, false to skip.
logger No Logger for startup diagnostics and dispatch debug output.
startupCancellationToken No Cancellation token for the startup assembly-scanning phase.

5. Dispatch

public class UserController(IPipeDispatcher dispatcher) : ControllerBase
{
    [HttpGet("{id}")]
    public async Task<IActionResult> Get(Guid id, CancellationToken cancellationToken)
    {
        var result = await dispatcher.Send(new GetUserQuery { UserId = id }, ct);

        if (result.IsFailure)
            return NotFound(result.Error);

        return Ok(result.Value);
    }
}

Pipeline

Handlers can register before and after behaviors that execute around the handler's Handle method.

Behaviors

  • IBeforeBehavior — executes before Handle
  • IAfterBehavior — executes after Handle, receives the result

Both receive IPipelineContext containing:

  • Operation: the dispatched operation instance
  • Metadata: shared state dictionary for all behaviors in this dispatch
  • Result: the handler's result (null in before behaviors)
  • IsCancelled: true if the pipeline was cancelled

Implementing behaviors

You can also use async keyword to avoid returning explicit Task.

A behavior class can implement both interfaces.

public class LoggingBehavior : IBeforeBehavior
{
    public Task Execute(IPipelineContext context, CancellationToken cancellationToken)
    {
        context.Metadata["started_at"] = DateTime.UtcNow;
        return Task.CompletedTask;
    }
}

public class TimingBehavior : IAfterBehavior
{
    public Task Execute(IPipelineContext context, CancellationToken cancellationToken)
    {
        var start = (DateTime)context.Metadata["started_at"];
        Console.WriteLine($"Handler took {(DateTime.UtcNow - start).TotalMilliseconds}ms");
        return Task.CompletedTask;
    }
}

public class ExampleBehavior : IBeforeBehavior, IAfterBehavior
{
    async Task IBeforeBehavior.Execute(IPipelineContext context, CancellationToken cancellationToken)
    {
        // Doing work before running the handler...
    }

    async Task IAfterBehavior.Execute(IPipelineContext context, CancellationToken cancellationToken)
    {
        // Doing work after running the handler...
    }
}

Notice we are not using public within the ExampleBehavior class methods.

Registering behaviors

// Program.cs
builder.Services.AddDisPipeMediatorPattern(
    assemblies: [typeof(Program).Assembly, typeof(Handlers).Assembly],
    configurePipelines: dispatcher => {
        // Apply behaviors globally to all operations
        dispatcher.AddPipeline()
            .AddBefore<LoggingBehavior>()
            .AddAfter<TimingBehavior>()
            .CommitGlobalBehaviour();

        // Or apply to specific operations only
        dispatcher.AddPipeline()
            .AddBefore<LoggingBehavior>()
            .CommitBehaviourForFew(typeof(GetUserQuery));
    }
);

Cancelling the pipeline

A before behavior can cancel the pipeline, preventing the handler and subsequent behaviors from running:

public class AuthBehavior : IBeforeBehavior
{
    public Task Execute(IPipelineContext context, CancellationToken cancellationToken)
    {
        if (!IsAuthenticated())
            context.CancelPipeline("UNAUTHORIZED", "User not authenticated.");
        return Task.CompletedTask;
    }
}

Callers must handle PipelineCancelledException:

try
{
    var result = await dispatcher.Send(new GetUserQuery { UserId = id }, ct);
}
catch (PipelineCancelledException ex)
{
    // ex.ErrorCode, ex.ErrorDetails
    return Unauthorized(ex.ErrorDetails);
}

Error strategy

By default, an unhandled exception in a behavior cancels the pipeline. Use ErrorStrategy.Continue to swallow exceptions and allow the pipeline to continue:

dispatcher.AddPipeline()
    .AddBefore<FlakyBehavior>(ErrorStrategy.Continue)
    .CommitGlobalBehaviour();

Execution order

For each Send() call:

  1. Before behaviors execute in registration order
  2. If pipeline not cancelled, handler executes
  3. After behaviors execute in registration order
  4. If pipeline was cancelled at any point, PipelineCancelledException is thrown

All behaviors are resolved as transient DI instances per-scope, so each Send call gets fresh instances.

Broadcast

IPipeDispatcher.Send routes one operation to exactly one handler. Broadcasting inverts that: one signal fans out to any number of handlers, with no return value. Use it for fire-and-forget notifications where multiple consumers should react independently.

1. Define a signal

public class UserRegistered : ISignal
{
    public Guid UserId { get; init; }
}

2. Implement handlers

Any number of handlers can implement ISignalHandler<TSignal> for the same signal type. All of them are discovered at startup and invoked on every dispatch.

public class SendWelcomeEmail : ISignalHandler<UserRegistered>
{
    public async Task Handle(UserRegistered signal, CancellationToken ct) { ... }
}

public class NotifyAdminSlack : ISignalHandler<UserRegistered>
{
    public async Task Handle(UserRegistered signal, CancellationToken ct) { ... }
}

Startup invariant: every ISignal implementor must have at least one handler. A signal with no handler throws UnmatchedAmountOfCallersException at startup — fail fast, not at runtime.

3. Dispatch

Inject IPipeBroadcaster and choose a dispatch mode:

Method Handler execution Use when
Broadcast Concurrent (Task.WhenAll) Handlers are independent; speed matters
SequentialBroadcast Sequential (foreach await) Order matters, or handlers must not run in parallel
public class UserController(IPipeBroadcaster broadcaster) : ControllerBase
{
    [HttpPost("register")]
    public async Task<IActionResult> Register(RegisterDto dto, CancellationToken ct)
    {
        // ... create user ...
        await broadcaster.Broadcast(new UserRegistered { UserId = user.Id }, ct);
        return Ok();
    }
}

IPipeBroadcaster is registered automatically by AddDisPipeMediatorPattern — no extra setup needed. It shares the same CallDispatcher instance as IPipeDispatcher.

BroadcastPipelineMode

Both dispatch methods accept beforeMode and afterMode flags that control when pipeline behaviors run relative to each handler:

public enum BroadcastPipelineMode
{
    Once,       // behaviors run once, shared PipelineContext across all handlers (default)
    PerHandler, // behaviors run once per handler, each with its own isolated PipelineContext
}

The two flags are independent — mix and match:

beforeMode afterMode Behavior
Once Once Before runs once → all handlers → after runs once. One shared context.
PerHandler Once Before runs per-handler with isolated context → after runs once on shared context.
Once PerHandler Before runs once → each handler gets isolated after-behavior with its own context.
PerHandler PerHandler Each handler runs its own before+after, sharing one context only between its own two phases.
// defaults — Once / Once
await broadcaster.Broadcast(signal, ct);

// per-handler before, shared after
await broadcaster.Broadcast(signal, ct, beforeMode: BroadcastPipelineMode.PerHandler);

// shared before, per-handler after
await broadcaster.Broadcast(signal, ct, afterMode: BroadcastPipelineMode.PerHandler);

// fully isolated per handler
await broadcaster.Broadcast(signal, ct, BroadcastPipelineMode.PerHandler, BroadcastPipelineMode.PerHandler);

When to use PerHandler: if behaviors write handler-specific data into Metadata, use PerHandler to prevent cross-handler contamination. In Broadcast (parallel), PerHandler is also required to avoid data races — Metadata is a plain Dictionary, not thread-safe.

Cancellation scope: in Once mode, a behavior calling CancelPipeline() cancels the entire broadcast. In PerHandler mode, cancellation is scoped to the individual handler — other handlers are unaffected.

Broadcast exceptions

Exception Thrown when
UnmatchedSignalException A ISignalHandler<> class's generic argument doesn't implement ISignal — caught at startup
SignalHandlerNotImplementedException A class passes the signal-handler scanner check but lacks a proper ISignalHandler<> interface — caught at startup

Results

DisPipe ships two result types.

Result<T> — general purpose

Result<T> ok  = Result.Success(value);
Result<T> err = Result.Failure<T>("ERROR_CODE", "Human-readable details.");

result.IsSuccess  // bool
result.IsFailure  // bool
result.Value      // T?
result.Error      // IFailedResult? — contains Code, Details, OcurredAt

Note: Value returns the original object reference — no deep copy is made. Treat the returned value as read-only after passing it to Result.Success().

ControllerResult<T> — HTTP-aware

Wraps Result<T> and adds an HttpStatusCode. Implicitly converts from Result<T>.

ControllerResult<T> ok  = ControllerResult.Success(value, HttpStatusCode.OK);
ControllerResult<T> err = ControllerResult.Failure<T>("ERR", "Details.", HttpStatusCode.NotFound);

result.StatusCode  // HttpStatusCode?

Interfaces

Interface Purpose
IOperation<TReturn> Marker for a query or command
IOperationHandler<TOperation, TReturn> Handles a specific operation
IPipeDispatcher Dispatches an operation to its handler
IPipeDispatcherServiceRegister Registers all discovered handlers into the DI container
IResult / IResult<T> Base result contract
IControllerResult<T> HTTP-aware result contract
IFailedResult Error details (Code, Details, OcurredAt)

Metadata

IPipelineContext.Metadata is a shared Dictionary<string, object?> available to all behaviors in a single dispatch. It is scoped to one Send() call and is not accessible to the caller.

Warning: Do not store sensitive data (tokens, credentials, PII) in Metadata. All behaviors registered for that operation can read every key.

How pairing works

At startup CallDispatcher scans the assemblies passed to its constructor for classes implementing IOperationHandler<TOperation, TReturn> and builds an internal Operation → Handler dictionary. Send<TReturn> looks up the handler for the given operation type, resolves it from a DI scope, and invokes Handle via reflection.

Every IOperation must have exactly one matching IOperationHandler. A mismatch in count throws UnmatchedAmountOfCallersException at startup.

Exceptions

Exception When thrown
MissingHandlerException No handler found for the dispatched operation
HandlerNotImplementedException A handler class does not implement IOperationHandler
UnmatchedCallerException A handler has no associated caller type
UnmatchedAmountOfCallersException Number of callers ≠ number of handler pairs
NullHandleInvocationException Handle returned null instead of a result

Changelog

Changelog v1.3.0

Summary: Major feature addition since v1.2.2. Introduces a full broadcasting system alongside the existing operation/send pipeline. Users can now define fire-and-forget signals that fan out to multiple handlers, with concurrent or sequential execution, full before/after pipeline behavior support, and fine-grained control over behavior isolation per handler. No breaking changes.


New Features

ISignal — marker interface for broadcast messages

New interface for declaring fire-and-forget messages. Unlike IOperation<TReturn>, signals carry no return value and can be handled by multiple handlers simultaneously. Implement it on any class you want to broadcast:

public class UserRegistered : ISignal { ... }

ISignalHandler<TSignal> — handler for a signal

Counterpart to IOperationHandler<,> for the broadcast world. One signal type can have any number of handlers — all are discovered at startup and all are invoked on each dispatch:

public class SendWelcomeEmail : ISignalHandler<UserRegistered>
{
    public async Task Handle(UserRegistered signal, CancellationToken ct) { ... }
}

public class NotifyAdminSlack : ISignalHandler<UserRegistered>
{
    public async Task Handle(UserRegistered signal, CancellationToken ct) { ... }
}

Startup invariant: every ISignal implementation must have at least one handler. A signal with no handler throws UnmatchedAmountOfCallersException at startup — fail-fast, not at runtime.


IPipeBroadcaster — new dispatch interface

Two dispatch methods, both injectable directly:

Method Execution Use when
Broadcast Concurrent (Task.WhenAll) Handlers are independent; speed matters
SequentialBroadcast Sequential (foreach await) Order matters, or handlers must not run in parallel

IPipeBroadcaster is registered automatically by AddDisPipeMediatorPattern as a singleton that shares the same CallDispatcher instance as IPipeDispatcher — no extra setup needed.

services.AddDisPipeMediatorPattern(assemblies: [typeof(Program).Assembly]);
// IPipeDispatcher and IPipeBroadcaster are both resolvable

ignorePipelines parameter

Both methods expose bool ignorePipelines = false. When true, all registered before/after behaviors are skipped and handlers are invoked directly. Use this for internal or system-triggered dispatches where pipeline overhead (auth checks, logging, rate limiting) is unnecessary or undesirable.

// Bypass all behaviors — handlers run directly
await broadcaster.Broadcast(signal, ct, ignorePipelines: true);
await broadcaster.SequentialBroadcast(signal, ct, ignorePipelines: true);

Note: ignorePipelines takes precedence over beforeMode/afterMode — those parameters are ignored when ignorePipelines is true.


BroadcastPipelineMode — control behavior isolation per broadcast

Broadcast methods accept two independent mode flags — beforeMode and afterMode — that control when pipeline behaviors run:

public enum BroadcastPipelineMode
{
    Once,       // behaviors run once, shared PipelineContext across all handlers (default)
    PerHandler, // behaviors run once per handler, each with its own isolated PipelineContext
}

The four combinations and their effects:

beforeMode afterMode Behavior
Once Once Before runs once → all handlers → after runs once. One shared context.
PerHandler Once Before runs per-handler with isolated context → after runs once on shared context.
Once PerHandler Before runs once → each handler gets isolated after-behavior with its own context.
PerHandler PerHandler Each handler runs its own before+after with a single context shared only between its own two phases.

When to use PerHandler: use PerHandler when behaviors write handler-specific data into Metadata that must not be visible across handler boundaries. In Once mode, behaviors share one PipelineContext — entries written by a before-behavior are still available to the after-behavior, but all handlers run against that same shared context.

Cancellation scope: in Once mode, a behavior cancelling the pipeline via CancelPipeline() affects the entire broadcast. In PerHandler mode, cancellation is scoped to the individual handler — other handlers are unaffected.


New startup exceptions

Two new exceptions thrown at startup (not at dispatch time) when signal-handler wiring is invalid:

Exception Thrown when
UnmatchedSignalException A ISignalHandler<> implementation's generic argument doesn't implement ISignal
SignalHandlerNotImplementedException A class is detected as a signal handler during scanning but doesn't properly implement ISignalHandler<>

Security Hardening

Behavior error handling

  • ErrorStrategy.Continue now emits a LogWarning when a before-behavior fails and the handler still executes. Previously only LogError fired; the new message explicitly flags the security implication: "SECURITY: Before-behavior {Name} failed with ErrorStrategy.Continue — handler will still execute".
  • ex.Message no longer leaks into PipelineCancelledException.Details. When a behavior fails with ErrorStrategy.CancelPipeline, Details is now a generic "A pipeline behavior failed." string. The full exception (including original message and stack trace) is logged via LogError instead — it was previously not logged at all in this path.

Assembly scanning

  • LogWarning emitted when no assemblyValidator is provided. All assemblies are still scanned (behavior unchanged), but the absence of validation is now visible in logs at startup.

Scope factory

  • SetScopeFactory is now idempotent-guarded. Calling it a second time throws InvalidOperationException. Previously, the scope factory could be silently replaced after startup.

Null safety

  • ArgumentNullException.ThrowIfNull added at the entry of Send, Broadcast, and SequentialBroadcast. Passing null previously caused an unguarded NullReferenceException deeper in the call stack.

Concurrency

  • _alreadyRegistered is now thread-safe. Changed from bool to int with Interlocked.Exchange in Register() and Volatile.Read in AddPipeline(). The previous plain-bool read/write was a TOCTOU race under concurrent startup.

Internal Changes

Compiled delegates for signal dispatch

Signal handlers use the same zero-reflection-per-dispatch delegate compilation pattern as operation handlers. At startup, CreateSignalWrapper<THandlerInterface, TOperation>() is closed via MakeGenericMethod once; at dispatch time, the cached Func<object, object, CancellationToken, Task> is called directly.

CallDispatcher split into partial classes

The class was refactored from one file into four partial files:

File Responsibility
CallDispatcher.cs Core fields, constructors, shared helpers (ExecuteBehaviors, HandleBehaviorError, ThrowIfCancelled)
CallDispatcher.Send.cs IPipeDispatcher.Send<TReturn> implementation
CallDispatcher.Broadcast.cs IPipeBroadcaster.Broadcast and SequentialBroadcast implementations, plus extracted helpers (RunHandlersParallel, RunHandlerWithPerScopeContext, AwaitAllPreservingAggregateException)
CallDispatcher.Populator.cs Assembly scanning, pairing logic, delegate compilation

DispatchablePairs record

OperationPairsPopulator.GetOperationOperationPairs now returns a DispatchablePairs record instead of a single dictionary:

internal sealed record DispatchablePairs(
    Dictionary<Type, HandlerTyping> OperationsPairs,
    Dictionary<Type, HandlerTyping[]> SignalPairs  // one signal → many handlers
);

HandlerTyping struct extended

Added InvokeSignalDelegate (Func<object, object, CancellationToken, Task>) alongside the existing InvokeOperationDelegate. This allows a single HandlerTyping to represent either kind of handler without a separate type.


Upgrade

No changes needed for existing code. AddDisPipeMediatorPattern now additionally registers IPipeBroadcaster — inject it wherever you need to broadcast signals.

To adopt broadcasting:

  1. Implement ISignal on your signal classes.
  2. Implement ISignalHandler<TSignal> on one or more handlers per signal.
  3. Inject IPipeBroadcaster and call Broadcast or SequentialBroadcast.
  4. Optionally pass BroadcastPipelineMode arguments to control behavior isolation.

Changelog v1.2.2

Summary: One bug fix and one breaking change since v1.2.1. AddDisPipeMediatorPattern no longer calls BuildServiceProvider() internally — a DI anti-pattern that produced a second root container and could cause singleton scoping bugs. As a consequence, CallDispatcher constructors no longer accept IServiceScopeFactory; the factory is now injected lazily via a new SetScopeFactory method.


Bug Fix

AddDisPipeMediatorPattern no longer calls BuildServiceProvider() at startup

Previously the extension method called services.BuildServiceProvider() to extract an IServiceScopeFactory before handing it to CallDispatcher. This is an ASP.NET Core anti-pattern: it creates a second root container, which means singleton services are instantiated twice and the built-in IHostApplicationLifetime/environment integrations resolve incorrectly. ASP.NET Core itself warns about this at runtime.

IPipeDispatcher is now registered as a factory lambda. The IServiceScopeFactory is resolved from the real, final IServiceProvider on first use and injected into the dispatcher via the new SetScopeFactory method. No user-facing behavior changes for code that goes through AddDisPipeMediatorPattern.


Breaking Changes

CallDispatcher constructors no longer accept IServiceScopeFactory

All four public overloads had IServiceScopeFactory scopeFactory as their first parameter. That parameter is removed. If you construct CallDispatcher manually (outside of AddDisPipeMediatorPattern), you must now call SetScopeFactory before the first Send call.

Before:

var dispatcher = new CallDispatcher(scopeFactory, assemblies);

After:

var dispatcher = new CallDispatcher(assemblies);
dispatcher.SetScopeFactory(scopeFactory);

Send<TReturn> throws if SetScopeFactory was never called

Calling Send without a scope factory now throws InvalidOperationException with the message:

IServiceScopeFactory not initialized. Ensure AddDisPipeMediatorPattern was called during startup.

Previously this would have been a NullReferenceException with no actionable message.

Upgrade

If you use AddDisPipeMediatorPattern: no changes needed.

If you construct CallDispatcher manually: remove IServiceScopeFactory from the constructor call and add a SetScopeFactory call before dispatching.


Changelog v1.2.1

Summary: Just merging changelog with README.


Changelog v1.2.0

Summary: Two additions since v1.1.0 — a new AddDisPipeMediatorPattern extension method and an IPipelineConfigurator interface. No breaking changes, no removals. This is a pure DX improvement: users no longer manually wire CallDispatcher; they call one extension method from startup.


New Features

ServiceCollectionExtensions.AddDisPipeMediatorPattern

Replaces the manual CallDispatcher construction + Register + AddSingleton boilerplate with a single services.AddDisPipeMediatorPattern(assemblies) call. Before this, users had to instantiate CallDispatcher themselves, call Register, and register IPipeDispatcher manually. Now all three steps collapse into one.

Parameters beyond assemblies are all optional:

  • configurePipelines — fluent callback for attaching before/after behaviors at startup
  • assemblyValidator — predicate to filter which assemblies are scanned (useful when passing broad assemblies)
  • logger — startup diagnostics
  • startupCancellationToken — cancel the assembly-scan phase

IPipelineConfigurator

New public interface exposing AddPipeline() — the pipeline-behavior configuration surface. CallDispatcher already implemented pipeline config internally; this interface extracts it as the public type passed to the configurePipelines callback. Users interact with it only inside that callback.

Internal Changes

IPipeDispatcherServiceRegister

Now internal and extends IPipelineConfigurator. Previously public — this is technically a breaking change for anyone who referenced IPipeDispatcherServiceRegister directly, but given it was an infrastructure-internal interface with no meaningful public contract, the practical impact is near zero.


Upgrade

Replace manual CallDispatcher wiring with services.AddDisPipeMediatorPattern(assemblies). No handler code changes needed.


Changelog v1.1.0

Overview

DisPipe added a complete pipeline system for middleware-style before/after handler execution, implemented 5 critical security fixes (reflection, assembly validation, cancellation handling, type confusion, debug logging), and refactored CallDispatcher into partial classes for maintainability. One breaking change: interface rename.

Breaking Changes
IOperationsServiceRegister → IPipeDispatcherServiceRegister

Interface renamed; signature unchanged. Update your DI registration code if you reference this type directly.

CallDispatcher Constructor

New optional Func<Assembly, bool> assemblyValidator parameter added. Existing constructors still work (backward-compatible), but you can now validate which assemblies load handlers at startup.

Pipeline Context in Dispatch Flow

Handler invocation now executes within before/after pipeline. If you inspected dispatch internals (reflection, method invocation), that path changed. Most users unaffected.

New Features
Pipeline System
  • IBeforeBehavior / IAfterBehavior — Middleware-style hooks around handler execution. Register via IBeforeBuilder / IAfterBuilder (fluent API).
  • IPipelineContext — Shared context passed through pipeline stages. Behaviors can pass data and control flow (commit/cancel pipeline).
  • ErrorStrategy enum — Handle errors in pipeline: Throw, Log, Ignore.

Use case: Logging, validation, metrics, cross-cutting concerns without handler modification.

Assembly Validation

Pass assemblyValidator to CallDispatcher constructor to define which assemblies are trusted. Prevents loading untrusted handlers. Raises UntrustedAssemblyException if validation fails.

Security Fixes
Unsafe Reflection in Handler Invocation

Replaced MethodInfo.Invoke() with pre-compiled delegates (cached at startup). Eliminates reflection overhead and method invocation vulnerabilities.

Uncontrolled Assembly Loading

Added optional assembly validator. You now control which assemblies are trusted, preventing unauthorized handler injection.

Type Confusion via Generic Matching

Stricter validation prevents duplicate handler registration for same operation. New DuplicateHandlerException on startup if misconfigured.

OperationCanceledException Swallowing

Explicit cancellation handling. Cancellation tokens now respected; exceptions no longer silently caught.

Information Disclosure via Debug Logging

Debug logs now use Type.Name instead of Type.FullName, reducing namespace exposure in logs.

Internal Changes
CallDispatcher Split

Now 3 partial classes: core dispatch logic, assembly scanning/pairing, DI registration. Cleaner, more maintainable.

New Exceptions
  • DuplicateHandlerException — Multiple handlers for same operation
  • PipelineCancelledException — Pipeline execution was cancelled
  • PipelineConfigurationException — Pipeline misconfiguration
  • UntrustedAssemblyException — Assembly failed validation
Result Mutability Warning

Result<T>.Value returns original reference. Treat as read-only; mutations after creation not supported.

Tests

PipelineTests (304 lines) — Covers pipeline behavior, context passing, cancellation, error strategies, and builder fluent API.

Upgrade Path

Update interface references (IOperationsServiceRegister). Optional: add assembly validator and pipeline behaviors. No code changes required for basic use.

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
1.3.0 91 5/17/2026
1.2.2 110 4/29/2026
1.2.1 111 4/21/2026 1.2.1 is deprecated because it has critical bugs.
1.2.0 106 4/21/2026 1.2.0 is deprecated because it has critical bugs.
1.1.0 91 4/21/2026
1.0.2 100 4/15/2026
1.0.1 110 4/15/2026
1.0.0 100 4/14/2026