Trellis.Mediator.FluentValidation — API Reference
Header
- Package:
Trellis.Mediator.FluentValidation - Namespace:
Trellis.Mediator.FluentValidation - Purpose: Plugs FluentValidation validators into the Trellis Mediator validation stage. Provides one DI extension class and one open-generic
IMessageValidator<TMessage>adapter; no additional pipeline behavior is added. - Depends on:
Trellis.Mediator(forIMessageValidator<TMessage>and theIMessageconstraint) andTrellis.FluentValidation(forJsonPointerNormalizerand the standaloneResult<T>helpers).
Why this is a separate package.
Trellis.FluentValidationcarries the Mediator-agnostic helpers — theValidationResult → Result<T>extensions and theJsonPointerNormalizer. The Mediator-specific bits (the adapter and its DI extension) live here so an application that only wants the standalone helpers does not pull inTrellis.Mediator. The adapter's behavior, idempotency guarantees, and AOT/trim contract are unchanged from prior versions; only the package and namespace moved.
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 register validators by assembly scanning (non-AOT) or explicitly (AOT/trim-safe).
- You need the wire-up rules for combining FluentValidation failures with
IValidate.Validate()failures into a singleError.InvalidInput.
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 |
| Understand nested/indexed field paths | FluentValidation names are normalized to RFC 6901 JSON Pointers via JsonPointerNormalizer |
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.
- The diagnostic log category emitted by the scanning overload is still
"Trellis.FluentValidation"so existing logging filters continue to work after the package split.
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 and calls AddOptions<ValidationArgsOptions>() so the adapter receives the application's configured argument allowlist. 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.InvalidInput. 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 and emits a single Warning per affected assembly via ILoggerFactory (when one is registered). The diagnostic log category remains "Trellis.FluentValidation" for log-filter compatibility. |
FluentValidationMessageValidatorAdapter<TMessage>
Declaration
public sealed class FluentValidationMessageValidatorAdapter<TMessage>
: IMessageValidator<TMessage>
where TMessage : Mediator.IMessage
{
// Uses ValidationArgsOptions.Default; throws when validators is null.
public FluentValidationMessageValidatorAdapter(IEnumerable<IValidator<TMessage>> validators);
// DI selects this overload; throws when validators or argsOptions is null.
public FluentValidationMessageValidatorAdapter(
IEnumerable<IValidator<TMessage>> validators,
Microsoft.Extensions.Options.IOptions<Trellis.FluentValidation.ValidationArgsOptions> argsOptions);
}
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.InvalidInput(EquatableArray.Create(violations)), where violations is the collected FieldViolation set. Each FluentValidation failure becomes a FieldViolation(new InputPointer(pointerPath), reasonCode, ValidationArgsProjection.Project(failure, options)) { Detail = failure.ErrorMessage }, with options supplied by the selected constructor. pointerPath is derived by JsonPointerNormalizer.ToJsonPointer from the FV property name; reasonCode is ValidationCodeProjection.Project(failure.ErrorCode, failure.AttemptedValue), which maps both a blank code and the legacy validation.error placeholder to error.unspecified — so validation.error is never emitted. Root-level failures (whitespace PropertyName) use typeof(TMessage).Name. |
Pointer normalization (RFC 6901)
FluentValidation property names are converted to JSON Pointers via JsonPointerNormalizer so they round-trip through InputPointer:
FluentValidation PropertyName |
Resulting InputPointer.Path |
|---|---|
Email |
/email |
Address.PostCode |
/address/postCode |
Items[0].Sku |
/items/0/sku |
Dotted FluentValidation paths split into separate JSON-pointer segments; bracketed indexers become numeric segments. Other producers (e.g., the ASP integration) build InputPointer values directly via InputPointer.ForProperty(...), which does not split on ., so the normalizer is FluentValidation-specific.
Reason-code projection
Each ValidationFailure.ErrorCode is projected through ValidationCodeProjection.Project before it reaches FieldViolation.ReasonCode, so a MaximumLengthValidator failure arriving through the Mediator pipeline reports the same string.max-length a generated TryCreate would. NotEmptyValidator is refined against the rejected value — null becomes value.not-null, a string or collection becomes value.not-empty, and a value type left at its default such as Guid.Empty or 0 becomes value.not-default — because those are three failures a client acts on differently. Custom WithErrorCode values pass through verbatim; Must(...) predicates project to error.unspecified.
Validation argument configuration
FieldViolation.Args is projected through ValidationArgsProjection.Project with the configured
ValidationArgsOptions. Widen the allowlist
with services.Configure<ValidationArgsOptions>(options => options.AllowArgs("MinimumAge", "MinAge")).
For manually constructed adapters, pass IOptions<ValidationArgsOptions> to use the same configuration;
the one-argument constructor intentionally uses the immutable default allowlist.
Behavioral notes
- 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.InvalidInputrather 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.
Code examples
Wire FluentValidation into the Mediator pipeline (AOT-safe)
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Trellis.Mediator;
using Trellis.Mediator.FluentValidation;
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.Mediator.FluentValidation;
services.AddTrellisBehaviors();
services.AddTrellisFluentValidation(typeof(CreateOrderCommandValidator).Assembly);
Cross-references
- trellis-api-fluentvalidation.md — the standalone
ValidationResult → Result<T>helpers andJsonPointerNormalizerthat this package builds on. - trellis-api-mediator.md — the pipeline stage this adapter participates in.
- trellis-api-core.md —
Error.InvalidInputshape. - trellis-api-asp.md — how
Error.InvalidInputlands on the wire.