Skip to content

Spec: Command Router (F3 Top-1) #256

Description

@Skymly

Problem Statement

Library consumers need in-process, typed request/response dispatch (1:1 command → handler) with the same compile-time registration, diagnostics, and optional DI glue already available for Strategy, Factory Registry, and Event Aggregator. MediatR already solves much of the runtime problem; what is missing in DesignPatterns is a Command Router domain whose exploration value is a closed command↔handler bijection before the first Send, plus optional ordered pipeline behaviors and stream sends as capabilities of this domain—not a second Event Aggregator, and not a combined MediatR-style ISender+IPublisher umbrella.

This work is F3 admission Top-1 from Wayfinder map #244 / ROADMAP F3 (shortlist locked in #253, written in #254 / PR #255).

Solution

Ship a Command Router pattern domain:

  • Runtime primitives for commands, handlers, and a router with Send / TrySend / async variants (CLR command type routing, 1:1).
  • [RegisterCommandHandler] (dual generic / non-generic attribute shapes, consistent with Event Aggregator) driving a source generator that emits a frozen command→handler map and router glue.
  • Compile-time diagnostics for duplicate handlers, contract mismatch, and an Analyzer + CodeFix for implemented-but-unregistered handlers (Event Aggregator DP044 family).
  • Optional pipeline behaviors (ordered wrappers around the single handler) and optional stream send mode as in-domain capabilities.
  • Optional MSDI / Autofac registration via existing DI integration flags and extension packages—Core remains free of MSDI.

Consumers get MediatR-shaped request dispatch with DesignPatterns-shaped compile-time bijection proofs.

User Stories

  1. As an application developer, I want to declare a command type and a single handler for it, so that I can dispatch work without a string key registry.
  2. As an application developer, I want Send / SendAsync to return a typed result when the command declares one, so that request/response flows are explicit.
  3. As an application developer, I want TrySend / TrySendAsync that fail explicitly without throwing when no handler is registered at runtime (manual builder path), so that I can handle missing handlers without try/catch.
  4. As an application developer, I want throwing Send variants with a clear exception when a handler is missing on the manual path, so that fail-fast startup/runtime behavior matches Strategy/Factory conventions.
  5. As an application developer, I want void/unit-style commands (no result) supported, so that fire-and-forget commands still go through the same router.
  6. As an application developer, I want async handlers with CancellationToken as a first-class parameter, so that cancellation flows through dispatch.
  7. As an application developer, I want ValueTask-friendly handler APIs, so that hot paths avoid unnecessary allocations where the library already prefers ValueTask.
  8. As a library consumer on netstandard2.0, I want the runtime primitives to work on the shared TFM, so that older hosts can still use Command Router.
  9. As a library consumer on net8.0, I want frozen dictionary / optimized map implementations where the rest of the library already specializes, so that dispatch stays consistent with Strategy/Factory registries.
  10. As a developer, I want to annotate a handler with [RegisterCommandHandler] (non-generic typeof form), so that netstandard2.0 / older C# projects can register without generic attributes.
  11. As a developer, I want to annotate a handler with [RegisterCommandHandler<TCommand>] (or command+result form as designed), so that modern C# projects get type-safe attributes.
  12. As a developer, I want the generator to discover registrations without assembly reflection scanning, so that AOT and trim-friendly scenarios stay aligned with AGENTS.md non-goals.
  13. As a developer, I want generated router/registry types named consistently with ROADMAP generator naming rules, so that discoverability matches Strategy/Event Aggregator.
  14. As a developer, I want a static (parameterless-ctor) registration path that wires handlers without DI, so that console/tools samples work like Event Aggregator SubscribeAll.
  15. As a developer, I want a DI registration path (RegisterDi + resolve-on-send or build router from IServiceProvider) when DI integration is enabled, so that handlers can take constructor-injected dependencies.
  16. As a developer using Microsoft.Extensions.DependencyInjection, I want an AddCommandRouter (or equivalent) extension, so that host setup matches AddEventAggregator / strategy extensions.
  17. As a developer using Autofac, I want a symmetric registration path when Autofac extensions are referenced, so that parity with other domains is preserved.
  18. As a developer, I want handlers to default to Transient lifetime under DI, so that behavior matches Event Aggregator handler defaults and reduces accidental captive dependencies.
  19. As a developer, I want documentation of lifetime pitfalls (singleton router resolving scoped handlers), so that existing captive-dependency vocabulary (DP060–062 family) applies.
  20. As a developer, I want a compile-time Error when two handlers register for the same command (and result signature as applicable), so that bijection is enforced before runtime.
  21. As a developer, I want a compile-time Error when a attributed type does not implement the expected command handler contract, so that mismatches fail at build.
  22. As a developer, I want an Analyzer Info (with CodeFix) when a type implements the handler interface but lacks [RegisterCommandHandler] while peers in the compilation use the attribute, so that registration is hard to forget (DP044 pattern).
  23. As a developer, I want CodeFix to insert the correct attribute form for my TFM/language version, so that fixing DP044-style findings is one action.
  24. As a developer, I want optional ordered pipeline behaviors around the single handler, so that cross-cutting concerns (logging, validation, transactions) compose without a separate ROADMAP domain.
  25. As a developer, I want duplicate behavior order to be a generator Error, so that pipeline ordering stays deterministic (Chain/Decorator precedent).
  26. As a developer, I want behaviors to be skippable in an MVP milestone if marked phase-2, so that the core bijection ships without blocking on full MediatR pipeline parity—but the domain design must leave a clear extension point.
  27. As a developer, I want an optional stream send API (IAsyncEnumerable results) as a capability of the same router domain, so that progressive results do not require a separate Stream Request Router domain.
  28. As a developer, I want stream support to be optional/phased so that the core 1:1 non-stream router can ship first without blocking on stream generator modes.
  29. As a developer, I want open-generic handlers to be either supported with clear rules or explicitly rejected with diagnostics, so that MediatR-like open generics do not silently mis-bind.
  30. As a developer writing samples, I want at least one Sample sketch (manual builder + attribute/generator path) in DesignPatterns.Samples (sibling repo, separate PR), so that ROADMAP admission criterion Allow multiple HandlerOrder attributes on a single handler class #3 stays satisfied.
  31. As a documentation reader, I want a Design Doc CommandRouter.md plus index entry, so that API/diagnostics/trade-offs are recorded like Event Aggregator.
  32. As a documentation reader, I want an explicit comparison table vs Event Aggregator (1:N Publish) and vs MediatR (overlap allowed), so that I know when to use which primitive.
  33. As a documentation reader, I want CHANGELOG and ROADMAP F3 Top-1 status updates when milestones land, so that backlog state stays truthful.
  34. As a maintainer, I want new diagnostic IDs allocated in the Diagnostics module without reusing ADR-008 reserved DP067–DP071, so that Singleton lifecycle diagnostics remain undisturbed.
  35. As a maintainer, I want AnalyzerReleases / DiagnosticIds / descriptors updated in the same generator/analyzer PRs that introduce rules, so that AGENTS.md diagnostic process is followed.
  36. As a maintainer, I want each solution module in its own PR (Runtime, Diagnostics, SourceGenerators, Analyzers+CodeFixes, DI, Autofac, Docs, Samples), so that AGENTS.md module boundaries hold.
  37. As a maintainer, I want generator tests to Verify both happy-path emitted sources and diagnostic cases (duplicate, contract mismatch), so that the primary compile-time seam stays locked.
  38. As a maintainer, I want runtime unit tests for router dispatch, concurrency expectations (document snapshot/lock semantics analogous to Event Aggregator if applicable), and Try vs throw APIs, so that primitives stay trustworthy without DI.
  39. As a maintainer, I want analyzer tests for unregistered-handler detection and CodeFix, so that IDE experience matches Strategy/Factory/Event Aggregator.
  40. As a maintainer, I want DI extension tests that resolve a handler via IServiceProvider and successfully Send, so that the DI path does not rot.
  41. As an application developer, I want command and result types to allow class / struct / record, so that domain modeling is not artificially constrained.
  42. As an application developer, I want the router to refuse silently returning null for missing handlers on the throwing API, so that AGENTS.md “explicit failure over silent null” holds.
  43. As an application developer, I want pipeline behaviors to receive the command and a next delegate (Chain-like) or decorate the handler (Decorator-like)—whichever decision is locked in Implementation Decisions—so that writing a behavior feels familiar to existing DesignPatterns users.
  44. As an application developer, I do not want Command Router to publish events or replace Event Aggregator, so that 1:N notifications stay on IEventAggregator.
  45. As an application developer, I do not want correlation-id request/reply messaging as part of this domain, so that Event Aggregator’s documented non-goal remains out of Command Router too (per Wayfinder rejection of Correlated Request/Reply Messenger as a thin standalone domain).
  46. As an AOT-minded developer, I want generated maps to be trim-friendly and free of runtime reflection scans, so that publish-time binding stays compile-declared.
  47. As a contributor, I want XML docs on all public APIs, so that GenerateDocumentationFile / TreatWarningsAsErrors continues to pass.
  48. As a contributor, I want file-scoped namespaces and nullable enable, so that repo coding standards apply.
  49. As a product user evaluating MediatR vs DesignPatterns, I want the README/Design Doc to state that overlapping MediatR is intentional exploration, so that “why another mediator?” is answered up front.
  50. As a future implementer of stream mode, I want the initial public surface to avoid painting into a corner that forbids SendStreamAsync, so that capability addition does not need a breaking redesign.
  51. As a future implementer of pipeline behaviors, I want registration attributes and generator hooks sketched even if MVP defers behavior execution, so that tickets can sequence cleanly.
  52. As a developer debugging dispatch, I want optional traced send (status per behavior / handler) to be considered as a follow-on enhancement consistent with Strategy/Chain/Event Aggregator tracing—not required for MVP unless cheap.
  53. As a consumer of the metapackage, I want Runtime + generators/analyzers to ship through the existing packaging layout when Package module work is done, so that Skymly.DesignPatterns users get Command Router without a special snowflake package (Package PR only if packing changes).
  54. As a developer using plural handlers by mistake, I want the error message to tell me which command type collided and which handler types conflict, so that the diagnostic is actionable (AGENTS.md messageFormat guidance).
  55. As a developer on a multi-project solution, I want registration discovery rules documented (same compilation / referenced generators behavior consistent with other Register* attributes), so that cross-assembly expectations match DP033-style thinking where applicable.
  56. As a security-conscious developer, I want pipeline behaviors not to become a plugin sandbox or remoting layer, so that the domain stays an in-process primitive.
  57. As a tester, I want tests to assert external behavior (dispatch results, diagnostics IDs/messages, generated public API shapes via Verify) rather than private generator helpers, so that refactors do not false-fail.
  58. As a release manager, I want no version bump/tag/publish as part of feature PRs unless explicitly requested, so that AGENTS.md release policy holds.

Implementation Decisions

Scope and phasing

  • Domain name: Command Router (ROADMAP F3 Top-1).
  • MVP (must): runtime command/handler/router primitives; [RegisterCommandHandler] dual shapes; generator frozen CLR-type map; Send/TrySend (+ async); duplicate + contract-mismatch generator diagnostics; Design Doc; unit + generator Verify tests.
  • Phase 2 (same domain, separate tickets OK): Analyzer + CodeFix for unregistered handlers; MSDI RegisterDi / AddCommandRouter; Autofac parity.
  • Phase 3 (capabilities): ordered pipeline behaviors; stream send mode; optional traced send.
  • Pipeline and stream are capabilities, not separate domains (Wayfinder Grilling: apply hard gates and draft rejection appendix #251).

Boundary vs siblings

  • vs Event Aggregator: EA = 1:N Publish notifications, no response. Command Router = 1:1 Send with response. Do not unify into one MediatR-like façade in this spec.
  • vs Strategy: Strategy is string-keyed interchangeable algorithms; Command Router is CLR-type request dispatch. No string Keys required for the default path.
  • vs Chain/Decorator: reuse their ordering / onion ideas for pipeline behaviors; do not require commands to be modeled as Chain contexts.
  • vs MediatR: overlap allowed; differentiation is attribute→frozen map + bijection diagnostics + DesignPatterns DI/generator conventions.

Modules (one PR each)

  • Runtime: command/handler/router primitives and exceptions under Behavioral (or agreed namespace consistent with Event Aggregator).
  • Diagnostics: new DP### IDs/descriptors (do not consume ADR-008 reserved DP067–DP071; allocate from the next free ID after that reservation, currently expected DP072+, confirming against DiagnosticIds.cs at implementation time).
  • SourceGenerators: RegisterCommandHandler generator (+ later pipeline/stream generators or modes).
  • Analyzers + CodeFixes: unregistered handler rule + CodeFix (phase 2).
  • DependencyInjection / Autofac: extensions + targets integration (phase 2).
  • Docs: Design Doc + ROADMAP checkbox / AGENTS summary row + CHANGELOG when user-visible.
  • Samples (sibling repo): sample project in a separate PR when API is usable.
  • Package: only if metapackage packing must change.

API contracts (conceptual)

  • Commands: marker / ICommand and ICommand<TResult> (exact interface names to match DesignPatterns naming on implement).
  • Handlers: ICommandHandler<TCommand> / ICommandHandler<TCommand,TResult> with async CancellationToken.
  • Router: ICommandRouter with Send / TrySend / async twins; explicit failure modes (no silent null).
  • Attributes: [RegisterCommandHandler] non-generic + generic forms; optional later [CommandPipelineBehavior(order)].
  • Manual builder remains available when no generator is used (Strategy/Event Aggregator precedent).

Pipeline shape (when phase 3 lands)

  • Prefer Chain-like next delegate onion around the terminal handler (familiar to IHandler<T> users) or Decorator-like wrap of the handler instance—implementer picks one and documents it in the Design Doc; do not ship both competing models.
  • Behaviors are ordered; duplicate order = Error.

Stream shape (when phase 3 lands)

  • Second generator/router mode binding stream handlers to SendStreamAsync-style API; non-stream MVP APIs must remain valid.

Architectural constraints (AGENTS.md)

  • Primitives over frameworks; Core without MSDI; async first-class; dual TFM netstandard2.0 + net8.0; Roslyn 4.8.0 baseline; nullable enable; TreatWarningsAsErrors; English for issues/PRs/commits.
  • No AppDomain reflection registration scans.
  • Diagnostic help links via existing DiagnosticHelpLinks pattern; Docs site diagnostics pages may lag in sibling Docs repo.

Testing Decisions (seams — confirmed)

What makes a good test here: assert observable external behavior only—dispatch results and failure modes, diagnostic IDs/severities/messages, and Verify snapshots of generated public sources—not private generator helpers or incidental formatting churn.

Primary seam (highest): DesignPatterns.SourceGenerators.Tests Verify of generated command↔handler map and bijection diagnostics (happy registry/router glue; duplicate handler Error; contract mismatch Error; optional DI RegisterDi snapshot when that phase lands). Prior art: RegisterEventHandlerGenerator Verify tests; Strategy/Factory registry Verify tests.

Secondary seams (later module tickets, do not elevate above primary):

  • DesignPatterns.Analyzers.Tests — unregistered handler Analyzer + CodeFix (prior art: DP044/DP006/DP023/DP024).
  • DesignPatterns.Tests — runtime router unit tests (prior art: Event Aggregator / Strategy registry tests).
  • DesignPatterns.Extensions.DependencyInjection.Tests (+ Autofac tests) — Send via provider-built router (prior art: Event Aggregator / Strategy DI tests).

Modules under test: SourceGenerators (primary), then Runtime, Analyzers/CodeFixes, DI/Autofac as those PRs land.

Out of Scope

  • Replacing or merging Event Aggregator into Command Router.
  • Cross-process messaging, queues, persistence, sagas, outbox, retries-as-platform.
  • Correlation-id request/reply messenger (rejected as standalone domain in Wayfinder).
  • Full MediatR feature parity (notification combined façade, advanced open-generic ecosystems, license/branding compatibility concerns beyond “overlap OK”).
  • Implementing Builder or Fork–Join Work Graph (other Top-3 items).
  • Resilience Pipeline / Specification / Channel Pipeline / Proxy (watch list).
  • VSIX CompletionProvider / Rider plugins.
  • API freeze, stable SemVer policy change, or NuGet publish as part of this feature work.
  • Changing ADR-008 Singleton diagnostic ID assignments.
  • Thick actor frameworks or mailbox runtimes.

Further Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions