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
44 changes: 31 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,44 +1,62 @@
# RequestFlow

A small, fast request/handler library for .NET. You define a request and its handler, register them with one call, and dispatch through a single interface. Handler lookup is validated at startup and served from a frozen map, so nothing on the dispatch path uses reflection.

Composable stages for cross-cutting concerns (validation, logging, authorization) are the next planned piece: a request will flow through its stages, then into the handler, and the response back out. They are not in the current preview.
A small, fast request/handler library for .NET. You define a request and its handler, register them with one call, and dispatch through a single interface. All the wiring happens at runtime, once at startup, with no compiler plugin and no build-time code generation: if a project can reference a NuGet package, it can run RequestFlow.

The core library stays unopinionated about how you name your requests. If you want a type-level split between commands and queries for CQRS- and DDD-style apps, install `RequestFlow.Cqrs` instead; it already contains the core package.

[![NuGet](https://img.shields.io/nuget/vpre/RequestFlow?label=nuget)](https://www.nuget.org/packages/RequestFlow)
[![Downloads](https://img.shields.io/nuget/dt/RequestFlow?label=downloads)](https://www.nuget.org/packages/RequestFlow)
[![CI](https://github.com/illia1f/RequestFlow/actions/workflows/ci.yml/badge.svg)](https://github.com/illia1f/RequestFlow/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/illia1f/RequestFlow/blob/main/LICENSE)
![Status](https://img.shields.io/badge/status-preview-orange)
![Targets](https://img.shields.io/badge/targets-netstandard2.0%20%7C%20net462%20%7C%20net8.0%20%7C%20net10.0-512BD4)

> **Status:** preview on NuGet. The request/handler core, startup validation, and the CQRS package are live; stages are still in development. Install with the `--prerelease` flag:
> **Status:** [preview on NuGet](https://www.nuget.org/packages/RequestFlow). Install with the `--prerelease` flag:
>
> ```
> dotnet add package RequestFlow --prerelease
> ```

## Why

[MediatR](https://github.com/LuckyPennySoftware/MediatR) went commercial in 2025, and it had been the default for this kind of work for years. The free options that remain fall into two camps. Some are general-purpose mediators that treat every message the same, with no distinction between a command and a query. Others are older and narrower, like Microsoft's [CQRS.Mediatr.Lite](https://github.com/microsoft/CQRS.Mediatr.Lite), which last shipped in 2021 and solved one team's problem before going quiet.
[MediatR](https://github.com/LuckyPennySoftware/MediatR) went commercial in 2025, and the search for a replacement now turns up a crowded field of free mediators. Many of the fastest are built on source generators: compiler plugins that write the dispatch code during your build. That buys speed and compile-time checks. It also ties the library to your toolchain: a recent compiler, `PackageReference`, analyzers left on, and generated code in every build.

RequestFlow trades those requirements away and keeps everything at runtime.

- Errors surface at startup, not in production. Discovery, validation, and the dispatch plan all finish before the first request, and a broken configuration fails the boot with one exception listing every problem. After that, dispatch is one dictionary lookup with no reflection, LINQ, or locking.
- No build step. Nothing runs inside your compiler, and there is no generated code to step through when something misbehaves. One package behaves the same from .NET 10 down to .NET Framework 4.6.2.
- MIT, permanently. This library exists because a license changed underneath its users once. It takes no dependency whose license could do the same.
- Migration is mostly renames. Requests and handlers keep their shape coming from MediatR; the mapping table below covers a typical codebase.

Fast is a claim to prove, not to assert. A BenchmarkDotNet suite against the other mediators, raw artifacts included, is on the [roadmap](ROADMAP.md) before v1. Until it lands, this README quotes no numbers.

## Coming from MediatR

None of them pair low-allocation dispatch with a command/query split the type system enforces. RequestFlow aims at that gap:
| MediatR | RequestFlow |
| ---------------------------------------------------- | ----------------------------------------------------- |
| `IRequest<TResponse>`, `IRequest` | same names, `RequestFlow` namespace |
| `IRequestHandler<TRequest, TResponse>` with `Handle` | same interface, method is `HandleAsync` |
| `IMediator.Send(...)` | `IRequestDispatcher.SendAsync(...)` |
| void requests through `Unit` | void handlers return plain `Task`, no `Unit` anywhere |
| `IPipelineBehavior<,>` | `IRequestStage<,>` |
| `services.AddMediatR(...)` | `services.AddRequestFlow(...)` |

1. No reflection on the hot path; handler lookup is cached.
2. CQRS as an opt-in package, not a convention.
3. Stage pipeline composed once, no LINQ in dispatch (planned, not in the current preview).
What doesn't move yet: notifications (`INotification` / `Publish`) and streaming. Both are on the [roadmap](ROADMAP.md) for after v1.0. Notifications return as events, an in-process publish/subscribe (`IEvent`, `IEventHandler`, `IEventPublisher`); streaming arrives through `IAsyncEnumerable<T>`. Neither is built today, so if your codebase leans on either, hold the migration until they land.

## Packages

- **`RequestFlow.Abstractions`** holds the contracts: `IRequest`, `IRequestHandler`, `IRequestDispatcher`, `NoResult`. Depends on nothing. `IRequestStage` joins this package when stages ship.
- **`RequestFlow`** is the runtime: dispatcher, `AddRequestFlow` with assembly scanning, startup validation. Depends on Abstractions and `Microsoft.Extensions.DependencyInjection.Abstractions`.
- **`RequestFlow.Cqrs.Abstractions`** holds the CQRS contracts: `ICommand`, `IQuery`, their handler interfaces, `ICommandDispatcher`, `IQueryDispatcher`. Depends on `RequestFlow.Abstractions` only.
- **`RequestFlow.Cqrs`** is the CQRS runtime: typed dispatcher implementations, registered with `AddRequestFlow(...).AddCqrs()`. Depends on the contracts package and the core runtime.
- **[`RequestFlow.Abstractions`](https://www.nuget.org/packages/RequestFlow.Abstractions)** holds the contracts: `IRequest`, `IRequestHandler`, `IRequestDispatcher`, `IRequestStage`, `NoResult`. Depends on nothing.
- **[`RequestFlow`](https://www.nuget.org/packages/RequestFlow)** is the runtime: dispatcher, `AddRequestFlow` with assembly scanning, startup validation. Depends on Abstractions and `Microsoft.Extensions.DependencyInjection.Abstractions`.
- **[`RequestFlow.Cqrs.Abstractions`](https://www.nuget.org/packages/RequestFlow.Cqrs.Abstractions)** holds the CQRS contracts: `ICommand`, `IQuery`, their handler interfaces, `ICommandDispatcher`, `IQueryDispatcher`. Depends on `RequestFlow.Abstractions` only.
- **[`RequestFlow.Cqrs`](https://www.nuget.org/packages/RequestFlow.Cqrs)** is the CQRS runtime: typed dispatcher implementations, registered with `AddRequestFlow(...).AddCqrs()`. Depends on the contracts package and the core runtime.

Contracts live in their own packages so your domain layer, and any future add-on package, can reference the interfaces without taking a dependency on a runtime. Install a runtime package at the composition root and the matching contracts arrive transitively. Core types share the `RequestFlow` namespace; the CQRS types live in `RequestFlow.Cqrs`.

## Documentation

- [Getting started](https://github.com/illia1f/RequestFlow/blob/main/docs/getting-started.md): install, first request and handler, dispatching
- [Registration](https://github.com/illia1f/RequestFlow/blob/main/docs/registration.md): every `AddRequestFlow` option, scanning, generic handlers, startup validation
- [Stages](https://github.com/illia1f/RequestFlow/blob/main/docs/stages.md): wrapping handlers, execution order, which requests a stage reaches, filters
- [Service lifetimes](https://github.com/illia1f/RequestFlow/blob/main/docs/lifetimes.md): what RequestFlow registers, with which lifetime, and what you can change
- [Exceptions](https://github.com/illia1f/RequestFlow/blob/main/docs/exceptions.md): every exception RequestFlow throws, when it surfaces, and how to fix it

Expand Down
14 changes: 6 additions & 8 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,17 @@ Deliberately minimal: request/response dispatch, the stage pipeline, and the CQR

- [x] Core abstractions: `IRequest`, `IRequestHandler<,>`, `NoResult`
- [x] `IRequestDispatcher` and the dispatcher over a frozen dispatch map
- [ ] `IRequestStage` with open, constrained, and closed generic registration
- [x] `IRequestStage` with open, constrained, and closed generic registration
- [x] CQRS layer: `ICommand`/`IQuery` and handler contracts in `RequestFlow.Cqrs.Abstractions`, typed dispatchers and `AddCqrs` registration in `RequestFlow.Cqrs`
- [x] `AddRequestFlow` registration with assembly scanning and generic handler closings
- [x] Exceptions and startup validation (`ValidateRequestFlow`)
- [ ] NuGet publish and package ID prefix reservation
- [x] NuGet publish: all four packages are up at `1.0.0-preview.1`
- [ ] `RequestFlow.*` package ID prefix reservation
- [ ] Benchmark suite: BenchmarkDotNet against MediatR, martinothamar/Mediator, and LiteBus as pinned package references, raw artifacts committed

Targets: `netstandard2.0;net462;net8.0;net10.0`.
Targets: `netstandard2.0;net462;net8.0;net10.0`. The published `1.0.0-preview.1` predates the net462 target, so that one first ships in the next preview.

## v1.x

- Events: in-process publish/subscribe (`IEvent`, `IEventHandler`, `IEventPublisher`), RequestFlow's answer to MediatR notifications. Delivery, ordering, concurrency, failure, and cancellation semantics get written down before any code.
- Streaming requests via `IAsyncEnumerable<T>`.

## Under consideration

- Events: in-process publish/subscribe (`IEvent`, `IEventHandler`, `IEventPublisher`). Not planned for now; would be additive if it ever happens.
- Native adapter packages for specific DI containers, if anyone asks.
31 changes: 30 additions & 1 deletion docs/exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ 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 |
| `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 |

Expand All @@ -30,6 +32,19 @@ Thrown when RequestFlow validates everything registered: the first time a dispat
| `Closing type '...' ... is not a closed type.` | An open generic passed as a closing type | Close it first: `typeof(Audit<Order>)`, not `typeof(Audit<>)` |
| `Generic handler '...' cannot be closed over '...'...` | The closing type violates the handler's generic constraints | Pick a closing type that satisfies the `where` clauses |

Stages registered with `AddStage` bring their own checks (see [stages.md](stages.md)); their problems land in the same exception:

| Problem message starts with | Cause | Fix |
| -------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `'...' is an interface; only concrete stage classes...` | An interface passed to `AddStage` | Register the implementing class |
| `'...' is abstract; only concrete stage classes...` | An abstract class passed to `AddStage` | Register a concrete stage class |
| `'...' is partially closed...` | A stage type with some type parameters bound and some open | Pass the open definition or a fully closed type |
| `'...' does not implement IRequestStage...` | The registered type is not a stage | Implement `IRequestStage<TRequest, TResponse>` or `IRequestStage<TRequest>` |
| `'...' declares generic parameters <...> that its IRequestStage implementation does not use...` | An open generic stage whose contract does not name its own parameters as the request | Implement the contract with the stage's own parameters, request first |
| `Stage '...' from assembly '...' is registered more than once...` | The same stage type in two `AddStage` calls | Remove the duplicate; a handler filter does not make it distinct |
| `Stages '...' and '...' both resolve to '...'` / `Stages '...' and '...' are the same stage class...` | An open definition registered next to its own closed form, or two closings of one class reaching the same request | Remove one of the two `AddStage` calls |
| `Stage '...' from assembly '...' applies to no registered request...` | `DisallowUnusedStages` is on and the stage reached nothing | Widen its constraints, scan the assembly holding its requests, or drop the opt-in |

Example: a contracts assembly scanned without its handlers fails at startup, not per request.

```csharp
Expand Down Expand Up @@ -112,6 +127,19 @@ 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

Plain `InvalidOperationException` signals a broken handler or stage contract. Three cases surface at dispatch, one at registration:

| 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 |

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)).

## Argument validation

Argument checks at the public surface throw immediately at the call site:
Expand All @@ -123,11 +151,12 @@ Argument checks at the public surface throw immediately at the call site:
| `RegisterHandlersFromAssembly` | `ArgumentNullException` | `assembly` is null |
| `RegisterGenericHandler` | `ArgumentNullException` | `handlerType` or `closingTypes` is null |
| `RegisterGenericHandler` | `ArgumentException` | `closingTypes` contains a null element |
| `AddStage` | `ArgumentNullException` | `stageType` is null |
| `ValidateRequestFlow` | `ArgumentNullException` | `provider` is null |

## What RequestFlow never wraps

Handler exceptions propagate as thrown. The dispatcher adds no try/catch and no wrapper exception, so `await dispatcher.SendAsync(...)` observes exactly what `HandleAsync` threw.
Handler and stage exceptions propagate as thrown. The dispatcher and the stage chain add no try/catch and no wrapper exception, so `await dispatcher.SendAsync(...)` observes exactly what the failing `HandleAsync` threw. A stage that wants to translate exceptions does so itself, in a try/catch around `next`.

Cancellation follows the same rule. The token passes to `HandleAsync` untouched, and an `OperationCanceledException` surfaces from the handler like any other exception.

Expand Down
2 changes: 2 additions & 0 deletions docs/registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ services.AddRequestFlow(o => o
| `RegisterHandlersFromAssemblyContaining<T>()` | Scans the assembly containing `T` |
| `RegisterHandlersFromAssembly(assembly)` | Scans the given assembly |
| `RegisterGenericHandler(handlerType, ...)` | Closes an open generic handler over the declared types |
| `AddStage(stageType, configure?)` | Wraps applicable handlers in a stage (see [stages.md](stages.md)) |
| `DisallowUnusedStages()` | Fails startup validation when a stage reaches no request (see [stages.md](stages.md)) |
| `AllowUnhandledRequests()` | Skips the missing-handler check at startup validation |
| `WithHandlerLifetime(lifetime)` | Lifetime for this call's handlers, transient by default (see [lifetimes.md](lifetimes.md)) |
| `WithTransientDispatcher()` | Registers the dispatcher transient instead of scoped (see [lifetimes.md](lifetimes.md)) |
Expand Down
Loading
Loading