Skip to main content

FluentValidation

A value object carries its own rules; Value objects shows how to write them by hand. If your team already writes rules with FluentValidation, there is no reason to stop at the value object. DDDToolkit.FluentValidation lets you write a value object's rules as a FluentValidation validator, and lets a value object take part in the validator you write for a command or a request.

There is nothing to register. A value object's validator is created where it is used, so no container is involved, and the rest is extension methods.

dotnet add package Temp.DDDToolkit.FluentValidation

A value object's rules​

Reference the package and the generator gives every value object a nested Validator. You write the other half, with the rules:

[SingleValueObject<string>]
public partial record EmailAddress
{
public static EmailAddress Create(string value) => new(value);

partial class Validator
{
public Validator() => RuleFor(x => x.Value).EmailAddress();
}
}

The generator writes the base class of that validator, the Validate() overrides that run it, and an Errors collection with what it found:

EmailAddress.FluentValidation.g.cs, shortened
partial record EmailAddress
{
[Internal]
[NotMapped]
public ReadOnlyCollection<FluentValidation.Results.ValidationFailure> Errors => _errors.AsReadOnly();

private List<FluentValidation.Results.ValidationFailure> _errors = new();

protected override bool Validate()
{
var validator = new Validator();
var result = validator.Validate(this);
_errors = result.Errors;
return result.IsValid;
}

protected override void Validate(ValidationErrorBuilder errors)
{
foreach (var failure in _errors)
{
// ... copies each failure, with its placeholder values as arguments, into a ValidationError
}
}

partial class Validator : FluentValidation.AbstractValidator<EmailAddress>
{
}
}

The two halves of Validator are the point: the generator states the base class, you state the rules. A value object with several properties is the same, with a rule per property:

[ValueObject]
public partial record Address
{
public Address(string street, string city, string postalCode)
=> (Street, City, PostalCode) = (street, city, postalCode);

public string Street { get; protected init; }
public string City { get; protected init; }
public string PostalCode { get; protected init; }

partial class Validator
{
public Validator()
{
RuleFor(x => x.Street).NotEmpty();
RuleFor(x => x.City).NotEmpty();
RuleFor(x => x.PostalCode).Matches(@"^\d{4}\s?[A-Za-z]{2}$");
}
}
}

Which types get a validator:

DeclarationValidator
[ValueObject]Yes
[SingleValueObject<T>]Yes
[EntityId<T>] partial record, the record form of an identifierYes
[EntityId<T>] partial record structNo: a struct identifier is well formed by construction

A value object that writes its own Validate() or Validate(ValidationErrorBuilder), in any part, is left alone: it gets no Validator, no Errors and no generated overrides, so one project can mix hand-validated value objects with ones validated by rules. Only those two signatures count; a Validate with other parameters is an ordinary method, and the type still gets its validator.

What the rules produce​

Everything the value object already offers now runs your validator: IsValid, ToValid(), TryToValid() and the always-valid twin. The failures come out in two shapes, one for each kind of caller:

Errors is FluentValidation's own ValidationFailure, handy when you already work in that library. ValidationErrors and TryToValid() hand back the toolkit's ValidationError, which carries the same property, code and attempted value, and FluentValidation's placeholder values as Arguments. A caller reads those without referencing FluentValidation at all:

var email = EmailAddress.Create("nope");

email.IsValid; // false
email.Errors[0].PropertyName; // "Value"

email.TryToValid(out var valid, out var errors);
errors[0].Code; // "EmailValidator"
errors[0].Message; // "'Value' is not a valid email address."
errors[0].AttemptedValue; // "nope"
Show the code: rules, a request validator and an endpoint

The value objects state their rules as above. The request validator adds what is about the request, and folds the value objects in:

public sealed record PlaceOrder(EmailAddress? Email, Address? ShipTo, int Quantity);

public sealed class PlaceOrderValidator : AbstractValidator<PlaceOrder>
{
public PlaceOrderValidator()
{
RuleFor(x => x.Email).NotNull().MustBeValid();
RuleFor(x => x.ShipTo).NotNull().MustBeValid();
RuleFor(x => x.Quantity).GreaterThan(0);
}
}

The endpoint turns the result into the toolkit's failures, and those into a 400:

app.MapPost("/orders", (PlaceOrder body) =>
{
var errors = new PlaceOrderValidator().Validate(body).ToValidationErrors();
if (errors.Count > 0)
{
return Results.ValidationProblem(errors.ToErrorDictionary());
}

// ...
});

A value object in a request validator​

A value object validates itself, which is not the same as taking part in the validator you write for a command or a request. MustBeValid() folds it into one:

public sealed class PlaceOrderValidator : AbstractValidator<PlaceOrder>
{
public PlaceOrderValidator()
{
RuleFor(x => x.Email).NotNull().MustBeValid();
RuleFor(x => x.ShipTo).NotNull().MustBeValid();
RuleFor(x => x.Quantity).GreaterThan(0);
}
}

The failure is reported against the containing property, so the caller gets one flat result rather than an exception per field. For a request with an invalid email address, an empty city and no quantity, it reads:

PropertyCodeMessage
EmailValueObjectValidator'Email' is not a valid EmailAddress.
ShipToValueObjectValidator'Ship To' is not a valid Address.
QuantityGreaterThanValidator'Quantity' must be greater than '0'.

ValueObjectValidator is ValueObjectRules.ErrorCode, so a caller can branch on it. The value object's type name travels as the ValueObject argument rather than as text in the message, so a translation can put it where its grammar wants it; see Localization.

A null property passes, exactly as with FluentValidation's own rules, so chain NotNull() when the value is required.

One list for the whole request​

ToValidationErrors() turns a FluentValidation result, or any list of ValidationFailure, into the toolkit's ValidationError. That puts the request validator's failures in the same list as the ones TryToValid() hands back, which is what an endpoint wants when it answers with every failure at once:

public sealed record CheckoutRequest(string Street, string City, string PostalCode, int Quantity);

public sealed class CheckoutRequestValidator : AbstractValidator<CheckoutRequest>
{
public CheckoutRequestValidator() => RuleFor(x => x.Quantity).GreaterThan(0);
}
var errors = new List<ValidationError>(new CheckoutRequestValidator().Validate(body).ToValidationErrors());

if (!new Address(body.Street, body.City, body.PostalCode).TryToValid(out var shipTo, out var addressErrors))
{
errors.AddRange(addressErrors.Prefixed("shipTo"));
}

if (errors.Count > 0)
{
return Results.ValidationProblem(errors.ToErrorDictionary());
}

Prefixed() and ToErrorDictionary() are the toolkit's, and work the same without FluentValidation; see Returning a refusal from an endpoint.

Messages in the reader's language​

FluentValidation phrases Message in its own languages. DDDToolkit.Localization goes further, because every failure arrives with its code and its placeholder values (MaxLength, ComparisonValue, ValueObject) as Arguments: a translation for your own codes and for FluentValidation's is looked up by code, and filled in from the arguments. Nothing on the value object changes. See Localization.

What it does not do​

  • No dependency injection. A value object's Validator is created with new wherever the value is validated, so it cannot take services in its constructor. A rule that needs a service, such as "this email address is not taken", is not a rule about the value; put it in the request validator.
  • No asynchronous rules on a value object. It validates synchronously, whenever IsValid is first read, so MustAsync and other async rules have nowhere to run. A request validator can have async rules of its own and run with ValidateAsync; MustBeValid() in it is a synchronous rule.
  • No rules across value objects. A value object's rules see that value object. Rules about the request as a whole belong in the request validator, next to MustBeValid().