Fluent Validation

Benzene.FluentValidation runs FluentValidation validators against a message handler's request before the handler executes, short-circuiting the pipeline with a validation-error 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 validation-error 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 validation-error.

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 validation-error 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.

Structured errors on the wire (field and code)

A validation failure doesn't just carry a joined detail string — each ValidationFailure becomes one BenzeneError (Message/Field/Code) on the result, and (when the failure travels over the wire) one entry in the problem document's errors array:

{
  "type": "https://benzene.app/problems/validation-error",
  "title": "Validation failed",
  "status": 422,
  "detail": "'Name' must not be empty.",
  "benzeneStatus": "validation-error",
  "errors": [
    { "message": "'Name' must not be empty.", "field": "/name", "code": "NotEmptyValidator" },
    { "message": "'Sku' must not be empty.", "field": "/items/0/sku", "code": "NotEmptyValidator" }
  ]
}

Over gRPC the same pointer travels as google.rpc.BadRequest's dotted FieldViolation.field (items[0].sku), and a Benzene gRPC client reads it back as the pointer. The error's code (e.g. FluentValidation's ErrorCode) travels as FieldViolation.reason and is read back too.

Changed in the spec 0.2 catch-up. field used to be FluentValidation's .NET property path verbatim (Items[0].Sku). A caller that matched on it needs the pointer form now — see the CHANGELOG.

The same rule applies to ValidationClientMiddleware on the outgoing-client side. See Message Results — Problem documents for the full document shape and how to read one back with GetProblem().

See Also