Skip to main content

Advanced Rules

This document covers rules that go beyond static checks: custom predicates, async logic, cross-property dependencies, transformations and nested validators.


Must

Must lets you define any synchronous predicate.

RuleFor(x => x.BirthDate)
.Must(date => date.Year >= 1900)
.WithMessage("The birth date cannot be before 1900.")
.Must(date => DateTime.Today.Year - date.Year >= 18)
.WithMessage("You must be at least 18 years old.");

Must can also access the full root object through a two-parameter overload:

RuleFor(x => x.EndDate)
.Must((request, endDate) => endDate > request.StartDate)
.WithMessage("The end date must be after the start date.");

MustAsync

MustAsync is the async version of Must. Use it when validation requires I/O: database queries, external API calls, etc.

Without CancellationToken

RuleFor(x => x.Email)
.MustAsync(async email =>
{
var exists = await _userRepository.ExistsByEmailAsync(email);
return !exists;
})
.WithMessage("A user is already registered with that email.");

With CancellationToken

RuleFor(x => x.Email)
.MustAsync(async (email, ct) =>
{
var exists = await _userRepository.ExistsByEmailAsync(email, ct);
return !exists;
})
.WithMessage("A user is already registered with that email.");

With Access to the Root Object

RuleFor(x => x.ProductId)
.MustAsync(async (request, productId, ct) =>
{
var product = await _productRepository.GetByIdAsync(productId, ct);
return product?.CategoryId == request.CategoryId;
})
.WithMessage("The product does not belong to the specified category.");

Performance: If you have multiple independent MustAsync calls, consider using ValidateParallelAsync to execute them in parallel. See Validators.


DependentRuleAsync

DependentRuleAsync defines an async rule that depends on two properties of the same object.

DependentRuleAsync(
x => x.ProductId,
x => x.WarehouseId,
async (productId, warehouseId) =>
{
return await _inventory.IsProductAvailableInWarehouseAsync(productId, warehouseId);
}
)
.WithMessage("The product is not available in the specified warehouse.");

Custom

Custom gives full control over validation. You receive the property value and a CustomContext<T> that allows you to add errors in a granular way.

CustomContext<T> Interface

// ctx.Instance — the full T object
// ctx.AddFailure(message) — adds an error to the current property
// ctx.AddFailure(property, message) — adds an error to a specific property
// ctx.AddFailure(property, message, errorCode) — with error code

Example: Complex Cross-Property Validation

RuleFor(x => x.SourceAccountId)
.Custom(async (sourceId, ctx) =>
{
var request = ctx.Instance;
var sourceAccount = await _accounts.GetByIdAsync(sourceId);

if (sourceAccount == null)
{
ctx.AddFailure("SourceAccountId", "The source account does not exist.", "ACCOUNT_NOT_FOUND");
return;
}

if (sourceAccount.Balance < request.Amount)
{
ctx.AddFailure("Amount", $"Insufficient balance. Available: {sourceAccount.Balance:C}.", "INSUFFICIENT_FUNDS");
}
});

SwitchOn — Conditional Rules by Value on a Property

SwitchOn lets you apply different rules to the same property depending on the value of another property.

Syntax

RuleFor(x => x.TargetProperty)
.SwitchOn(x => x.DiscriminatorProperty)
.Case(value1, b => b.Rule1().Rule2())
.Case(value2, b => b.Rule3())
.Default( b => b.FallbackRule());

Complete Example: Document Validator

public class DocumentValidator : AbstractValidator<DocumentDto>
{
public DocumentValidator()
{
RuleFor(x => x.DocumentNumber)
.SwitchOn(x => x.DocumentType)
.Case("passport", b => b.NotEmpty().Matches(@"^[A-Z]{2}\d{6}$")
.WithMessage("The passport number must have the format AA999999."))
.Case("dni", b => b.NotEmpty().IsNumeric().MinimumLength(8).MaximumLength(8)
.WithMessage("The DNI must be exactly 8 digits."))
.Case("ruc", b => b.NotEmpty().IsNumeric().MinimumLength(11).MaximumLength(11)
.WithMessage("The RUC must be exactly 11 digits."))
.Default( b => b.NotEmpty());
}
}

Difference from When/Unless

SwitchOnWhen / Unless
ExclusivityOnly one case runsEach When block is evaluated independently
Multiple variantsOne construct covers all variantsRequires one RuleFor(...).When(...) per variant
OverlapImpossible by designPossible if conditions are not mutually exclusive

Transform

Transform converts the property value before applying rules.

Normalize Before Validating

RuleFor(x => x.SearchTerm)
.Transform(term => term?.Trim().ToLowerInvariant())
.MinimumLength(2)
.MaximumLength(100)
.When(x => x.SearchTerm != null);

Extract Part of the Value

// Validates only the file extension
RuleFor(x => x.FileName)
.Transform(name => Path.GetExtension(name).ToLowerInvariant())
.In(new[] { ".pdf", ".docx", ".xlsx", ".png", ".jpg" })
.WithMessage("The file format is not allowed.");

Type Conversion

RuleFor(x => x.AmountString)
.Transform(s => decimal.TryParse(s, out var result) ? result : -1m)
.GreaterThan(0m)
.WithMessage("The amount must be a positive number.")
.MaxDecimalPlaces(2);

SetValidator

SetValidator delegates validation of a complex property to another validator. Errors appear with the property name as prefix.

public class CreateShipmentValidator : AbstractValidator<CreateShipmentRequest>
{
public CreateShipmentValidator()
{
RuleFor(x => x.ShippingAddress)
.NotNull()
.WithMessage("The shipping address is required.")
.SetValidator(new AddressValidator());

RuleFor(x => x.BillingAddress)
.SetValidator(new AddressValidator())
.When(x => x.BillingAddress != null);
}
}

If ShippingAddress.Street is empty, the result will contain:

{
"ShippingAddress.Street": ["The Street field cannot be empty."]
}

Cross-Property Rules

These rules compare a property against another property on the same object.

GreaterThanProperty / GreaterThanOrEqualToProperty

RuleFor(x => x.EndDate)
.GreaterThanProperty(x => x.StartDate)
.WithMessage("The check-out date must be after the check-in date.");

RuleFor(x => x.MaxPrice)
.GreaterThanOrEqualToProperty(x => x.MinPrice)
.WithMessage("The maximum price must be greater than or equal to the minimum price.");

LessThanProperty / LessThanOrEqualToProperty

RuleFor(x => x.DiscountPrice)
.LessThanProperty(x => x.OriginalPrice)
.WithMessage("The discounted price must be lower than the original price.");

RuleFor(x => x.ActualCost)
.LessThanOrEqualToProperty(x => x.BudgetCap)
.WithMessage("The actual cost cannot exceed the budget cap.");

NotEqualToProperty

RuleFor(x => x.NewPassword)
.NotEqualToProperty(x => x.OldPassword)
.WithMessage("The new password must be different from the current password.");

MultipleOfProperty

RuleFor(x => x.Quantity)
.Positive()
.MultipleOfProperty(x => x.MinLotSize)
.WithMessage("The quantity must be a multiple of the minimum lot size.");

Conditional Required

RequiredIf(Func<T, bool> condition)

The field is required when the condition evaluates to true.

RuleFor(x => x.CompanyName)
.RequiredIf(x => x.IsCompany)
.WithMessage("The company name is required for company accounts.");

RequiredIf<TOther>(Expression otherProperty, TOther expectedValue)

RuleFor(x => x.ShippingAddress)
.RequiredIf(x => x.DeliveryMethod, "delivery")
.WithMessage("A shipping address is required for home delivery.");

RequiredUnless(Func<T, bool> condition)

RuleFor(x => x.VatNumber)
.RequiredUnless(x => x.CustomerType == "individual")
.WithMessage("A VAT number is required for business invoices.");

Next Steps

  • Modifiers — WithMessage, WithErrorCode, When/Unless, OverridePropertyName
  • Validation Result — How to work with ValidationResult and ErrorCodes
  • Advanced Patterns — Validator composition, inheritance, complex cases