Skip to content

feat(validation)!: run startup checks as pluggable rules - #5

Merged
illia1f merged 5 commits into
mainfrom
feat/validation-extensibility
Aug 9, 2026
Merged

feat(validation)!: run startup checks as pluggable rules#5
illia1f merged 5 commits into
mainfrom
feat/validation-extensibility

Conversation

@illia1f

@illia1f illia1f commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Startup validation was one method with every check inlined. Adding a check meant editing RegistrationValidator, and a package built on RequestFlow had no way to add one at all.

Validation now runs as a list of rules. A rule reads the whole registration picture off RequestFlowValidationContext and reports into the same exception as the built-in checks. The built-in checks are rules themselves, and AddValidationRule<T>() appends to the list.

  • The model lives in RequestFlow.Abstractions, so a rule can ship in an assembly that never references the DI package. Lifetimes come through as RequestFlowLifetime instead of the container's ServiceLifetime, and a handler or stage reports the contract it implements as ContractType, so a kind layered on a core contract, such as ICommandHandler, arrives under its own name. Every list on the model is read-only.
  • RequestFlowModelBuilder builds a RequestFlowModel by hand and BuildContext wraps one in the context a rule receives, which is how a rule is unit tested without a container.
  • A rule that throws is reported as RF0107, and the rules after it still run.
  • Two checks are new. RF0106 catches a request implementing more than one IRequest<TResponse>, which used to pass the freeze and then fail every dispatch under it with ResponseTypeMismatchException. CQRS0001 catches a request that AddCqrs classifies as both a command and a query.
  • RF0104 now reports one problem per stage-class collision instead of a line per colliding pair, and no longer fires for two closings of one stage class on a request with more than one handler. That request already fails RF0101, and the collision surfaces once the duplicate handler is gone.
  • docs/validation-rules.md covers writing, registering, and testing a rule. samples/Orders.Api is a minimal API wired with two custom rules, a validation stage, and a logging stage; samples/Orders.Api.Violations is the same wiring with the mistakes left in, so the exception can be read against real code.

BREAKING CHANGE: RequestFlowValidationException.Problems holds RequestFlowValidationProblem values (stable code, message, offending type) instead of strings, and the public constructor takes the same list, so a call site passing strings no longer compiles. Message lines now read RF0101: ..., so anything matching the old text breaks. Repeated registrations collapse into one problem each, where a request with three handlers used to report two identical lines. ProblemCodes and CqrsProblemCodes ship in the abstractions packages, so match ProblemCodes.UnhandledRequest rather than a literal string.

illia1f added 5 commits August 9, 2026 14:46
A rule implementing IRequestFlowValidationRule reads the whole registration
picture off RequestFlowValidationContext and returns
RequestFlowValidationProblem values carrying a stable code from ProblemCodes.

The model reports lifetimes as RequestFlowLifetime rather than the
container's ServiceLifetime, since RequestFlow.Abstractions takes no
dependency on the DI package. Handlers and stages carry the contract they
implement, so a kind layered on a core contract, such as ICommandHandler,
comes through under its own.

RequestFlowModelBuilder builds a model by hand and BuildContext wraps one in
the context a rule receives, which is how a rule is tested without a
container.

Nothing consumes these types yet.
The six built-in registration checks move out of RegistrationValidator and
behind IRequestFlowValidationRule, so an application or a package adds its
own checks with AddValidationRule<T>() and reports into the same exception.
A rule that throws is reported as RF0107 and the rules after it still run.

RegistrationSnapshot projects the registry into the RequestFlowModel a rule
sees, which is why HandlerRegistration now carries the lifetime chosen by
the AddRequestFlow call that found it.

A request implementing more than one IRequest<TResponse> now fails the
freeze with RF0106. The extra contract used to pass the freeze and fail
every dispatch under it with ResponseTypeMismatchException.

BREAKING CHANGE: RequestFlowValidationException.Problems holds
RequestFlowValidationProblem values instead of strings, and the public
constructor takes the same list, so a call site passing strings no longer
compiles. Message lines now read "RF0101: ...", so anything matching the old
text breaks. Repeated registrations collapse into one problem each, where a
request with three handlers used to report two identical lines. Aliased
stage declarations report a single RF0104 naming every declaration in the
collision, and RF0104 no longer fires for two closings of one stage class on
a request with more than one handler, since that request already fails
RF0101.
AddCqrs registers CommandQuerySplitRule, so a request implementing both
ICommand<T> and IQuery<T> fails the freeze with CQRS0001 instead of
resolving through whichever typed dispatcher the caller happened to reach
for. The rule checks contract assignability rather than response shapes, so
it also catches ICommand<A> next to IQuery<B>.

CqrsProblemCodes is public and ships in RequestFlow.Cqrs.Abstractions, so an
assembly referencing only the contracts can match the code instead of a
literal string.
docs/validation-rules.md walks the rule contract, the model a rule reads,
registration and lifetime, and how to test a rule without a container. It
also lists every built-in code, since a caller matching ProblemCodes needs
to know what already fires.

The exception, lifetime, registration, and stage pages pick up the
code-carrying problems and the narrowed RF0104.
Orders.Api dispatches commands and queries, filters a validation stage
by handler marker, and contributes two validation rules of its own.
Orders.Api.Violations holds the types those rules reject, in an assembly
scanned only under --break-rules, so the startup failure is
reproducible from a checkout.
@illia1f
illia1f merged commit bfbb7f2 into main Aug 9, 2026
1 check passed
@illia1f
illia1f deleted the feat/validation-extensibility branch August 9, 2026 18:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant