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