Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3657ee2
docs(adr): record request validation handler (ADR 0063)
xbizzybone Jun 15, 2026
cd870f2
feat(validation): add request validation handler with FluentValidatio…
xbizzybone Jun 15, 2026
c537cce
test(validation): add adversarial tests for the validation handler
xbizzybone Jun 15, 2026
6868522
Merge branch 'master' into feature/4175-validation-handler-fluentvali…
iancooper Jun 15, 2026
0854b7c
refactor(validation): fold abstractions into core as RequestValidation
xbizzybone Jun 15, 2026
bbdf77a
test(validation): exercise the real UseFluentValidation() registration
xbizzybone Jun 15, 2026
348bd43
feat(validation): add DataAnnotations validation provider
xbizzybone Jun 16, 2026
67b069a
test(validation): add adversarial tests for the DataAnnotations provider
xbizzybone Jun 16, 2026
e504f0e
feat(validation): add Specification validation provider
xbizzybone Jun 16, 2026
5ce88f7
test(validation): add adversarial tests for the Specification provider
xbizzybone Jun 16, 2026
9849c7b
refactor(validation): unify Use* names and document specification lif…
xbizzybone Jun 16, 2026
445d0c0
test(validation): cover the async UseX() registration for all three p…
xbizzybone Jun 16, 2026
5ad5340
refactor(validation): rename ValidationPipeline test double to Comman…
xbizzybone Jun 16, 2026
1998d11
test(validation): assert valid requests do not throw rather than retu…
xbizzybone Jun 16, 2026
44176b4
docs(validation): add samples for the three validation providers
xbizzybone Jun 16, 2026
ffc919e
Merge branch 'master' into feature/4175-validation-handler-fluentvali…
iancooper Jun 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
6 changes: 6 additions & 0 deletions Brighter.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@
<Project Path="tests/Paramore.Brighter.Testing.Tests/Paramore.Brighter.Testing.Tests.csproj" />
<Project Path="tests/Paramore.Brighter.TickerQ.Tests/Paramore.Brighter.TickerQ.Tests.csproj" />
<Project Path="tests/Paramore.Brighter.Transforms.Adaptors.Tests/Paramore.Brighter.Transforms.Adaptors.Tests.csproj" />
<Project Path="tests/Paramore.Brighter.Validation.DataAnnotations.Tests/Paramore.Brighter.Validation.DataAnnotations.Tests.csproj" />
<Project Path="tests/Paramore.Brighter.Validation.Specification.Tests/Paramore.Brighter.Validation.Specification.Tests.csproj" />
<Project Path="tests/Paramore.Brighter.Validation.FluentValidation.Tests/Paramore.Brighter.Validation.FluentValidation.Tests.csproj" />
<Project Path="tests/Paramore.Test.Helpers/Paramore.Test.Helpers.csproj" />
</Folder>
<Folder Name="/tools/">
Expand Down Expand Up @@ -323,6 +326,9 @@
<Project Path="src/Paramore.Brighter.Transformers.JustSaying/Paramore.Brighter.Transformers.JustSaying.csproj" />
<Project Path="src/Paramore.Brighter.Transformers.MassTransit/Paramore.Brighter.Transformers.MassTransit.csproj" />
<Project Path="src/Paramore.Brighter.Transformers.MongoGridFS/Paramore.Brighter.Transformers.MongoGridFS.csproj" />
<Project Path="src/Paramore.Brighter.Validation.DataAnnotations/Paramore.Brighter.Validation.DataAnnotations.csproj" />
<Project Path="src/Paramore.Brighter.Validation.Specification/Paramore.Brighter.Validation.Specification.csproj" />
<Project Path="src/Paramore.Brighter.Validation.FluentValidation/Paramore.Brighter.Validation.FluentValidation.csproj" />
<Project Path="src/Paramore.Brighter/Paramore.Brighter.csproj" />
<Folder Name="/benchmarks/">
<Project Path="benchmarks/Paramore.Brighter.Benchmarks/Paramore.Brighter.Benchmarks.csproj" />
Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ Brighter contributors (sorted alphabeticaly)
**[Tarun Pothulapati](https://github.com/Pothulapati)**
* PostgreSQL Outbox

**[Miguel Ramirez](https://github.com/xbizzybone)**
* Request validation handler with FluentValidation, DataAnnotations and Specification providers

**[Ravriel](https://github.com/ravriel)**
* RabbitMQ Fixes

Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.0.1" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.7" />
<PackageVersion Include="Microsoft.Data.Sqlite.Core" Version="10.0.7" />
<PackageVersion Include="FluentValidation" Version="11.12.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
Expand Down
112 changes: 112 additions & 0 deletions docs/adr/0063-request-validation-handler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# 63. Request validation handler

Date: 2026-06-15

## Status

Proposed

## Context

Commands and queries typically need their input validated before the business
handler runs. Bad input can cause exceptions deep in the pipeline, SQL injection
risks, or unnecessary database load. MediatR has a well-established pattern for
this: a pipeline behavior that resolves an `IValidator<TRequest>` and validates
the request before calling `next`.

Brighter already expresses cross-cutting concerns as attribute + handler pairs in
the request pipeline (for example `RequestLoggingAttribute` +
`RequestLoggingHandler<TRequest>`, and `UseInboxAttribute` +
`UseInboxHandler<TRequest>`). A validation concern fits the same shape: an
attribute marks the target `Handle`/`HandleAsync` method, and a decorator handler
runs before the business handler.

Issue [#4175](https://github.com/BrighterCommand/Brighter/issues/4175) proposes:

- a `[ValidateQuery]` attribute and a corresponding `ValidateRequestHandler`;
- the decorator resolves `IValidator<TRequest>` from the DI container and
validates before calling `next`;
- on failure, throw a `RequestValidationException` with structured error details;
- support for three frameworks over time — Brighter's Specification pattern,
FluentValidation, and `System.ComponentModel.DataAnnotations`.

The maintainer noted that "FluentValidation might be a good starting PR".

## Decision

We add validation as an **optional, additive concern**. The provider-agnostic
abstractions live in the **core** `Paramore.Brighter` assembly (they depend only
on the core and carry no third-party reference, exactly like the built-in
`RequestLogging`/`UseInbox` attribute+handler pairs), and each validation
framework ships as **its own provider package**, so users only take the dependency
they need.

**Core — `Paramore.Brighter`, under a `RequestValidation/` sub-folder**:

- `ValidateRequestAttribute` / `ValidateRequestAsyncAttribute`
(namespace `Paramore.Brighter.RequestValidation.Attributes`) — extend
`RequestHandlerAttribute`; `GetHandlerType()` points at the **abstract** base
handlers below, mirroring `RequestLoggingAttribute`.
- `ValidateRequestHandler<TRequest>` / `ValidateRequestHandlerAsync<TRequest>`
(namespace `Paramore.Brighter.RequestValidation.Handlers`) — **abstract** base
handlers extending `RequestHandler<TRequest>` / `RequestHandlerAsync<TRequest>`.
They own the shared pipeline behaviour (null-guard the request, ask the provider
for failures, throw on any failure, otherwise call `base.Handle`/`base.HandleAsync`)
and expose one abstract method, `Validate` / `ValidateAsync`, that returns the
failures.
- `RequestValidationException` and `RequestValidationError`
(namespace `Paramore.Brighter.RequestValidation`) — the exception carries a
framework-agnostic `IReadOnlyCollection<RequestValidationError>` (property,
message, attempted value, error code), so a caller catches **one** exception type
regardless of provider. `RequestValidationError` is named to avoid a clash with
the pre-existing `Paramore.Brighter.ValidationError`, which reports findings from
Brighter's *startup pipeline validation* (ADR 0053) — a different concern.

**Provider packages** (separate assemblies, this PR):

- `Paramore.Brighter.Validation.FluentValidation` — derives from the abstract base
handlers and implements `Validate`/`ValidateAsync` by resolving a FluentValidation
`IValidator<TRequest>` from the container and mapping its failures onto
`RequestValidationError`. `UseFluentValidation()` maps the abstract handler types
to the FluentValidation implementations on the container.
- `Paramore.Brighter.Validation.DataAnnotations` — validates with
`System.ComponentModel.DataAnnotations`; `UseDataAnnotations()`.
- `Paramore.Brighter.Validation.Specification` — validates with Brighter's own
Specification pattern (ADR 0040); `UseSpecification()`.

Because the attribute targets the abstract handler and each provider package maps
it to a concrete implementation, **one `[ValidateRequest]` attribute works for
every framework**: adding a further provider is purely additive — a new concrete
handler plus a `UseX()` registration — with no change to the abstractions or to
the existing provider packages.

**Missing validator** is treated as a configuration error: if a request is marked
with `[ValidateRequest]` but no validator is registered for it, the provider handler
throws `ConfigurationException` (Brighter's existing misconfiguration type),
fail-fast, rather than silently skipping validation.

## Consequences

- Validation is opt-in per request via one attribute; the framework is chosen by
which provider package is registered. The core gains no third-party dependency —
only a small, dependency-free abstraction, consistent with the existing built-in
middleware.
- A single `RequestValidationException` / `RequestValidationError` model is shared
by all current and future providers, so edge code (e.g. an API mapping to HTTP
422) catches one type.
- Adding further frameworks is additive and leaves the shipped code untouched.

### Resolution of the open questions (maintainer feedback on PR #4183)

- **Naming.** Renamed `[ValidateQuery]` → `[ValidateRequest]` (and the async
variant). "Query" was a holdover from the Darker write-up of the issue; Brighter
has requests, not queries. No alias is kept.
- **Placement.** The abstractions are folded into the core `Paramore.Brighter`
assembly (under `RequestValidation/`) rather than a separate
`Paramore.Brighter.Validation` package, following the same pattern as the other
built-in attribute+handler pairs. Providers remain separate packages.
- **Issue tracking.** This PR delivers FluentValidation, DataAnnotations and
Specification providers; it "Contributes to" #4175.
- **Darker.** The same requirement exists for Darker V5; it carries a lot of
structural change for V5 and is intentionally out of scope here, to follow as
separate work.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Paramore.Brighter\Paramore.Brighter.csproj" />
<ProjectReference Include="..\..\..\src\Paramore.Brighter.Extensions.DependencyInjection\Paramore.Brighter.Extensions.DependencyInjection.csproj" />
<ProjectReference Include="..\..\..\src\Paramore.Brighter.Validation.DataAnnotations\Paramore.Brighter.Validation.DataAnnotations.csproj" />
</ItemGroup>
</Project>
60 changes: 60 additions & 0 deletions samples/Validation/DataAnnotationsSample/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#region Licence
/* The MIT License (MIT)
Copyright © 2026 Miguel Ramirez <xbizzybone@gmail.com>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. */

#endregion

using DataAnnotationsSample;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Paramore.Brighter;
using Paramore.Brighter.Extensions.DependencyInjection;
using Paramore.Brighter.RequestValidation;
using Paramore.Brighter.Validation.DataAnnotations;

var builder = Host.CreateApplicationBuilder();

// DataAnnotations needs nothing extra registered: the constraints live on the request type itself.
// AutoFromAssemblies discovers RegisterUserHandler; UseDataAnnotations() maps the provider-agnostic
// [ValidateRequest] attribute to the DataAnnotations implementation.
builder.Services
.AddBrighter()
.AutoFromAssemblies()
.UseDataAnnotations();

var host = builder.Build();
var commandProcessor = host.Services.GetRequiredService<IAmACommandProcessor>();

Console.WriteLine("Sending a valid registration...");
commandProcessor.Send(new RegisterUser { Name = "Ada", Email = "ada@example.com" });

Console.WriteLine();
Console.WriteLine("Sending an invalid registration (empty name, malformed email)...");
try
{
commandProcessor.Send(new RegisterUser { Name = "", Email = "not-an-email" });
}
catch (RequestValidationException exception)
{
Console.WriteLine($"Rejected before the handler ran, with {exception.Errors.Count} error(s):");
foreach (var error in exception.Errors)
Console.WriteLine($" - {error.PropertyName}: {error.ErrorMessage}");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"Development": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
43 changes: 43 additions & 0 deletions samples/Validation/DataAnnotationsSample/RegisterUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#region Licence
/* The MIT License (MIT)
Copyright © 2026 Miguel Ramirez <xbizzybone@gmail.com>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. */

#endregion

using System.ComponentModel.DataAnnotations;
using Paramore.Brighter;

namespace DataAnnotationsSample;

/// <summary>
/// A request that declares its own validation constraints with DataAnnotations attributes: Name is required
/// and Email must be present and a well-formed address. The provider validates the request against these
/// attributes, so no separate validator type is needed.
/// </summary>
public sealed class RegisterUser() : Command(Id.Random())
{
[Required(AllowEmptyStrings = false, ErrorMessage = "Name must not be empty")]
public string Name { get; set; } = string.Empty;

[Required(AllowEmptyStrings = false, ErrorMessage = "Email must not be empty")]
[EmailAddress(ErrorMessage = "Email must be a valid email address")]
public string Email { get; set; } = string.Empty;
}
43 changes: 43 additions & 0 deletions samples/Validation/DataAnnotationsSample/RegisterUserHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#region Licence
/* The MIT License (MIT)
Copyright © 2026 Miguel Ramirez <xbizzybone@gmail.com>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. */

#endregion

using Paramore.Brighter;
using Paramore.Brighter.RequestValidation.Attributes;

namespace DataAnnotationsSample;

/// <summary>
/// The business handler for <see cref="RegisterUser"/>. The <see cref="ValidateRequestAttribute"/> runs the
/// validation handler first, so this method only executes once the request is known to be valid.
/// </summary>
public sealed class RegisterUserHandler : RequestHandler<RegisterUser>
{
[ValidateRequest(step: 1, timing: HandlerTiming.Before)]
public override RegisterUser Handle(RegisterUser command)
{
Console.WriteLine($" Handler ran: registered {command.Name} <{command.Email}>");

return base.Handle(command);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="FluentValidation" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Paramore.Brighter\Paramore.Brighter.csproj" />
<ProjectReference Include="..\..\..\src\Paramore.Brighter.Extensions.DependencyInjection\Paramore.Brighter.Extensions.DependencyInjection.csproj" />
<ProjectReference Include="..\..\..\src\Paramore.Brighter.Validation.FluentValidation\Paramore.Brighter.Validation.FluentValidation.csproj" />
</ItemGroup>
</Project>
38 changes: 38 additions & 0 deletions samples/Validation/FluentValidationSample/GreetingCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#region Licence
/* The MIT License (MIT)
Copyright © 2026 Miguel Ramirez <xbizzybone@gmail.com>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. */

#endregion

using Paramore.Brighter;

namespace FluentValidationSample;

/// <summary>
/// A request greeting someone; both <see cref="Name"/> and <see cref="Email"/> are checked by
/// <see cref="GreetingCommandValidator"/> before <see cref="GreetingCommandHandler"/> runs.
/// </summary>
public sealed class GreetingCommand() : Command(Id.Random())
{
public string Name { get; set; } = string.Empty;

public string Email { get; set; } = string.Empty;
}
Loading
Loading