Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ Releases are cut from this file. The `release` workflow reads the section matchi

## [Unreleased]

### Added

- Dedicated exceptions for the three broken-contract failures that used to throw a plain `InvalidOperationException`: `HandlerNullTaskException` and `StageNullTaskException` for a null task returned from `HandleAsync`, and `OverlappingNextCallException` for a stage that calls `next` while its earlier call is still running. Each carries the type at fault in a property (`RequestType` or `StageType`) instead of only naming it in the message. The two null-task types share an abstract `NullTaskException` base, so one catch clause covers both, and all three still derive from `InvalidOperationException`.

### Changed

- A stage receives `next` as `IContinuation<TResponse>` (or `IContinuation` on the void form) instead of the `StageDelegate` delegate types, which are gone. Stage bodies call `await next.InvokeAsync()` where they called `await next()`. Nothing else about a stage changes. An interface also leaves room to add arguments to a future `InvokeAsync` overload, which a delegate signature cannot take without breaking every stage.
- A stage now resolves from the container when its level first runs rather than up front, and so does the handler. A stage that short-circuits builds neither the stages below it nor the handler behind them, which is the point for a cache stage sitting in front of a repository. A repeated `next` call still walks the instances the dispatch already resolved, the handler included.
- For a request with stages, a container failure building a stage or the handler now surfaces from inside the chain, out of the `next.InvokeAsync()` call that reached that level, where the stages wrapped around it can catch it. A retry stage with a broad `catch` will retry a missing registration. Turn on `ServiceProviderOptions.ValidateOnBuild` to keep registration mistakes at startup.

### Performance

- A dispatch through a chain of N stages allocates N objects instead of 2N+1: the per-level delegate and the per-dispatch stage array are both gone, and a stage that invokes `next` more than once no longer allocates on the repeat. Measured on net10.0 with a synchronous handler, a three-stage chain costs 192 bytes per dispatch against 416 before, and each further stage adds 56 bytes rather than 112.

## [1.0.0-preview.3] - 2026-08-02

### Added
Expand Down
46 changes: 35 additions & 11 deletions docs/exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ Every exception RequestFlow throws, when it surfaces, and how to fix it.
| `RequestFlowValidationException` | Startup validation | Any registration problem; one throw lists all of them |
| `HandlerNotFoundException` | `SendAsync` | The dispatched request type has no registered handler |
| `ResponseTypeMismatchException` | `SendAsync` | The call site's response type differs from the registered one |
| `InvalidOperationException` | `SendAsync` | A handler or stage returned a null task, or a stage overlapped two `next` calls |
| `HandlerNullTaskException` | `SendAsync` | A handler returned a null task from `HandleAsync` |
| `StageNullTaskException` | `SendAsync` | A stage returned a null task from `HandleAsync` |
| `OverlappingNextCallException` | `SendAsync` | A stage called `next` while its earlier call was still running |
| `InvalidOperationException` | `WhereHandlerImplements` | A second handler filter added to one stage |
| `ArgumentNullException` | All public entry points | A required argument is null |
| `ArgumentException` | `RegisterGenericHandler` | `closingTypes` contains a null element |

The three RequestFlow types are sealed, live in the `RequestFlow` namespace in the `RequestFlow.Abstractions` package, and derive from `InvalidOperationException`. All of them signal programmer errors: fix the registration or the call site instead of catching them.
The RequestFlow types live in the `RequestFlow` namespace in the `RequestFlow.Abstractions` package and derive from `InvalidOperationException`. All are sealed except `NullTaskException`, the abstract base the two null-task types share. All of them signal programmer errors: fix the registration or the call site instead of catching them.

## RequestFlowValidationException

Expand Down Expand Up @@ -127,18 +129,38 @@ public sealed class SyncInventory : IRequest, IRequest<SyncReport> { }

With a handler registered as `IRequestHandler<SyncInventory, SyncReport>`, the natural call `SendAsync(new SyncInventory())` cannot infer `TResponse` from two candidate interfaces. It silently binds the void `SendAsync(IRequest)` overload, asks for `NoResult`, and throws. The fix belongs in the model, not the call site: give each request type exactly one `IRequest<TResponse>` interface, and split it in two if both shapes are needed.

## InvalidOperationException
## HandlerNullTaskException

Plain `InvalidOperationException` signals a broken handler or stage contract. Three cases surface at dispatch, one at registration:
Thrown by `SendAsync` when a handler returns a null task from `HandleAsync`. The `RequestType` property holds the request whose handler returned it.

| Message starts with | Thrown from | Fix |
| ----------------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------- |
| `The handler for '...' returned a null task from HandleAsync.` | `SendAsync` | Return a task from every path; use `Task.CompletedTask` or `Task.FromResult` for synchronous results |
| `Stage '...' returned a null task from HandleAsync...` | `SendAsync` | Return the task from `next`, or a completed task when short-circuiting |
| `Stage '...' called next while the task from its earlier call was still running.` | `SendAsync` | Await each `next` call before calling it again; each call runs the rest of the chain |
| `This stage already filters on '...'` | `AddStage` configure delegate | One `WhereHandlerImplements` per stage; give the target handlers one shared contract |
Return a task from every path: `Task.FromResult(value)` for a synchronous result, `Task.CompletedTask` for the void form. The usual source is a test double left without a configured return value.

The null-task checks exist so the failure names the handler or stage at fault instead of surfacing as a `NullReferenceException` at the await. The overlap check stops a stage from running the rest of the chain twice at the same time; a sequential second call, the retry shape, is allowed (see [stages.md](stages.md)).
## StageNullTaskException

Thrown by `SendAsync` when a stage returns a null task from `HandleAsync`. The `StageType` property holds the stage class at fault.

Return the task from `next.InvokeAsync()`, or a completed task when short-circuiting.

Both null-task types derive from `NullTaskException`, so one catch clause covers a handler and a stage:

```csharp
catch (NullTaskException e)
{
// e is a HandlerNullTaskException or a StageNullTaskException
}
```

The base class is abstract with no public constructor, so those two are the only cases it ever holds. Both checks exist so the failure names the handler or stage at fault instead of surfacing as a `NullReferenceException` at the await.

## OverlappingNextCallException

Thrown by `SendAsync` when a stage invokes `next` while the task from its earlier call is still running. The `StageType` property holds the stage class at fault.

Await each call before making the next one. The check stops a stage from running the rest of the chain twice at the same time; a sequential second call, the retry shape, is allowed (see [stages.md](stages.md)).

## Plain InvalidOperationException

One case is left with no type of its own. Adding a second `WhereHandlerImplements` to one stage throws from the `AddStage` configure delegate, with a message starting `This stage already filters on '...'`. A stage takes one handler filter, so give the target handlers one shared contract instead.

## Argument validation

Expand All @@ -161,3 +183,5 @@ Handler and stage exceptions propagate as thrown. The dispatcher and the stage c
Cancellation follows the same rule. The token passes to `HandleAsync` untouched, and an `OperationCanceledException` surfaces from the handler like any other exception.

Container failures keep the container's own exception types. The dispatcher resolves the handler from the service provider on every dispatch, so a handler with a missing constructor dependency, or a scoped handler resolved from the root provider, throws the container's `InvalidOperationException` at dispatch time. See [lifetimes.md](lifetimes.md) for the lifetime rules that prevent these.

On a request with stages, that failure lands inside the chain. Each level resolves when it first runs, so the container's exception comes out of the `next.InvokeAsync()` call that reached the broken level, and the stages wrapped around it can catch it like any other exception. A retry stage with a broad `catch` will retry a missing registration until it runs out of attempts. Catch the exceptions you mean to handle, and turn on `ServiceProviderOptions.ValidateOnBuild` so a registration mistake fails at startup instead.
26 changes: 13 additions & 13 deletions docs/stages.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A stage wraps the handler of every request it applies to: code before and after

## Writing a stage

Implement `IRequestStage<TRequest, TResponse>`. The `next` delegate runs the rest of the chain, ending at the handler:
Implement `IRequestStage<TRequest, TResponse>`. Invoking `next` runs the rest of the chain, ending at the handler:

```csharp
using RequestFlow;
Expand All @@ -13,10 +13,10 @@ public sealed class LoggingStage<TRequest, TResponse> : IRequestStage<TRequest,
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> HandleAsync(
TRequest request, StageDelegate<TResponse> next, CancellationToken cancellationToken)
TRequest request, IContinuation<TResponse> next, CancellationToken cancellationToken)
{
Console.WriteLine($"Handling {typeof(TRequest).Name}");
TResponse response = await next();
TResponse response = await next.InvokeAsync();
Console.WriteLine($"Handled {typeof(TRequest).Name}");
return response;
}
Expand All @@ -25,9 +25,9 @@ public sealed class LoggingStage<TRequest, TResponse> : IRequestStage<TRequest,

A stage has three ways to use `next`:

- Await it once and return its result: the normal pass-through.
- Return without calling it to short-circuit. The handler, and every stage inside this one, never runs.
- Call it again after its task completes to run the rest of the chain again, the shape of a retry stage. A repeated call walks the same stage instances resolved for the dispatch, so state a stage kept from the first pass is still there. Calling `next` while an earlier call is still running throws `InvalidOperationException`.
- Await `next.InvokeAsync()` once and return its result: the normal pass-through.
- Return without invoking it to short-circuit. The handler, and every stage inside this one, never runs. Nothing inside is built either: each level resolves from the container the first time it runs, and that includes the handler, so a cache stage that answers from memory never pays for the repository behind it.
- Invoke it again after its task completes to run the rest of the chain again, the shape of a retry stage. A repeated call walks the same stage instances resolved for the dispatch, and reaches the same handler instance, so state a stage kept from the first pass is still there. Invoking `next` while an earlier call is still running throws `OverlappingNextCallException`.

## Registering

Expand Down Expand Up @@ -56,9 +56,9 @@ public sealed class AuditStage<TRequest, TResponse> : IRequestStage<TRequest, TR
where TRequest : IRequest<TResponse>, IAudited
{
public async Task<TResponse> HandleAsync(
TRequest request, StageDelegate<TResponse> next, CancellationToken cancellationToken)
TRequest request, IContinuation<TResponse> next, CancellationToken cancellationToken)
{
TResponse response = await next();
TResponse response = await next.InvokeAsync();
// write the audit record
return response;
}
Expand All @@ -76,11 +76,11 @@ public sealed class ErrorTranslationStage<TRequest> : IRequestStage<TRequest, Re
where TRequest : IRequest<Result>
{
public async Task<Result> HandleAsync(
TRequest request, StageDelegate<Result> next, CancellationToken cancellationToken)
TRequest request, IContinuation<Result> next, CancellationToken cancellationToken)
{
try
{
return await next();
return await next.InvokeAsync();
}
catch (DomainException e)
{
Expand All @@ -106,13 +106,13 @@ The filter looks at the handler class, not the request, so a module can mark its

## Void requests

A stage for void requests implements `IRequestStage<TRequest>`, takes the parameterless `StageDelegate`, and returns plain `Task`:
A stage for void requests implements `IRequestStage<TRequest>`, takes the void form `IContinuation`, and returns plain `Task`:

```csharp
public sealed class CacheClearGuard : IRequestStage<ClearCache>
{
public Task HandleAsync(ClearCache request, StageDelegate next, CancellationToken cancellationToken)
=> next();
public Task HandleAsync(ClearCache request, IContinuation next, CancellationToken cancellationToken)
=> next.InvokeAsync();
}
```

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System;

namespace RequestFlow;

/// <summary>
/// Thrown by <see cref="IRequestDispatcher.SendAsync{TResponse}"/> when the handler for the
/// dispatched request returns a null task from HandleAsync.
/// </summary>
public sealed class HandlerNullTaskException(Type requestType)
: NullTaskException($"The handler for '{requestType.FullName}' returned a null task from HandleAsync.")
{
/// <summary>
/// The request type whose handler returned the null task.
/// </summary>
public Type RequestType { get; } = requestType;
}
14 changes: 14 additions & 0 deletions src/RequestFlow.Abstractions/Exceptions/NullTaskException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System;

namespace RequestFlow;

/// <summary>
/// Base class for the exceptions thrown when a handler or a stage returns a null task
/// from HandleAsync.
/// </summary>
public abstract class NullTaskException : InvalidOperationException
{
private protected NullTaskException(string message)
: base(message)
{ }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;

namespace RequestFlow;

/// <summary>
/// Thrown by <see cref="IRequestDispatcher.SendAsync{TResponse}"/> when a stage invokes next
/// while the task from its earlier call is still running.
/// </summary>
public sealed class OverlappingNextCallException(Type stageType)
: InvalidOperationException(
$"Stage '{stageType.FullName}' called next while the task from its earlier call was still running. " +
"Await that task before calling next again: each call runs the rest of the chain, so overlapping calls would run it twice at once.")
{
/// <summary>
/// The stage type that called next twice over.
/// </summary>
public Type StageType { get; } = stageType;
}
18 changes: 18 additions & 0 deletions src/RequestFlow.Abstractions/Exceptions/StageNullTaskException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;

namespace RequestFlow;

/// <summary>
/// Thrown by <see cref="IRequestDispatcher.SendAsync{TResponse}"/> when a stage returns a null
/// task from HandleAsync.
/// </summary>
public sealed class StageNullTaskException(Type stageType)
: NullTaskException(
$"Stage '{stageType.FullName}' returned a null task from HandleAsync; " +
"return the task from next, or a completed task when short-circuiting.")
{
/// <summary>
/// The stage type that returned the null task.
/// </summary>
public Type StageType { get; } = stageType;
}
32 changes: 32 additions & 0 deletions src/RequestFlow.Abstractions/IContinuation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Threading.Tasks;

namespace RequestFlow;

/// <summary>
/// The rest of the stage chain below one stage, ending at the request's handler. Invoke it
/// again after its task completes to run the rest of the chain again; invoking it while an
/// earlier call is still running throws <see cref="OverlappingNextCallException"/>.
/// </summary>
/// <remarks>
/// A repeated call walks the same stage instances: the chain resolves them once per
/// dispatch, not per call, so state a stage kept from the first pass is still there.
/// </remarks>
/// <typeparam name="TResponse">The response the chain produces.</typeparam>
public interface IContinuation<TResponse>
{
/// <summary>
/// Runs the rest of the chain.
/// </summary>
Task<TResponse> InvokeAsync();
}

/// <summary>
/// The void form of <see cref="IContinuation{TResponse}"/>, under the same rules.
/// </summary>
public interface IContinuation
{
/// <summary>
/// Runs the rest of the chain.
/// </summary>
Task InvokeAsync();
}
25 changes: 4 additions & 21 deletions src/RequestFlow.Abstractions/IRequestStage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,6 @@

namespace RequestFlow;

/// <summary>
/// Runs the rest of the stage chain, ending at the request's handler. Call it again after
/// its task completes to run the rest of the chain again; calling it while an earlier call
/// is still running throws <see cref="System.InvalidOperationException"/>.
/// </summary>
/// <remarks>
/// A repeated call walks the same stage instances: the chain resolves them once per
/// dispatch, not per call, so state a stage kept from the first pass is still there.
/// </remarks>
/// <typeparam name="TResponse">The response the chain produces.</typeparam>
public delegate Task<TResponse> StageDelegate<TResponse>();

/// <summary>
/// The void form of <see cref="StageDelegate{TResponse}"/>, under the same rules.
/// </summary>
public delegate Task StageDelegate();

/// <summary>
/// Runs around the handler of every request this stage applies to. The implementing
/// class's generic constraints decide which requests those are.
Expand All @@ -30,10 +13,10 @@ public interface IRequestStage<in TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
/// <summary>
/// Wraps the rest of the chain for <paramref name="request"/>. Call
/// Wraps the rest of the chain for <paramref name="request"/>. Invoke
/// <paramref name="next"/> to continue, or skip it to short-circuit.
/// </summary>
Task<TResponse> HandleAsync(TRequest request, StageDelegate<TResponse> next, CancellationToken cancellationToken);
Task<TResponse> HandleAsync(TRequest request, IContinuation<TResponse> next, CancellationToken cancellationToken);
}

/// <summary>
Expand All @@ -45,8 +28,8 @@ public interface IRequestStage<in TRequest>
where TRequest : IRequest<NoResult>
{
/// <summary>
/// Wraps the rest of the chain for <paramref name="request"/>. Call
/// Wraps the rest of the chain for <paramref name="request"/>. Invoke
/// <paramref name="next"/> to continue, or skip it to short-circuit.
/// </summary>
Task HandleAsync(TRequest request, StageDelegate next, CancellationToken cancellationToken);
Task HandleAsync(TRequest request, IContinuation next, CancellationToken cancellationToken);
}
2 changes: 1 addition & 1 deletion src/RequestFlow.Cqrs/CqrsRequestFlowBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
using RequestFlow;
using RequestFlow.Cqrs;

// Microsoft's own convention for registration extensions: AddCqrs is visible in Program.cs without an extra using.
// Same namespace convention as AddRequestFlow: AddCqrs needs no extra using.
namespace Microsoft.Extensions.DependencyInjection;

/// <summary>
Expand Down
Loading