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:

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:

  1. Registers an IValidationStatusMapper (DefaultValidationStatusMapper, unless one is already registered) and discovers every AbstractValidator<T> in the given assemblies, registering each one against IValidator<T> with TryAddSingleton (see Validator discovery).
  2. Adds ValidationMiddleware<TRequest, TResponse> to the handler pipeline, which resolves IValidator<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:

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:

  1. Per-rule status — if any ValidationFailure.CustomState is a BenzeneValidationState (set via .WithStatus(...) on a rule), that status is used.
  2. Per-handler status — if the handler class (or method) carries a [ValidationStatus("...")] attribute, that status is used.
  3. 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