Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8a09242
docs: add accepted ADR 0020 for agreement-dispatch handler routing
iancooper Jul 14, 2026
d227709
docs: add task breakdown for agreement dispatcher (spec 013)
iancooper Jul 15, 2026
a1d8f17
chore: mark tasks phase approved for agreement dispatcher (spec 013)
iancooper Jul 15, 2026
2eafe86
refactor: model handler resolution as a route (IResolveHandlers)
iancooper Jul 15, 2026
7cbb83e
feat: add RoutingException and RoutingFailure for agreement-dispatch …
iancooper Jul 15, 2026
3705a7a
feat: add RoutedHandlers and sync routing Register overload for agree…
iancooper Jul 15, 2026
ee04aa8
chore: mark Phase 1, Phase 2, and Phase 3 task 1 complete in tasks.md
iancooper Jul 15, 2026
a7ec743
test: guard context-based routing path through RoutedHandlers (Phase …
iancooper Jul 15, 2026
bdbcc16
feat: throw RoutingException(NoHandlerResolved) when router returns n…
iancooper Jul 15, 2026
41d9943
feat: throw RoutingException(UnregisteredCandidate) when router retur…
iancooper Jul 15, 2026
fea27f2
feat: validate routing candidates implement handler interface at regi…
iancooper Jul 15, 2026
d90c3a4
test: guard that exceptions thrown inside routing func surface unwrap…
iancooper Jul 15, 2026
a13ca4d
test: guard that routed handler still traverses decorator pipeline (P…
iancooper Jul 15, 2026
a17414b
test: guard duplicate-registration constraint for routing and type-ba…
iancooper Jul 15, 2026
41cff79
feat: add routing Register overload to async registry and prove conte…
iancooper Jul 15, 2026
e28ecdc
test: guard async context-based routing path through RoutedHandlers (…
iancooper Jul 15, 2026
9d6f83d
test: guard async null-router throws RoutingException NoHandlerResolv…
iancooper Jul 15, 2026
14c8829
test: guard async non-candidate router throws RoutingException Unregi…
iancooper Jul 15, 2026
565ac5f
test: guard async routing registration validation - bad candidate and…
iancooper Jul 15, 2026
c272876
feat: add routing Register overload to stream registry and prove cont…
iancooper Jul 15, 2026
3472c02
test: guard stream context-based routing path through RoutedHandlers …
iancooper Jul 15, 2026
8295c43
test: guard stream null-router throws RoutingException NoHandlerResol…
iancooper Jul 15, 2026
47e82a7
test: guard stream non-candidate router throws RoutingException Unreg…
iancooper Jul 15, 2026
36de406
test: guard stream routing registration validation - bad candidate an…
iancooper Jul 15, 2026
b1d1600
feat: override routing Register in ServiceCollectionHandlerRegistry t…
iancooper Jul 15, 2026
ea18b95
chore: mark Phase 5 task 1 complete in tasks.md
iancooper Jul 15, 2026
314da8d
feat: register async routing candidates in DI container (Phase 5, tas…
iancooper Jul 15, 2026
55d4ce4
feat: register stream routing candidates in DI container (Phase 5, ta…
iancooper Jul 16, 2026
eeff976
chore: mark Phase 6 complete and close spec 013 implementation
iancooper Jul 16, 2026
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
298 changes: 298 additions & 0 deletions docs/adr/0020-agreement-dispatch-handler-routing.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion specs/.current-spec
Original file line number Diff line number Diff line change
@@ -1 +1 @@
012-streaming_results
013-agreement_dispatcher
1 change: 1 addition & 0 deletions specs/013-agreement_dispatcher/.adr-list
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0020-agreement-dispatch-handler-routing.md
Empty file.
1 change: 1 addition & 0 deletions specs/013-agreement_dispatcher/.issue-number
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
349
Empty file.
Empty file.
34 changes: 34 additions & 0 deletions specs/013-agreement_dispatcher/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Agreement Dispatcher

**Spec ID:** 013-agreement_dispatcher
**Created:** 2026-07-14
**Status:** Requirements drafted — awaiting review/approval
**Source:** [Issue #349](https://github.com/BrighterCommand/Darker/issues/349) — Create an Agreement Dispatcher based on the Query and Context

## Overview

Brighter supports not only type-based routing, which matches a handler to a specific request
type, but also an Agreement Dispatcher, which allows the matching to be done on any part of the
context given to the command dispatcher — that is, the request and the context. See
[Agreement Dispatcher](https://brightercommand.gitbook.io/paramore-brighter-documentation/brighter-request-handlers-and-middleware-pipelines/agreementdispatcher)
in Brighter's documentation, and Brighter's `docs/adr/0031-support-agreement-dispatcher.md` for
the design this mirrors.

Brighter implements this via a `Func` that maps between request and handler in the
`SubscriberRegistry`. If the user does not supply this, the default matches on type, but the
user can supply a `Func` that routes differently. Brighter's Subscriber Registry stores a set of
"observers" for a request, each an implementation of `Func` that can produce a handler for that
type, since `CommandProcessor.Publish` can have multiple matches.

Darker should mirror this design: allow a different `QueryHandler` pipeline to be chosen
depending on both the `Query` itself and the `QueryContext`. A typical usage is date-based —
queries before a certain date use one shape, and after that date use another, following a
decision to change how a particular metric is provided.

## Status Checklist

- [x] Requirements (`/spec:requirements`) — approved
- [x] Design / ADR (`/spec:design`) — approved (ADR 0020)
- [x] Adversarial Review — PASS (see `review-design.md`)
- [x] Task Breakdown (`/spec:tasks`) — `tasks.md`
- [x] Implementation (`/spec:implement`)
136 changes: 136 additions & 0 deletions specs/013-agreement_dispatcher/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Requirements

> **Note**: This document captures user requirements and needs. Technical design decisions and implementation details should be documented in an Architecture Decision Record (ADR) in `docs/adr/`.

**Linked Issue**: #349

## Problem Statement

As a Darker user, I would like to route a query to different handlers based on the query's
content and the query context — not just the query's static type — so that I can evolve how a
query is handled over time (or by other runtime conditions) without changing the query type or
forcing all callers onto a single handler implementation.

Darker currently only supports type-based routing: `IQueryHandlerRegistry` maps a query `Type` to
exactly one handler `Type`, fixed at registration time. Brighter already solves this problem for
commands/events via its "Agreement Dispatcher" (see
[Brighter docs](https://brightercommand.gitbook.io/paramore-brighter-documentation/brighter-request-handlers-and-middleware-pipelines/agreementdispatcher)
and `docs/adr/0031-support-agreement-dispatcher.md` in the Brighter repo). Darker should offer the
equivalent capability for queries.

A representative scenario: queries for a metric change shape after a given date — queries dated
before the cutover should be handled by the legacy handler, queries dated after by the new
handler, without the caller having to know which one applies.

## Proposed Solution

Allow a query type to be registered with a **routing function** instead of (or alongside) a fixed
handler type. The routing function is evaluated per-query-execution and receives both the query
instance and the `IQueryContext`, and resolves to the single handler type that should process
that particular query. Everything downstream of handler resolution — pipeline building, decorator
resolution, execution — behaves exactly as it does today; only *which handler type* is selected
changes.

Query types not registered with a routing function continue to use today's fixed type-to-handler
mapping, including `RegisterFromAssemblies` auto-scanning. Both styles of registration coexist in
the same `IQueryHandlerRegistry` / `IQueryHandlerRegistryAsync`.

## Requirements

### Functional Requirements

- A query type can be registered with a routing function that is given the query instance and the
`IQueryContext`, and returns the single handler `Type` that should handle that specific query
execution.
- The routing function is evaluated on every execution of that query type (not cached at
registration time), so its result can depend on the query's data (e.g. a date field) and/or
anything available on the context (e.g. `IQueryContext.Bag`).
- All handler types a routing function can possibly resolve to must be registered explicitly up
front (see Constraints — no implicit discovery of routed handlers).
- If the routing function resolves to a handler type that was not registered as a candidate for
that query type, this is a configuration/routing error, surfaced clearly to the caller.
- If the routing function resolves to no handler (e.g. returns `null`), this is a routing error,
surfaced clearly to the caller — distinguishable from "no handler registered for this query
type at all" (today's existing error).
- Supported for `QueryHandlerRegistry` (sync), `QueryHandlerRegistryAsync` (async), and
`StreamQueryHandlerRegistry` (streaming). All three registries share the same
`Get`/`Register`/`RegisterFromAssemblies` shape and the same eager, pre-execution handler-type
resolution point, so agreement dispatch applies uniformly across them — resolving the handler
type is unaffected by the streaming pipeline's later `IAsyncEnumerable` enumerator lifecycle,
cancellation, or post-first-item exception handling (see ADR 0019), since those all occur after
the handler type has already been chosen.
- Existing type-based registration (`Register<TQuery, TResult, THandler>()`,
`RegisterFromAssemblies`) continues to work unchanged for queries that don't use agreement
dispatch, in the same registry instance as agreement-dispatched queries.
- The routing function, once selected a handler type, hands off to the existing pipeline-building
and decorator-resolution machinery unchanged — decorators attached to the resolved handler's
`Execute`/`ExecuteAsync` method are still discovered and ordered exactly as today.

### Non-functional Requirements

- **Performance**: Routing function evaluation happens on the hot path (every query execution) —
it must not introduce noticeable overhead beyond invoking a `Func`. No caching of routing
decisions is required (results may legitimately differ per call).
- **Backwards compatibility**: No behavioural change for query types that don't opt into
agreement dispatch. Existing registration APIs and call sites continue to compile and behave as
they do today.
- **Clarity of failure**: Routing errors (unregistered candidate resolved, no match resolved) must
produce exceptions that clearly distinguish "routing failed" from "no handler registered for
this query type."

### Constraints and Assumptions

- Mirrors Brighter's constraint that `RegisterFromAssemblies` (auto-scan) cannot be combined with
agreement dispatch for the same query type: the set of candidate handler types for an
agreement-dispatched query must be registered explicitly, not discovered by assembly scanning.
Other, non-routed query types in the same application can still use `RegisterFromAssemblies`.
- Unlike Brighter's `Publish`, which allows a routing function to return multiple handler types
(multiple observers), Darker queries always produce exactly one `TResult`. The routing function
therefore resolves to exactly one handler type per execution — not a list. This is a deliberate
deviation from Brighter's `List<Type>`-returning signature, justified by Darker's single-result
`Execute`/`ExecuteAsync` semantics.
- The routing function's input is the query instance itself (typed `TQuery`, so any of its own
fields — e.g. a date — are naturally available for the routing decision) together with
`IQueryContext`. Routing can depend on the properties of either.
- Assumes existing `IQueryContext` (with its `Bag`, tracing, and resilience members) is sufficient
for providing the contextual input for routing decisions — no new context members are required by this feature.

### Out of Scope

- Returning/executing multiple handlers for a single query (fan-out); Darker's query model remains
strictly one handler, one result.
- Caching or memoizing routing decisions.
- Any change to decorator resolution/ordering, pipeline building beyond handler-type selection, or
the `IQueryHandlerFactory` / `IQueryHandlerDecoratorFactory` lifecycle.
- DI container-specific registration helpers beyond what's needed to register the routing function
and its candidate handler types (e.g. no MAUI-specific work).

## Acceptance Criteria

- A query type can be registered with a routing function and a set of candidate handler types; on
execution, the handler chosen by the routing function (given the query and context) is the one
that runs.
- Two queries of the same type, differing only in a field the routing function inspects (e.g. a
date), are routed to different handlers within the same registry.
- Registering a routing function for a query type whose candidate handlers were also picked up by
`RegisterFromAssemblies` is rejected/prevented (or otherwise clearly disallowed per the
constraint above).
- A routing function that resolves to an unregistered/unexpected handler type throws a clear,
distinct exception at execution time.
- A routing function that resolves to no handler throws a clear, distinct exception at execution
time, different from "unregistered query type."
- Existing tests for type-based registration and dispatch continue to pass with their **behaviour and
assertions unchanged**. Mechanical source-level updates required purely by a deliberate breaking API
change in the approved design (e.g. widening the registry `Get` signature, which the design chose
over adding an overload) are permitted at their call sites; no test's expected behaviour or
assertions may change.
- New tests cover: routing to different handlers based on query content, routing based on
`IQueryContext`, the "no match" error path, and the "unregistered candidate" error path — for
sync, async, and streaming registries.

## Additional Context

Reference implementation: Brighter's `docs/adr/0031-support-agreement-dispatcher.md` and
`SubscriberRegistry.Register<TRequest>(routingFunc, handlerTypes)` overload. Brighter's routing
function signature is `(IRequest, IRequestContext) => List<Type>`; Darker's equivalent should be
`(TQuery, IQueryContext) => Type` (or `Type?`), reflecting the single-handler constraint above.
97 changes: 97 additions & 0 deletions specs/013-agreement_dispatcher/review-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Review: design — agreement_dispatcher (re-review)

**Date**: 2026-07-14
**Threshold**: 60
**Verdict**: PASS
**ADR reviewed**: `docs/adr/0020-agreement-dispatch-handler-routing.md`

> Re-review after the six prior findings were addressed. All six were genuinely resolved (not
> merely reworded). Two new low-severity issues surfaced from the edits — both below threshold and
> both fixed post-review (see "Post-review fixes").

## Findings

### 1. ADR stated a false codebase fact: `Paramore.Darker` does NOT set `IsTrimmable` (Score: 48)

The F5 edit added a trimming/AOT Risk whose parenthetical asserted a build-property fact that does
not hold. `IsTrimmable` appears in no `.csproj` in the repo. `Paramore.Darker.csproj` sets only
`IsAotCompatible`, and even that is **conditional** — `Condition="'$(TargetFramework)' != 'netstandard2.0'"`
— so it applies to net8.0/net9.0 only. The mitigation's *conclusion* (routing adds no reflection
beyond the existing path, so posture is unchanged) is sound, but the grounding claim was a
verifiable falsehood.

**Evidence**: `src/Paramore.Darker/Paramore.Darker.csproj:7` sets only
`<IsAotCompatible Condition="'$(TargetFramework)' != 'netstandard2.0'">true</IsAotCompatible>`;
`grep -rn IsTrimmable --include=*.csproj` returns nothing.

**Recommendation**: Drop `/IsTrimmable`; say "sets `IsAotCompatible` (net8.0/net9.0 targets)".
**→ Fixed** (see Post-review fixes).

---

### 2. Flow diagram showed a `RoutingException` construction not matching the canonical constructor (Score: 40)

Prose and code block were consistent with the canonical
`RoutingException(RoutingFailure, Type queryType, Type resolvedHandlerType = null)` (mandatory
`queryType`), but the flow diagram still showed the one-argument schematic form, omitting the
mandatory `queryType` — the exact diagram-vs-code inconsistency F2 targeted.

**Evidence**: ADR flow diagram `→ throw RoutingException(RoutingFailure.NoHandlerResolved)` vs.
canonical ctor and code `new RoutingException(RoutingFailure.NoHandlerResolved, _queryType)`.

**Recommendation**: Add `queryType` to the diagram labels or annotate them as schematic.
**→ Fixed** (see Post-review fixes).

---

## Prior findings status

- **F1 (Get can throw / three outcomes / XML docs) — RESOLVED.** Now an explicit Negative
consequence ("`Get`'s exception contract widens, not just its signature… can make `Get` **throw**…
interface XML docs must be updated") plus the three-outcome enumeration in Implementation Approach.
Verified against `IStreamQueryHandlerRegistry.cs:10-13` and `QueryHandlerRegistry.cs:18-21`.
- **F2 (one enum name + one canonical ctor) — RESOLVED** (residual diagram nit → Finding 2, now
fixed). Enum uniformly `RoutingFailure` with a `Reason` property; prose and code match the
canonical constructor.
- **F3 (per-registry generic constraints + candidate validation) — RESOLVED.** sync/async
`where TQuery : IQuery<TResult>`, stream `where TQuery : IStreamQuery<TResult>`, matching the real
overloads (`QueryHandlerRegistry.cs:24`, `QueryHandlerRegistryAsync.cs:24`,
`StreamQueryHandlerRegistry.cs:22`); `params Type[]` candidates validated by runtime
`IsAssignableFrom` throwing `ConfigurationException` at registration. Handler interface names
`IQueryHandler<,>` / `IQueryHandlerAsync<,>` / `IStreamQueryHandler<,>` verified correct.
- **F4 (registration-vs-execution concurrency) — RESOLVED.** States registration completes before
execution, no concurrent mutation, no runtime re-registration, no new synchronisation — matching
the unsynchronised `Dictionary` at `QueryHandlerRegistry.cs:11`.
- **F5 (trimming/AOT posture) — RESOLVED in intent**, but introduced a factual error (Finding 1,
now fixed).
- **F6 (`(TQuery)q` cast safety) — RESOLVED.** Justified via the `typeof(TQuery)` key and
`query.GetType()` lookup; verified at `PipelineBuilder.cs:64/125/279`.

Additional grounding re-verified true: `Dictionary<Type,Type>` storage and
`Get`/`Register`/`RegisterFromAssemblies` shape across all three registries; `RegisterFromAssemblies`
funnels through `Register(Type,Type,Type)`; `IStreamQuery<T> : IQuery<T> : IQuery`;
`ConfigurationException` is `sealed`; the three `ServiceCollection*` registries are `sealed` and
override `Register(Type,Type,Type)` then delegate to base; `netstandard2.0` is a target.

---

## Post-review fixes (applied after this pass)

- Finding 1: replaced the false `IsAotCompatible`/`IsTrimmable` claim with the accurate
"sets `IsAotCompatible` for the net8.0/net9.0 targets (`Condition != netstandard2.0`)".
- Finding 2: the flow-diagram `RoutingException(...)` labels now include `queryType` and are marked
"schematic — see canonical ctor below".

---

## Summary

| Score Range | Count |
|-------------|-------|
| 90-100 (Critical) | 0 |
| 70-89 (High) | 0 |
| 50-69 (Medium) | 0 |
| 0-49 (Low) | 2 |

**Total findings**: 2
**Findings at or above threshold (60)**: 0
Loading
Loading