Fluent Validation
Benzene.FluentValidation runs FluentValidation validators against a message handler's request before the handler executes, short-circuiting the pipeline with a ValidationError result when validation fails.
Overview
FluentValidation lets you define strongly-typed validation rules for a request type using a fluent, lambda-based API (AbstractValidator<T>), separate from the request type itself and separate from the handler's business logic.
Benzene.FluentValidation plugs this into the middleware pipeline as a single piece of middleware, ValidationMiddleware<TRequest, TResponse>, added via .UseFluentValidation() inside .UseMessageHandlers(...). For each request:
- If no
IValidator<TRequest>is registered in DI, the middleware does nothing and calls the next middleware (which eventually invokes the handler). - If a validator is found and the request is
null, the pipeline short-circuits with aValidationErrorresult ("Request is null"). - If a validator is found and validation fails, the pipeline short-circuits with the mapped status (
ValidationErrorby default — see Failure status mapping) and the collected error messages. - If validation passes,
next()is called and the handler runs as normal.
Use this package when you want validation rules that live outside the request/handler, support complex cross-property rules, and can be unit-tested independently with FluentValidation's own test helpers.
Installation
dotnet add package Benzene.FluentValidation
This pulls in the FluentValidation NuGet package (11.8.0 at the time of writing) as a transitive dependency.
Basic Usage
Define a validator for your request type:
using FluentValidation;
public class CreateWidgetRequestValidator : AbstractValidator<CreateWidgetRequest>
{
public CreateWidgetRequestValidator()
{
RuleFor(x => x.Name).MaximumLength(10);
}
}
Wire the middleware into the message handler pipeline:
using Benzene.FluentValidation;
app.UseBenzeneMessage(benzeneMessageApp => benzeneMessageApp
.UseMessageHandlers(router => router
.UseFluentValidation()
)
);
.UseFluentValidation() does two things:
- Registers an
IValidationStatusMapper(DefaultValidationStatusMapper, unless one is already registered) and discovers everyAbstractValidator<T>in the given assemblies, registering each one againstIValidator<T>withTryAddSingleton(see Validator discovery). - Adds
ValidationMiddleware<TRequest, TResponse>to the handler pipeline, which resolvesIValidator<TRequest>for the current request type on every call.
If CreateWidgetRequest.Name is longer than 10 characters, the handler is never invoked and the caller receives a ValidationError result containing FluentValidation's error messages.
Validator discovery
.UseFluentValidation() has two overloads that control which assemblies/types are scanned for validators:
// Scan specific assemblies
router.UseFluentValidation(typeof(CreateWidgetRequestValidator).Assembly);
// Scan specific types
router.UseFluentValidation(new[] { typeof(CreateWidgetRequestValidator) });
// No arguments: scans every assembly currently loaded in the AppDomain
router.UseFluentValidation();
Discovery works by finding every non-abstract type assignable to FluentValidation's IValidator (excluding types from the FluentValidation assembly itself), then registering each one as IValidator<TRequest> via services.TryAddSingleton(...). Because TryAddSingleton is used:
- Validators are constructed once and reused for the lifetime of the application — they should not hold per-request mutable state.
- If you've already registered an
IValidator<TRequest>yourself (e.g.services.AddTransient<IValidator<CreateWidgetRequest>, CreateWidgetRequestValidator>()), that registration wins and the scan will not overwrite it.
You can also register validators (and the schema builder) directly against the DI container without going through the router, using AddFluentValidation:
using Benzene.FluentValidation;
services.UsingBenzene(x => x
.AddFluentValidation(typeof(CreateWidgetRequestValidator).Assembly));
ValidationMiddleware resolves the validator per-request with IServiceResolver.TryGetService<IValidator<TRequest>>(), so a request type with no matching validator simply skips validation — it is not an error.
Failure status mapping
By default, a failed validation maps to BenzeneResultStatus.ValidationError. This is resolved by IValidationStatusMapper.GetStatus(handlerType, requestType, validationResult), implemented by DefaultValidationStatusMapper, which checks — in order:
- Per-rule status — if any
ValidationFailure.CustomStateis aBenzeneValidationState(set via.WithStatus(...)on a rule), that status is used. - Per-handler status — if the handler class (or method) carries a
[ValidationStatus("...")]attribute, that status is used. - Default — falls back to
BenzeneResultStatus.ValidationError.
Overriding the status from a rule
using FluentValidation;
using Benzene.FluentValidation;
using Benzene.Results;
public class SampleValidator : AbstractValidator<SampleRequest>
{
public SampleValidator()
{
RuleFor(x => x.Name).NotEmpty().WithStatus(BenzeneResultStatus.BadRequest);
}
}
A failure on this rule returns BenzeneResultStatus.BadRequest instead of ValidationError.
Overriding the status from the handler
using Benzene.Abstractions.Validation;
using Benzene.Results;
[ValidationStatus(BenzeneResultStatus.Forbidden)]
public class SampleHandler : IMessageHandler<SampleRequest, string>
{
public Task<IBenzeneResult<string>> HandleAsync(SampleRequest request)
{
return Task.FromResult(BenzeneResult.Ok("Success"));
}
}
Any failing rule on SampleRequest (that doesn't itself specify .WithStatus(...)) returns BenzeneResultStatus.Forbidden for this handler.
You can supply a custom IValidationStatusMapper implementation instead of the default by registering it before calling .UseFluentValidation() (registration uses TryAddSingleton, so the first one registered wins).
Composing inside .UseMessageHandlers()
.UseFluentValidation() is a handler-pipeline middleware builder (IHandlerMiddlewareBuilder) added inside the router passed to .UseMessageHandlers(...) — the same place a handler's own request/response types have been resolved, before the handler is invoked:
app.UseBenzeneMessage(benzeneMessageApp => benzeneMessageApp
.UseTimer("benzene-message")
.UseLogResult(x => x.WithCorrelationId())
.UseMessageHandlers(router => router
.UseFluentValidation()
)
);
Because it is per-handler middleware, it runs for every message handler in the pipeline that has a matching IValidator<TRequest> registered — there's no need to add it once per handler.
Client-side validation
Benzene.FluentValidation also ships ValidationClientMiddleware<TRequest, TResponse>, which applies the same "resolve IValidator<TRequest>, validate, short-circuit with ValidationError on failure" behavior to outgoing Benzene client calls (IBenzeneClientContext<TRequest, TResponse>), rather than incoming message handler requests. Note that client-side failures always map to BenzeneResultStatus.ValidationError — the per-rule/per-handler status mapping described above only applies to ValidationMiddleware on the handler side.
Custom string validators
The package includes a set of reusable FluentValidation rule extensions for common string checks, in Benzene.FluentValidation.Common:
using FluentValidation;
using Benzene.FluentValidation.Common;
public class TestValidator : AbstractValidator<TestValidationObject>
{
public TestValidator()
{
RuleFor(x => x.IsOneOf).IsOneOf("one", "two");
RuleFor(x => x.IsAlphaNumericAndSymbols).IsAlphaNumericAndSymbols('!').NotEmpty().NotNull();
RuleFor(x => x.IsBoolean).IsBoolean();
RuleFor(x => x.IsDoubleGuid).IsDoubleGuid();
RuleFor(x => x.IsGuid).IsGuid();
RuleFor(x => x.IsJson).IsJson();
RuleFor(x => x.IsLettersAndSymbols).IsLettersAndSymbols('!');
RuleFor(x => x.IsNumbersAndSymbols).IsNumbersAndSymbols('!');
RuleFor(x => x.IsNumeric).IsNumeric();
}
}
Available extensions: IsGuid(), IsDoubleGuid() (two GUIDs separated by |), IsNumeric(), IsJson(), IsOneOf(params string[] options), IsStrictAlphabetic(), IsLettersAndSymbols(params char[] validChars), IsAlphaNumericAndSymbols(params char[] validChars), IsNumbersAndSymbols(params char[] validChars), and IsBoolean().
Validation schema (OpenAPI / documentation generation)
AddFluentValidation also registers an IValidationSchemaBuilder (FluentValidationSchemaBuilder) that reflects over a validator's rules to produce a description per property — used by Benzene's documentation/schema generation to describe validation rules (e.g. for OpenAPI output) without re-running FluentValidation itself:
using Benzene.FluentValidation.Schema;
var schemaBuilder = new FluentValidationSchemaBuilder(new TestValidator());
var schemas = schemaBuilder.GetValidationSchemas(typeof(TestValidationObject));
// schemas["IsOneOf"][0].Name == "IsOneOf"
// schemas["IsOneOf"][0].Description == "Is one of 'one', 'two'"
Recognized rule types include minimum/maximum length, regular expressions, NotEmpty/NotNull, IsOneOf, email, phone number, numeric comparisons (GreaterThan, LessThanOrEqual, etc.), and the custom string validators listed above. Rules it doesn't recognize are omitted from the schema rather than causing an error.
See Also
- Message Handlers — where validation middleware sits in the request lifecycle
- Handler Result —
IBenzeneResultstatuses, includingValidationError - Middleware — general middleware pipeline concepts
- Data Annotations — the attribute-based alternative to FluentValidation