Trellis.FluentValidation — API Reference
Header
- Package:
Trellis.FluentValidation - Namespace:
Trellis.FluentValidation - Purpose: Two integration paths for FluentValidation in Trellis:
- Mediator integration —
AddTrellisFluentValidation()plugs FluentValidation validators into the existingValidationBehavior<TMessage,TResponse>via the open-genericIMessageValidator<TMessage>adapter. No additional pipeline behavior is added. - Standalone helpers —
FluentValidationResultExtensionsconverts aValidationResult(or runs anIValidator<T>synchronously/asynchronously) into aResult<T>failure backed byError.UnprocessableContent.
- Mediator integration —
See also: trellis-api-cookbook.md — recipes using this package.
Use this file when
- You want FluentValidation validators to run inside the Trellis Mediator validation behavior.
- You need to convert a FluentValidation
ValidationResultintoResult<T>/Error.UnprocessableContent. - You need exact JSON Pointer normalization behavior for FluentValidation property names.
Patterns Index
| Goal | Canonical API / pattern | See |
|---|---|---|
| Add the FluentValidation adapter without scanning | services.AddTrellisFluentValidation() plus explicit IValidator<T> registrations |
FluentValidationServiceCollectionExtensions |
| Add the adapter and scan assemblies | services.AddTrellisFluentValidation(typeof(SomeType).Assembly) |
FluentValidationServiceCollectionExtensions |
| Keep AOT/trim safety | Use the parameterless adapter overload and register validators explicitly | FluentValidationServiceCollectionExtensions |
Convert ValidationResult to Result<T> |
validationResult.ToResult(value) |
FluentValidationResultExtensions |
| Validate a value outside Mediator | validator.ValidateToResult(value) / ValidateToResultAsync(...) |
FluentValidationResultExtensions |
| Understand nested/indexed field paths | FluentValidation names are normalized to RFC 6901 JSON Pointers | Pointer normalization |
Common traps
AddTrellisFluentValidation()does not add a second mediator pipeline behavior; it registersIMessageValidator<TMessage>so the existingValidationBehaviorcan aggregate failures.- The assembly-scanning overload is intentionally not AOT/trim-safe. Use explicit registrations for AOT-sensitive apps.
- Keep primitive-to-value-object parsing at the transport seam; validators should normally validate already-shaped command/value-object inputs.
Types
FluentValidationServiceCollectionExtensions
Declaration
public static class FluentValidationServiceCollectionExtensions
Methods
| Signature | Returns | Description |
|---|---|---|
public static IServiceCollection AddTrellisFluentValidation(this IServiceCollection services) |
IServiceCollection |
Registers FluentValidationMessageValidatorAdapter<TMessage> as the open-generic IMessageValidator<TMessage> implementation. Every IValidator<T> registered for the message in DI then runs inside the existing ValidationBehavior<TMessage,TResponse> and contributes its failures to an aggregated Error.UnprocessableContent. AOT/trim-safe; uses open-generic DI registration with no reflection. Idempotent — repeated calls do not duplicate the adapter. Throws ArgumentNullException when services is null. Validators must be registered explicitly (e.g., services.AddScoped<IValidator<CreateOrderCommand>, CreateOrderCommandValidator>()). |
public static IServiceCollection AddTrellisFluentValidation(this IServiceCollection services, params Assembly[] assemblies) |
IServiceCollection |
Calls the parameterless overload, then scans the supplied assemblies for concrete IValidator<T> implementations and registers each as a scoped service. Not AOT or trim-compatible — annotated [RequiresUnreferencedCode] and [RequiresDynamicCode]. Skips abstract/interface/open-generic types. Deduplicates so repeated calls (or overlapping assemblies) do not register the same validator twice. Throws ArgumentNullException for null services/assemblies, and ArgumentException when assemblies is empty or contains a null element. Tolerates ReflectionTypeLoadException by using only loadable types. |
FluentValidationMessageValidatorAdapter<TMessage>
Declaration
public sealed class FluentValidationMessageValidatorAdapter<TMessage>(
IEnumerable<IValidator<TMessage>> validators)
: IMessageValidator<TMessage>
where TMessage : Mediator.IMessage
Methods
| Signature | Returns | Description |
|---|---|---|
public ValueTask<IResult> ValidateAsync(TMessage message, CancellationToken cancellationToken) |
ValueTask<IResult> |
Runs every injected IValidator<TMessage> against message. Returns Result.Ok() when all validators pass (or none are registered — the empty injected sequence allocates no violations). Otherwise aggregates every ValidationFailure into a single new Error.UnprocessableContent(EquatableArray.Create(violations)), where violations is the collected FieldViolation set. Each FluentValidation failure becomes a FieldViolation(new InputPointer(pointerPath), reasonCode) { Detail = failure.ErrorMessage }. pointerPath is derived by JsonPointerNormalizer.ToJsonPointer from the FV property name; reasonCode defaults to "validation.error" when failure.ErrorCode is null/whitespace. Root-level failures (whitespace PropertyName) use typeof(TMessage).Name. |
Pointer normalization (RFC 6901)
FluentValidation property names are converted to JSON Pointers so they round-trip through InputPointer:
FluentValidation PropertyName |
Resulting InputPointer.RawValue |
|---|---|
Email |
/Email |
Address.PostCode |
/Address/PostCode |
Items[0].Sku |
/Items/0/Sku |
FluentValidationResultExtensions
Declaration
public static class FluentValidationResultExtensions
Constructors
- None. This is a static class.
Properties
| Name | Type | Description |
|---|---|---|
| None | — | This static class exposes no public properties. |
Methods
| Signature | Returns | Description |
|---|---|---|
public static Result<T> ToResult<T>(this ValidationResult validationResult, T value, [CallerArgumentExpression(nameof(value))] string paramName = "value") |
Result<T> |
Returns Result.Ok(value) when validationResult.IsValid is true; otherwise groups validationResult.Errors by property name, substitutes paramName for root-level failures, and returns Result.Fail<T>(new Error.UnprocessableContent(fieldViolations)) where each FluentValidation failure becomes a FieldViolation(InputPointer.ForProperty(propName), reasonCode) { Detail = fvMessage }. Throws ArgumentNullException when validationResult is null. |
public static Result<T> ValidateToResult<T>(this IValidator<T> validator, T value, [CallerArgumentExpression(nameof(value))] string paramName = "value", string? message = null) |
Result<T> |
Throws ArgumentNullException when validator is null. If value is null, does not call validator.Validate; instead returns a validation failure for paramName using message ?? $"'{paramName}' must not be empty.". Otherwise calls validator.Validate(value) and forwards to ToResult(value, paramName). |
public static async Task<Result<T>> ValidateToResultAsync<T>(this IValidator<T> validator, T value, [CallerArgumentExpression(nameof(value))] string paramName = "value", string? message = null, CancellationToken cancellationToken = default) |
Task<Result<T>> |
Throws ArgumentNullException when validator is null. If value is null, does not call validator.ValidateAsync; instead returns the same validation failure shape as ValidateToResult. Otherwise awaits validator.ValidateAsync(value, cancellationToken).ConfigureAwait(false) and forwards to ToResult(value, paramName). |
Extension methods
FluentValidationResultExtensions
public static Result<T> ToResult<T>(
this ValidationResult validationResult,
T value,
[CallerArgumentExpression(nameof(value))] string paramName = "value")
public static Result<T> ValidateToResult<T>(
this IValidator<T> validator,
T value,
[CallerArgumentExpression(nameof(value))] string paramName = "value",
string? message = null)
public static async Task<Result<T>> ValidateToResultAsync<T>(
this IValidator<T> validator,
T value,
[CallerArgumentExpression(nameof(value))] string paramName = "value",
string? message = null,
CancellationToken cancellationToken = default)
Behavioral notes
Mediator integration (AddTrellisFluentValidation + adapter)
- FluentValidation does not add an additional pipeline behavior. It plugs into the existing
ValidationBehavior<TMessage,TResponse>via the open-genericIMessageValidator<TMessage>extension point. - The adapter is registered scoped, matching the typical scoped lifetime of FluentValidation validators.
- When no
IValidator<TMessage>is registered for a message type,IEnumerable<IValidator<TMessage>>is empty, the adapter returnsResult.Ok(), and no allocations are performed. - All validators are awaited sequentially; failures from every validator are aggregated into a single
Error.UnprocessableContentrather than short-circuiting on the first failure. - The adapter forwards the ambient
CancellationTokentovalidator.ValidateAsync. AddTrellisFluentValidation()is idempotent — calling it multiple times (directly, or via the scanning overload) only registers the open-generic adapter once.- The assembly-scan overload deduplicates
(serviceType, implementationType)pairs against existing registrations, so calling it twice with overlapping assemblies will not register a validator more than once.
Standalone helpers (FluentValidationResultExtensions)
- The extension methods are stateless; they do not keep shared mutable state or add synchronization.
- Shared validator instances are only as concurrency-safe as the underlying
IValidator<T>implementation; these helpers do not change that. ToResult<T>only null-checksvalidationResult; it does not independently reject anullvalue.- Validation failures are converted into
Error.UnprocessableContentwhoseFieldscollection is built from oneFieldViolationper FluentValidation failure (no grouping; multiple failures on the same property emit multiple violations). - Grouping rule:
string.IsNullOrWhiteSpace(e.PropertyName) ? paramName : e.PropertyName. ValidateToResult<T>andValidateToResultAsync<T>short-circuitnullinput before invoking FluentValidation.- Null-input failures are created as
new ValidationResult([new ValidationFailure(paramName, message ?? $"'{paramName}' must not be empty.")]). ValidateToResultAsync<T>propagates cancellation throughvalidator.ValidateAsync(value, cancellationToken).- Exceptions from FluentValidation itself are not caught, except for the explicit
ArgumentNullException.ThrowIfNull(...)guards onvalidationResultandvalidator.
Code examples
Wire FluentValidation into the Mediator pipeline (AOT-safe)
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Trellis.FluentValidation;
using Trellis.Mediator;
services.AddTrellisBehaviors();
services.AddTrellisFluentValidation();
// Register validators explicitly so the call site is AOT/trim-friendly.
services.AddScoped<IValidator<CreateOrderCommand>, CreateOrderCommandValidator>();
services.AddScoped<IValidator<UpdateOrderCommand>, UpdateOrderCommandValidator>();
Wire FluentValidation with assembly scanning (not AOT-compatible)
using Trellis.FluentValidation;
services.AddTrellisBehaviors();
services.AddTrellisFluentValidation(typeof(CreateOrderCommandValidator).Assembly);
Convert an existing ValidationResult
using FluentValidation;
using FluentValidation.Results;
using Trellis;
using Trellis.FluentValidation;
public sealed record CreateUserRequest(string Email);
var validator = new InlineValidator<CreateUserRequest>();
validator.RuleFor(x => x.Email).NotEmpty().EmailAddress();
var request = new CreateUserRequest("invalid-email");
ValidationResult validation = validator.Validate(request);
Result<CreateUserRequest> result = validation.ToResult(request);
Validate directly with sync and async helpers
using System.Threading;
using FluentValidation;
using Trellis;
using Trellis.FluentValidation;
public sealed record CreateUserRequest(string Email);
var validator = new InlineValidator<CreateUserRequest>();
validator.RuleFor(x => x.Email).NotEmpty().EmailAddress();
var request = new CreateUserRequest("user@example.com");
Result<CreateUserRequest> syncResult = validator.ValidateToResult(request);
Result<CreateUserRequest> asyncResult =
await validator.ValidateToResultAsync(request, cancellationToken: CancellationToken.None);
Null input with caller-expression field naming
using FluentValidation;
using Trellis;
using Trellis.FluentValidation;
string? alias = null;
var validator = new InlineValidator<string?>();
validator.RuleFor(x => x).NotEmpty();
Result<string?> result = validator.ValidateToResult(alias, message: "Alias is required.");