FluentValidation

Shell
dotnet add package Nullean.Entities.FluentValidation

For a team that already validates with FluentValidation. The package works in both directions: your constraints run inside FluentValidation, and your FluentValidation validators run as constraints. Every snippet on this page is taken from tests/Nullean.Entities.FluentValidation.Tests.

A constraint set becomes an IValidator<T>. Register it where the pipeline (ASP.NET Core, a mediator pipeline behaviour, your own) resolves validators, and it is called through ValidateAsync like any other:

C#
services.AddSingleton<IValidator<Customer>>(CustomerRules.All.ToValidator());

Add the constraints to a validator you already have, or check one property or each item of a collection against its own set:

C#
public sealed class SignUpValidator : AbstractValidator<Customer>
{
	public SignUpValidator()
	{
		RuleFor(c => c.Name).NotEmpty();
		Include(CustomerRules.All.ToValidator());
	}
}

validator.RuleFor(c => c.Address).SatisfiesConstraints(AddressRules.All);   // a failure is keyed "Address.City"
validator.RuleForEach(c => c.Lines).SatisfiesConstraints(LineRules.All);    // "Lines[1].Quantity"

ConstraintValidator<T> is the validator class itself: derive from it to add FluentValidation rules next to the constraints (: base(CustomerRules.All)).

Each violation becomes one ValidationFailure:

ValidationFailure From the Violation
PropertyName Member under the rule's property path (Address.City, Lines[1].Quantity). Without a member: the path, or at the root the rule's id
ErrorMessage Message, as written (FluentValidation's {placeholders} are not expanded)
ErrorCode ConstraintId, the rule's name
Severity Error, Warning and Hint become Error, Warning and Info
AttemptedValue Evidence
CustomState The Violation itself

Only the point-in-time rules run, as in Validate: a validator sees one value, not a change, so a rule about a transition is not checked here. A null property is skipped, as FluentValidation skips a child validator; add NotNull() when null is wrong.

Tested in ConstraintValidatorTests.cs.

An existing validator becomes a named constraint, and from there everything that takes a constraint set takes it, including the property tests:

C#
var rules = ConstraintSet.From(("customer-validator", Constraints<Customer>.FromValidator(new CustomerValidator())));
// or: new CustomerValidator().ToConstraintSet()   (named after the validator type)

rules.Validate(customer with { Age = 7 });
// customer-validator  Member "Age"  'Age' must be between 18 and 120. You entered 7.

Each failure keeps its PropertyName as Member (Address.City, Lines[1].Quantity from RuleForEach), its AttemptedValue as Evidence, and Info maps back to Hint. Only errors reject a change in Apply. Tested in ValidatorConstraintsTests.cs.

This is the reason to do it. With Nullean.Entities.CsCheck in the test project, generated values are thrown at the validator:

C#
var rules = new CustomerValidator().ToConstraintSet();

rules.Probe(samples: 500);
//   CustomerValidator    held 62  violated 438      (one run; both must be above zero)

GenAuto.Of<Customer>().ShouldSatisfy(rules);
// Set seed: "..." to reproduce (3 shrinks, ...)
// Customer { Name = "Anna Douglas", Email = "-", Age = 1, ... } violates: CustomerValidator: 'Email' is not a valid email address.; CustomerValidator: 'Age' must be between 18 and 120. You entered 1.

Probe tells you the validator both holds and fails on generated data (a validator that never fails is accepting everything); ShouldSatisfy against the output of your own factory or mapper finds a value it builds that the validator rejects, shrunk and with a seed to replay. Tested in ProbeTests.cs.

  • Severity is in both namespaces. A file with using FluentValidation; and using Nullean.Entities; must say which one it means: using FvSeverity = FluentValidation.Severity;.
  • The validator is run synchronously. Constraints are synchronous and pure, so FromValidator calls Validate; a validator with MustAsync or CustomAsync rules throws FluentValidation's AsyncValidatorInvokedSynchronouslyException when the constraint is checked. Keep database lookups in the application service.
  • One validator is one constraint. Probe counts the whole validator, not each RuleFor; the violation's Member says which property failed.
  • FluentValidation's ErrorCode is not kept on the way in: a violation's ConstraintId is the constraint's name.
  • Not trim or NativeAOT clean: FluentValidation compiles expressions and is not annotated for trimming.
  • Built and tested against FluentValidation 12.2.0 (Apache-2.0).