Skip to content

Commit 244279e

Browse files
authored
Merge pull request #4183 from xbizzybone/feature/4175-validation-handler-fluentvalidation
feat: add request validation handler with FluentValidation provider (#4175)
2 parents 6548bc0 + ffc919e commit 244279e

105 files changed

Lines changed: 4731 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Brighter.slnx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,9 @@
228228
<Project Path="tests/Paramore.Brighter.Testing.Tests/Paramore.Brighter.Testing.Tests.csproj" />
229229
<Project Path="tests/Paramore.Brighter.TickerQ.Tests/Paramore.Brighter.TickerQ.Tests.csproj" />
230230
<Project Path="tests/Paramore.Brighter.Transforms.Adaptors.Tests/Paramore.Brighter.Transforms.Adaptors.Tests.csproj" />
231+
<Project Path="tests/Paramore.Brighter.Validation.DataAnnotations.Tests/Paramore.Brighter.Validation.DataAnnotations.Tests.csproj" />
232+
<Project Path="tests/Paramore.Brighter.Validation.Specification.Tests/Paramore.Brighter.Validation.Specification.Tests.csproj" />
233+
<Project Path="tests/Paramore.Brighter.Validation.FluentValidation.Tests/Paramore.Brighter.Validation.FluentValidation.Tests.csproj" />
231234
<Project Path="tests/Paramore.Test.Helpers/Paramore.Test.Helpers.csproj" />
232235
</Folder>
233236
<Folder Name="/tools/">
@@ -323,6 +326,9 @@
323326
<Project Path="src/Paramore.Brighter.Transformers.JustSaying/Paramore.Brighter.Transformers.JustSaying.csproj" />
324327
<Project Path="src/Paramore.Brighter.Transformers.MassTransit/Paramore.Brighter.Transformers.MassTransit.csproj" />
325328
<Project Path="src/Paramore.Brighter.Transformers.MongoGridFS/Paramore.Brighter.Transformers.MongoGridFS.csproj" />
329+
<Project Path="src/Paramore.Brighter.Validation.DataAnnotations/Paramore.Brighter.Validation.DataAnnotations.csproj" />
330+
<Project Path="src/Paramore.Brighter.Validation.Specification/Paramore.Brighter.Validation.Specification.csproj" />
331+
<Project Path="src/Paramore.Brighter.Validation.FluentValidation/Paramore.Brighter.Validation.FluentValidation.csproj" />
326332
<Project Path="src/Paramore.Brighter/Paramore.Brighter.csproj" />
327333
<Folder Name="/benchmarks/">
328334
<Project Path="benchmarks/Paramore.Brighter.Benchmarks/Paramore.Brighter.Benchmarks.csproj" />

CONTRIBUTORS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ Brighter contributors (sorted alphabeticaly)
137137
**[Tarun Pothulapati](https://github.com/Pothulapati)**
138138
* PostgreSQL Outbox
139139

140+
**[Miguel Ramirez](https://github.com/xbizzybone)**
141+
* Request validation handler with FluentValidation, DataAnnotations and Specification providers
142+
140143
**[Ravriel](https://github.com/ravriel)**
141144
* RabbitMQ Fixes
142145

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.0.1" />
7474
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.7" />
7575
<PackageVersion Include="Microsoft.Data.Sqlite.Core" Version="10.0.7" />
76+
<PackageVersion Include="FluentValidation" Version="11.12.0" />
7677
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.7" />
7778
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.7" />
7879
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# 63. Request validation handler
2+
3+
Date: 2026-06-15
4+
5+
## Status
6+
7+
Proposed
8+
9+
## Context
10+
11+
Commands and queries typically need their input validated before the business
12+
handler runs. Bad input can cause exceptions deep in the pipeline, SQL injection
13+
risks, or unnecessary database load. MediatR has a well-established pattern for
14+
this: a pipeline behavior that resolves an `IValidator<TRequest>` and validates
15+
the request before calling `next`.
16+
17+
Brighter already expresses cross-cutting concerns as attribute + handler pairs in
18+
the request pipeline (for example `RequestLoggingAttribute` +
19+
`RequestLoggingHandler<TRequest>`, and `UseInboxAttribute` +
20+
`UseInboxHandler<TRequest>`). A validation concern fits the same shape: an
21+
attribute marks the target `Handle`/`HandleAsync` method, and a decorator handler
22+
runs before the business handler.
23+
24+
Issue [#4175](https://github.com/BrighterCommand/Brighter/issues/4175) proposes:
25+
26+
- a `[ValidateQuery]` attribute and a corresponding `ValidateRequestHandler`;
27+
- the decorator resolves `IValidator<TRequest>` from the DI container and
28+
validates before calling `next`;
29+
- on failure, throw a `RequestValidationException` with structured error details;
30+
- support for three frameworks over time — Brighter's Specification pattern,
31+
FluentValidation, and `System.ComponentModel.DataAnnotations`.
32+
33+
The maintainer noted that "FluentValidation might be a good starting PR".
34+
35+
## Decision
36+
37+
We add validation as an **optional, additive concern**. The provider-agnostic
38+
abstractions live in the **core** `Paramore.Brighter` assembly (they depend only
39+
on the core and carry no third-party reference, exactly like the built-in
40+
`RequestLogging`/`UseInbox` attribute+handler pairs), and each validation
41+
framework ships as **its own provider package**, so users only take the dependency
42+
they need.
43+
44+
**Core — `Paramore.Brighter`, under a `RequestValidation/` sub-folder**:
45+
46+
- `ValidateRequestAttribute` / `ValidateRequestAsyncAttribute`
47+
(namespace `Paramore.Brighter.RequestValidation.Attributes`) — extend
48+
`RequestHandlerAttribute`; `GetHandlerType()` points at the **abstract** base
49+
handlers below, mirroring `RequestLoggingAttribute`.
50+
- `ValidateRequestHandler<TRequest>` / `ValidateRequestHandlerAsync<TRequest>`
51+
(namespace `Paramore.Brighter.RequestValidation.Handlers`) — **abstract** base
52+
handlers extending `RequestHandler<TRequest>` / `RequestHandlerAsync<TRequest>`.
53+
They own the shared pipeline behaviour (null-guard the request, ask the provider
54+
for failures, throw on any failure, otherwise call `base.Handle`/`base.HandleAsync`)
55+
and expose one abstract method, `Validate` / `ValidateAsync`, that returns the
56+
failures.
57+
- `RequestValidationException` and `RequestValidationError`
58+
(namespace `Paramore.Brighter.RequestValidation`) — the exception carries a
59+
framework-agnostic `IReadOnlyCollection<RequestValidationError>` (property,
60+
message, attempted value, error code), so a caller catches **one** exception type
61+
regardless of provider. `RequestValidationError` is named to avoid a clash with
62+
the pre-existing `Paramore.Brighter.ValidationError`, which reports findings from
63+
Brighter's *startup pipeline validation* (ADR 0053) — a different concern.
64+
65+
**Provider packages** (separate assemblies, this PR):
66+
67+
- `Paramore.Brighter.Validation.FluentValidation` — derives from the abstract base
68+
handlers and implements `Validate`/`ValidateAsync` by resolving a FluentValidation
69+
`IValidator<TRequest>` from the container and mapping its failures onto
70+
`RequestValidationError`. `UseFluentValidation()` maps the abstract handler types
71+
to the FluentValidation implementations on the container.
72+
- `Paramore.Brighter.Validation.DataAnnotations` — validates with
73+
`System.ComponentModel.DataAnnotations`; `UseDataAnnotations()`.
74+
- `Paramore.Brighter.Validation.Specification` — validates with Brighter's own
75+
Specification pattern (ADR 0040); `UseSpecification()`.
76+
77+
Because the attribute targets the abstract handler and each provider package maps
78+
it to a concrete implementation, **one `[ValidateRequest]` attribute works for
79+
every framework**: adding a further provider is purely additive — a new concrete
80+
handler plus a `UseX()` registration — with no change to the abstractions or to
81+
the existing provider packages.
82+
83+
**Missing validator** is treated as a configuration error: if a request is marked
84+
with `[ValidateRequest]` but no validator is registered for it, the provider handler
85+
throws `ConfigurationException` (Brighter's existing misconfiguration type),
86+
fail-fast, rather than silently skipping validation.
87+
88+
## Consequences
89+
90+
- Validation is opt-in per request via one attribute; the framework is chosen by
91+
which provider package is registered. The core gains no third-party dependency —
92+
only a small, dependency-free abstraction, consistent with the existing built-in
93+
middleware.
94+
- A single `RequestValidationException` / `RequestValidationError` model is shared
95+
by all current and future providers, so edge code (e.g. an API mapping to HTTP
96+
422) catches one type.
97+
- Adding further frameworks is additive and leaves the shipped code untouched.
98+
99+
### Resolution of the open questions (maintainer feedback on PR #4183)
100+
101+
- **Naming.** Renamed `[ValidateQuery]``[ValidateRequest]` (and the async
102+
variant). "Query" was a holdover from the Darker write-up of the issue; Brighter
103+
has requests, not queries. No alias is kept.
104+
- **Placement.** The abstractions are folded into the core `Paramore.Brighter`
105+
assembly (under `RequestValidation/`) rather than a separate
106+
`Paramore.Brighter.Validation` package, following the same pattern as the other
107+
built-in attribute+handler pairs. Providers remain separate packages.
108+
- **Issue tracking.** This PR delivers FluentValidation, DataAnnotations and
109+
Specification providers; it "Contributes to" #4175.
110+
- **Darker.** The same requirement exists for Darker V5; it carries a lot of
111+
structural change for V5 and is intentionally out of scope here, to follow as
112+
separate work.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net9.0</TargetFramework>
4+
<OutputType>Exe</OutputType>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
</PropertyGroup>
8+
<ItemGroup>
9+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
10+
<PackageReference Include="Microsoft.Extensions.Hosting" />
11+
</ItemGroup>
12+
<ItemGroup>
13+
<ProjectReference Include="..\..\..\src\Paramore.Brighter\Paramore.Brighter.csproj" />
14+
<ProjectReference Include="..\..\..\src\Paramore.Brighter.Extensions.DependencyInjection\Paramore.Brighter.Extensions.DependencyInjection.csproj" />
15+
<ProjectReference Include="..\..\..\src\Paramore.Brighter.Validation.DataAnnotations\Paramore.Brighter.Validation.DataAnnotations.csproj" />
16+
</ItemGroup>
17+
</Project>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#region Licence
2+
/* The MIT License (MIT)
3+
Copyright © 2026 Miguel Ramirez <xbizzybone@gmail.com>
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in
13+
all copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE. */
22+
23+
#endregion
24+
25+
using DataAnnotationsSample;
26+
using Microsoft.Extensions.DependencyInjection;
27+
using Microsoft.Extensions.Hosting;
28+
using Paramore.Brighter;
29+
using Paramore.Brighter.Extensions.DependencyInjection;
30+
using Paramore.Brighter.RequestValidation;
31+
using Paramore.Brighter.Validation.DataAnnotations;
32+
33+
var builder = Host.CreateApplicationBuilder();
34+
35+
// DataAnnotations needs nothing extra registered: the constraints live on the request type itself.
36+
// AutoFromAssemblies discovers RegisterUserHandler; UseDataAnnotations() maps the provider-agnostic
37+
// [ValidateRequest] attribute to the DataAnnotations implementation.
38+
builder.Services
39+
.AddBrighter()
40+
.AutoFromAssemblies()
41+
.UseDataAnnotations();
42+
43+
var host = builder.Build();
44+
var commandProcessor = host.Services.GetRequiredService<IAmACommandProcessor>();
45+
46+
Console.WriteLine("Sending a valid registration...");
47+
commandProcessor.Send(new RegisterUser { Name = "Ada", Email = "ada@example.com" });
48+
49+
Console.WriteLine();
50+
Console.WriteLine("Sending an invalid registration (empty name, malformed email)...");
51+
try
52+
{
53+
commandProcessor.Send(new RegisterUser { Name = "", Email = "not-an-email" });
54+
}
55+
catch (RequestValidationException exception)
56+
{
57+
Console.WriteLine($"Rejected before the handler ran, with {exception.Errors.Count} error(s):");
58+
foreach (var error in exception.Errors)
59+
Console.WriteLine($" - {error.PropertyName}: {error.ErrorMessage}");
60+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"$schema": "http://json.schemastore.org/launchsettings.json",
3+
"profiles": {
4+
"Development": {
5+
"commandName": "Project",
6+
"environmentVariables": {
7+
"ASPNETCORE_ENVIRONMENT": "Development"
8+
}
9+
}
10+
}
11+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#region Licence
2+
/* The MIT License (MIT)
3+
Copyright © 2026 Miguel Ramirez <xbizzybone@gmail.com>
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in
13+
all copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE. */
22+
23+
#endregion
24+
25+
using System.ComponentModel.DataAnnotations;
26+
using Paramore.Brighter;
27+
28+
namespace DataAnnotationsSample;
29+
30+
/// <summary>
31+
/// A request that declares its own validation constraints with DataAnnotations attributes: Name is required
32+
/// and Email must be present and a well-formed address. The provider validates the request against these
33+
/// attributes, so no separate validator type is needed.
34+
/// </summary>
35+
public sealed class RegisterUser() : Command(Id.Random())
36+
{
37+
[Required(AllowEmptyStrings = false, ErrorMessage = "Name must not be empty")]
38+
public string Name { get; set; } = string.Empty;
39+
40+
[Required(AllowEmptyStrings = false, ErrorMessage = "Email must not be empty")]
41+
[EmailAddress(ErrorMessage = "Email must be a valid email address")]
42+
public string Email { get; set; } = string.Empty;
43+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#region Licence
2+
/* The MIT License (MIT)
3+
Copyright © 2026 Miguel Ramirez <xbizzybone@gmail.com>
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in
13+
all copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE. */
22+
23+
#endregion
24+
25+
using Paramore.Brighter;
26+
using Paramore.Brighter.RequestValidation.Attributes;
27+
28+
namespace DataAnnotationsSample;
29+
30+
/// <summary>
31+
/// The business handler for <see cref="RegisterUser"/>. The <see cref="ValidateRequestAttribute"/> runs the
32+
/// validation handler first, so this method only executes once the request is known to be valid.
33+
/// </summary>
34+
public sealed class RegisterUserHandler : RequestHandler<RegisterUser>
35+
{
36+
[ValidateRequest(step: 1, timing: HandlerTiming.Before)]
37+
public override RegisterUser Handle(RegisterUser command)
38+
{
39+
Console.WriteLine($" Handler ran: registered {command.Name} <{command.Email}>");
40+
41+
return base.Handle(command);
42+
}
43+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net9.0</TargetFramework>
4+
<OutputType>Exe</OutputType>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
</PropertyGroup>
8+
<ItemGroup>
9+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
10+
<PackageReference Include="Microsoft.Extensions.Hosting" />
11+
<PackageReference Include="FluentValidation" />
12+
</ItemGroup>
13+
<ItemGroup>
14+
<ProjectReference Include="..\..\..\src\Paramore.Brighter\Paramore.Brighter.csproj" />
15+
<ProjectReference Include="..\..\..\src\Paramore.Brighter.Extensions.DependencyInjection\Paramore.Brighter.Extensions.DependencyInjection.csproj" />
16+
<ProjectReference Include="..\..\..\src\Paramore.Brighter.Validation.FluentValidation\Paramore.Brighter.Validation.FluentValidation.csproj" />
17+
</ItemGroup>
18+
</Project>

0 commit comments

Comments
 (0)