DisPipe 1.3.0
dotnet add package DisPipe --version 1.3.0
NuGet\Install-Package DisPipe -Version 1.3.0
<PackageReference Include="DisPipe" Version="1.3.0" />
<PackageVersion Include="DisPipe" Version="1.3.0" />
<PackageReference Include="DisPipe" />
paket add DisPipe --version 1.3.0
#r "nuget: DisPipe, 1.3.0"
#:package DisPipe@1.3.0
#addin nuget:?package=DisPipe&version=1.3.0
#tool nuget:?package=DisPipe&version=1.3.0
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.
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 beforeHandleIAfterBehavior— executes afterHandle, receives the result
Both receive IPipelineContext containing:
Operation: the dispatched operation instanceMetadata: shared state dictionary for all behaviors in this dispatchResult: the handler's result (null in before behaviors)IsCancelled: true if the pipeline was cancelled
Implementing behaviors
You can also use
asynckeyword to avoid returning explicit Task.
A
behaviorclass 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
publicwithin theExampleBehaviorclass 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:
- Before behaviors execute in registration order
- If pipeline not cancelled, handler executes
- After behaviors execute in registration order
- If pipeline was cancelled at any point,
PipelineCancelledExceptionis 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
ISignalimplementor must have at least one handler. A signal with no handler throwsUnmatchedAmountOfCallersExceptionat 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:
Valuereturns the original object reference — no deep copy is made. Treat the returned value as read-only after passing it toResult.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.Continuenow emits aLogWarningwhen a before-behavior fails and the handler still executes. Previously onlyLogErrorfired; the new message explicitly flags the security implication:"SECURITY: Before-behavior {Name} failed with ErrorStrategy.Continue — handler will still execute".ex.Messageno longer leaks intoPipelineCancelledException.Details. When a behavior fails withErrorStrategy.CancelPipeline,Detailsis now a generic"A pipeline behavior failed."string. The full exception (including original message and stack trace) is logged viaLogErrorinstead — it was previously not logged at all in this path.
Assembly scanning
LogWarningemitted when noassemblyValidatoris provided. All assemblies are still scanned (behavior unchanged), but the absence of validation is now visible in logs at startup.
Scope factory
SetScopeFactoryis now idempotent-guarded. Calling it a second time throwsInvalidOperationException. Previously, the scope factory could be silently replaced after startup.
Null safety
ArgumentNullException.ThrowIfNulladded at the entry ofSend,Broadcast, andSequentialBroadcast. Passingnullpreviously caused an unguardedNullReferenceExceptiondeeper in the call stack.
Concurrency
_alreadyRegisteredis now thread-safe. Changed frombooltointwithInterlocked.ExchangeinRegister()andVolatile.ReadinAddPipeline(). 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:
- Implement
ISignalon your signal classes. - Implement
ISignalHandler<TSignal>on one or more handlers per signal. - Inject
IPipeBroadcasterand callBroadcastorSequentialBroadcast. - Optionally pass
BroadcastPipelineModearguments 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 startupassemblyValidator— predicate to filter which assemblies are scanned (useful when passing broad assemblies)logger— startup diagnosticsstartupCancellationToken— 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 operationPipelineCancelledException— Pipeline execution was cancelledPipelineConfigurationException— Pipeline misconfigurationUntrustedAssemblyException— 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 | 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 (>= 10.0.5)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.