Skip to content

feat: add request validation handler with FluentValidation provider (#4175)#4183

Merged
iancooper merged 16 commits into
BrighterCommand:masterfrom
xbizzybone:feature/4175-validation-handler-fluentvalidation
Jun 16, 2026
Merged

feat: add request validation handler with FluentValidation provider (#4175)#4183
iancooper merged 16 commits into
BrighterCommand:masterfrom
xbizzybone:feature/4175-validation-handler-fluentvalidation

Conversation

@xbizzybone

@xbizzybone xbizzybone commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

What

Adds request validation to Brighter as an optional, additive concern, with FluentValidation as the first provider — the starting point suggested in the issue.

Contributes to #4175

Design

Two assemblies so a single [ValidateQuery] attribute serves every framework, while users only take the dependency they need:

Paramore.Brighter.Validation (provider-agnostic, depends only on the core)

  • [ValidateQuery] / [ValidateQueryAsync] — extend RequestHandlerAttribute, mirroring RequestLoggingAttribute; they target the abstract base handlers.
  • ValidateRequestHandler<TRequest> / ValidateRequestHandlerAsync<TRequest>abstract base handlers that own the shared behaviour: null-guard the request, ask the provider for failures, throw before calling next, otherwise call base.Handle/base.HandleAsync.
  • RequestValidationException carrying a framework-agnostic IReadOnlyCollection<ValidationError> (property, message, attempted value, error code), so a caller catches one exception type regardless of provider.

Paramore.Brighter.Validation.FluentValidation (this PR's provider)

  • FluentValidationRequestHandler<TRequest> / ...Async derive from the base handlers and resolve a FluentValidation IValidator<TRequest> from the container, mapping its failures onto ValidationError.
  • UseFluentValidation() maps the abstract handlers to these implementations. The core is not modified.

Because [ValidateQuery] targets the abstract handler and the provider package maps it to a concrete one, adding Paramore.Brighter.Validation.DataAnnotations or ...Specification later is purely additive — a new concrete handler plus a UseX() registration, with no change to the abstractions or to this package.

A request marked [ValidateQuery] with no registered validator throws ConfigurationException (Brighter's existing misconfiguration type), fail-fast, rather than silently skipping validation. An ADR (0063) is included as the first commit, per CONTRIBUTING.

Scope / additive guarantees

  • Two new assemblies + a new test project. The two edits to existing files are unavoidable wiring with no logic change:
    • Brighter.slnx — register the new projects.
    • Directory.Packages.props — central version for FluentValidation (11.12.0, the last 11.x line that still supports netstandard2.0, which Brighter targets).
  • Targets netstandard2.0;net8.0;net9.0;net10.0; strong-named with Brighter.snk.

Tests

xUnit, one behavior per file. Adversarial coverage through the FluentValidation provider: valid request (sync + async), single and multiple validation errors, missing validator (ConfigurationException), a validator that throws (propagated, not swallowed), null request (ArgumentNullException), cancellation-token propagation in the async handler, the exact shape of RequestValidationException, and concurrent reuse of a single handler instance. All green on net10.0 locally; CI covers every TFM.

Open questions for the maintainer

  1. Naming. The issue names the attribute [ValidateQuery], but the concern validates commands as well as queries. Keep it, or rename to [ValidateRequest] (optionally keeping [ValidateQuery] as an alias)? Implemented as specified.
  2. Placement. The abstractions live in a new Paramore.Brighter.Validation package; they depend only on the core, so they could equally live in the core assembly. Which do you prefer?
  3. Issue. This delivers FluentValidation only; I used "Contributes to" rather than "Closes" so you can keep Add a Validation Handler to Brighter #4175 open to track DataAnnotations / Specification.
  4. Darker. The same requirement exists for Darker V5; out of scope here, happy to follow up.

Contributor License Agreement

Signed via CLA assistant — the license/cla check is green.

Minor docs note: CONTRIBUTING.md still links to clahub.com for the CLA, but ClaHub has shut down; the CLA assistant GitHub App is what actually runs on PRs. Might be worth updating that link in a follow-up.

Type of Change

  • New feature (non-breaking change which adds functionality)

Checklist

  • I have read the Contributing Guide
  • I have checked the documentation for relevant guidance
  • I have added/updated XML documentation for any public API changes
  • I have added/updated tests as appropriate
  • My changes follow the existing code style and conventions

Additional Notes

User-facing documentation for the BrighterCommand docs site can follow as a separate change once the API here is confirmed.

Document the decision to add request validation as an optional, additive
concern: provider-agnostic abstractions (the [ValidateQuery] attribute, base
handlers and RequestValidationException) plus FluentValidation as the first
provider, per issue BrighterCommand#4175.

Refs BrighterCommand#4175
…n provider

Add request validation as an optional, additive concern (issue BrighterCommand#4175):

- Paramore.Brighter.Validation: provider-agnostic abstractions. The
  [ValidateQuery] / [ValidateQueryAsync] attributes target abstract
  ValidateRequestHandler<TRequest> / ...Async base handlers that own the shared
  pipeline behaviour (null-guard, ask the provider for failures, throw before
  calling next). RequestValidationException carries a framework-agnostic list of
  ValidationError (property, message, attempted value, error code).
- Paramore.Brighter.Validation.FluentValidation: the first provider. Concrete
  handlers resolve a FluentValidation IValidator<TRequest> from the container and
  map its failures onto ValidationError; UseFluentValidation() wires the abstract
  handlers to these implementations. The core is not modified.

One [ValidateQuery] attribute serves every framework: the provider package maps
the abstract handler to its concrete one, so DataAnnotations and the Specification
pattern can be added later as additive packages. A request marked [ValidateQuery]
with no registered validator throws ConfigurationException (Brighter's existing
misconfiguration type).

Refs BrighterCommand#4175
Cover the issue BrighterCommand#4175 proposal points and the failure paths through the
FluentValidation provider: valid request (sync and async), single and multiple
validation errors, missing validator (ConfigurationException), a validator that
throws, a null request, cancellation-token propagation in the async handler, the
structured shape of RequestValidationException, and concurrent reuse of a single
handler instance.

All tests pass on net10.0. (net9.0 builds but the runtime is unavailable on the
authoring machine; CI exercises every target framework.)

Refs BrighterCommand#4175
@CLAassistant

CLAassistant commented Jun 15, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

codescene-delta-analysis[bot]

This comment was marked as outdated.

@iancooper

iancooper commented Jun 15, 2026

Copy link
Copy Markdown
Member

Thanks for this contribution! It helps a lot. It looks good and we should try to get it out asap.

  1. Let's fold the abstractions into Paramore. Brighter, within a sub-folder. They depend only on the core and carry no third-party reference. Since RequestLoggingAttribute and friends already live in core, this follows the same pattern for built-ins. We should still ship providers separately.
  2. Let's name these [ValidateRequest] and not [ValidateQuery]; they query name is a holdover from writing up the issue for Darker. Brighter has requests, not queries.
  3. Miguel Ramirez in both src csproj files overrides the repo-wide Authors=Ian Cooper / Company=Brighter Command from src/Directory.Build.props. I'm not trying to steal your work, it just helps us attribute official packages. Your attribution belongs in the file header's copyright notice, not in the package author field. You can also add yourself to the contributors file at the root (saves me the job)
  4. There is no friendly error from ValidatePipelines() when UseFluentValidation() is omitted, but we have attributed handlers. We can put that in a separate issue/PR, though
  5. UseFluentValidation() itself is never exercised.

@iancooper

Copy link
Copy Markdown
Member

Let's leave Darker for now, as it has a lot of structural changes for V5, but I can tag you there when it is ready to go. You are welcome to push implementations of DataAnnotations and Specifications here, too.

codescene-delta-analysis[bot]

This comment was marked as outdated.

xbizzybone and others added 8 commits June 15, 2026 19:55
Maintainer feedback on BrighterCommand#4183: move the provider-agnostic validation
abstractions out of the separate Paramore.Brighter.Validation package into the
core Paramore.Brighter assembly under RequestValidation/, mirroring the built-in
RequestLogging/UseInbox attribute+handler pattern. Providers stay separate
packages.

- Rename [ValidateQuery]/[ValidateQueryAsync] -> [ValidateRequest]/
  [ValidateRequestAsync]; Brighter has requests, not queries.
- Rename the error type to RequestValidationError to avoid clashing with the
  pre-existing pipeline-validation Paramore.Brighter.ValidationError.
- Use file-scoped namespaces to match the current core style.
- Point the FluentValidation provider at the core directly; delete the
  standalone abstractions project and its slnx entry.
- Drop the package-level <Authors> override (repo-wide Authors apply); add the
  contributor to CONTRIBUTORS.md.
- Update ADR 0063 to record the decisions and resolved review questions.

Contributes to BrighterCommand#4175

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a test that wires AddBrighter().UseFluentValidation() and sends an invalid
request, proving the extension maps the validation handlers into the pipeline.
Previously the handlers were only ever registered by hand in the test harness,
so UseFluentValidation() itself had no coverage.

Uses a dedicated CreateAccount command with a single sync handler: AddBrighter()
auto-scans every loaded Paramore.Brighter* assembly (including the test
assembly), and SubscriberRegistry keeps sync and async handlers under one key,
so a request that has both a sync and an async handler cannot be used through a
synchronous Send.

Contributes to BrighterCommand#4175

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds Paramore.Brighter.Validation.DataAnnotations, the second provider for the
request-validation concern. DataAnnotationsRequestHandler / ...Async validate a
request against the System.ComponentModel.DataAnnotations attributes declared on
the request type (e.g. [Required], [EmailAddress]) via Validator.TryValidateObject
and map the failures onto RequestValidationError. UseDataAnnotationsValidation()
selects the provider.

Unlike a validator-based provider there is nothing to register per request — the
constraints live on the request type — so a request with no annotations is simply
valid (no missing-validator error). The IServiceProvider is passed to the
ValidationContext so custom ValidationAttributes that resolve services work.
No third-party dependency: the types ship with the framework (netstandard2.0+).

Contributes to BrighterCommand#4175

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Covers the valid path (sync + async), multiple field errors, an invalid async
request, a null request (ArgumentNullException), cancellation-token propagation
in the async handler, a request with no data-annotation attributes being treated
as valid, and the real AddBrighter().UseDataAnnotationsValidation() registration
through a synchronous Send. All green on net10.0; CI covers every TFM.

Contributes to BrighterCommand#4175

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds Paramore.Brighter.Validation.Specification, the third provider for the
request-validation concern, built on Brighter's own Specification pattern
(ADR 0040). SpecificationRequestHandler / ...Async resolve an ISpecification<TRequest>
from the container, evaluate it with IsSatisfiedBy, and on failure collect the
findings via the public ValidationResultCollector visitor, mapping each
ValidationError (Source, Message) onto RequestValidationError. A request marked
[ValidateRequest] with no registered specification throws ConfigurationException,
fail-fast, matching the FluentValidation provider. UseSpecificationValidation()
selects the provider.

Contributes to BrighterCommand#4175

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Covers the valid path (sync + async), a request that breaks multiple rules
(both reported via And-composed specifications), an invalid async request, a
missing specification (ConfigurationException), a null request
(ArgumentNullException), cancellation-token propagation in the async handler,
and the real AddBrighter().UseSpecificationValidation() registration through a
synchronous Send. All green on net10.0; CI covers every TFM.

Contributes to BrighterCommand#4175

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…etime

- Rename UseDataAnnotationsValidation() -> UseDataAnnotations() and
  UseSpecificationValidation() -> UseSpecification(), so all three providers
  follow the same Use<Provider>() shape as the established UseFluentValidation().
- Document that the ISpecification<TRequest> must be registered with a per-request
  lifetime (transient or scoped): Brighter's Specification<T> records per-evaluation
  state for the visitor to collect, so a single shared instance is not safe to
  evaluate from concurrent requests.

Contributes to BrighterCommand#4175

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…roviders

Adds a SendAsync wiring test per provider (FluentValidation, DataAnnotations,
Specification) that drives the real AddBrighter().UseX() registration through the
asynchronous pipeline, closing the gap where only the synchronous Send path was
exercised. Each uses a dedicated async-only command (no sync sibling) to stay
clear of the sync/async handler ambiguity under Brighter's assembly auto-scan.

Contributes to BrighterCommand#4175

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
codescene-delta-analysis[bot]

This comment was marked as outdated.

@xbizzybone

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, @iancooper — really appreciate it. I've pushed the changes addressing all your points:

  1. Folded into the core. The abstractions now live in Paramore.Brighter under RequestValidation/ (with Attributes/ + Handlers/ sub-namespaces, mirroring Logging/Inbox); the standalone Paramore.Brighter.Validation package is gone and the providers stay separate. One thing to flag: I renamed the error type to RequestValidationError to avoid clashing with the existing Paramore.Brighter.ValidationError from the startup pipeline-validation (ADR 0053). I also used file-scoped namespaces to match the current core style.
  2. [ValidateRequest] — renamed (and [ValidateRequestAsync]); the "query" name is gone.
  3. Authors — dropped the <Authors> override so the repo-wide Authors/Company from src/Directory.Build.props apply, and added myself to CONTRIBUTORS.md.
  4. Friendly ValidatePipelines() error — left as you suggested; happy to follow up in a separate issue/PR.
  5. UseFluentValidation() coverage — added a test that drives the real AddBrighter().UseFluentValidation() through the pipeline (previously the handlers were only wired by hand in the test harness).

I also took you up on the offer to add the other two providers in this PR:

  • Paramore.Brighter.Validation.DataAnnotations — validates the request's own System.ComponentModel.DataAnnotations attributes (no per-request validator, no extra dependency — the types ship with the framework).
  • Paramore.Brighter.Validation.Specification — built on your Specification pattern (ADR 0040): resolves an ISpecification<TRequest> and collects failures via the public ValidationResultCollector. I documented that the specification should be registered with a per-request lifetime, since Specification<T> carries per-evaluation state.

The registration methods are UseFluentValidation() / UseDataAnnotations() / UseSpecification(). Each provider has the same adversarial test coverage plus sync and async UseX() wiring tests. Darker — left out for now, tag me when V5 is ready.

@iancooper iancooper left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks really good, and the comments here are mostly about creating clarity rather than providing functional fixes. As this is the first PR for this, it would be nice to get the baseline right.

It would be useful to have a simple sample for these. It can be something as simple as /Users/ian_hammond_cooper/CSharpProjects/github/xbizzybone/Brighter/samples/CommandProcessor but in a separate folder Validation and include some samples that support the different validation types that we have.

The samples will also help us create the docs

/// </summary>
internal sealed class ValidationPipeline
{
private ValidationPipeline(CommandProcessor commandProcessor, HandlerReceipt receipt)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename this? I found it confusing on first review, as it's a CommandProcessorBuilder, not the pipeline doing validation, which Brighter itself provides.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — renamed ValidationPipelineCommandProcessorHarness. That drops the misleading "pipeline" (Brighter owns the validation pipeline) and names it for what it actually is: a builder whose With(...)/WithAsync(...) factories assemble a real CommandProcessor. Updated the XML doc and all call sites (the local pipeline is now harness).

public class ValidSpecificationValidationTests
{
[Fact]
public void When_a_valid_request_satisfies_its_specification_should_return_the_request()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really, what you are testing here is that we don't throw an exception. The fact that the test returns the same request is just an artifact of the path having completed; it's not what you are trying to confirm. Assert.True() would work here as well, because if an exception were thrown, you would throw an exception from the test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, the request-equality was incidental. Switched to Assert.Null(Record.Exception(() => handler.Handle(validRequest))) — the repo's existing "does not throw" idiom — so the test confirms exactly that. I applied the same fix to the four sibling happy-path tests (DataAnnotations + Specification, sync/async) that shared the Assert.Same pattern, and renamed them ..._should_return_the_request..._should_not_throw to match the intent.

@xbizzybone

Copy link
Copy Markdown
Contributor Author

Thanks @iancooper — addressed the review:

  • Renamed the ValidationPipeline test double → CommandProcessorHarness. It's a builder for the CommandProcessor (Brighter owns the actual validation pipeline), and the name now reflects that it bundles the processor with the HandlerReceipt. Doc comment and call sites updated.
  • Valid-request tests now assert "does not throw" via Assert.Null(Record.Exception(...)) instead of returning the request; renamed the four affected happy-path tests to ..._should_not_throw.
  • Added a sample as suggested: samples/Validation/ (modeled on samples/CommandProcessor) with three console apps — FluentValidationSample, DataAnnotationsSample, SpecificationSample — each sending one valid request (reaches the handler) and one invalid request (rejected with the collected RequestValidationException.Errors), plus a README on when to use each provider and the async variant. Verified each runs end-to-end.

xbizzybone and others added 3 commits June 16, 2026 10:52
…dProcessorHarness

It is a builder for the CommandProcessor (Brighter owns the validation
pipeline), and it bundles that processor with the HandlerReceipt the handler
writes to; the new name reflects both concerns. Updates the XML doc and all
call sites (the local `pipeline` variable becomes `harness`).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rn the request

For the happy-path tests, returning the same request was incidental to the
path completing; what they confirm is that validation does not throw. Switches
them to Assert.Null(Record.Exception(...)) (the repo's existing "does not throw"
idiom) and renames the four affected tests from ..._should_return_the_request to
..._should_not_throw.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds samples/Validation/ (modeled on samples/CommandProcessor): three console
apps - FluentValidationSample, DataAnnotationsSample and SpecificationSample -
each sending one valid request (which reaches the handler) and one invalid
request (rejected before the handler, printing the collected
RequestValidationException.Errors), plus a README on when to use each provider
and the async variant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
codescene-delta-analysis[bot]

This comment was marked as outdated.

@codescene-delta-analysis codescene-delta-analysis Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gates Passed
4 Quality Gates Passed

See analysis details in CodeScene

Quality Gate Profile: Clean Code Collective
Install CodeScene MCP: safeguard and uplift AI-generated code. Catch issues early with our IDE extension and CLI tool.

@iancooper

Copy link
Copy Markdown
Member

Thanks for this contribution @xbizzybone. It is much appreciated. Feel free to ping us about other issues that you might want to pick up, and we will let you know. Typically, look for ones that are unassigned. Avoid the gateway for a couple of weeks, as we want to get the generic test framework in

@iancooper iancooper merged commit 244279e into BrighterCommand:master Jun 16, 2026
27 of 30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants