Saltar al contenido principal

Reglas avanzadas

Este documento cubre las reglas que van más allá de las comprobaciones estáticas: predicados personalizados, lógica asíncrona, dependencias entre propiedades, transformaciones y validadores anidados.


Must​

Must permite definir cualquier predicado síncrono.

RuleFor(x => x.BirthDate)
.Must(date => date.Year >= 1900)
.WithMessage("La fecha de nacimiento no puede ser anterior a 1900.")
.Must(date => DateTime.Today.Year - date.Year >= 18)
.WithMessage("Debes tener al menos 18 años.");

Must también puede acceder al objeto raíz completo a través de una sobrecarga con dos parámetros:

RuleFor(x => x.EndDate)
.Must((request, endDate) => endDate > request.StartDate)
.WithMessage("La fecha de fin debe ser posterior a la fecha de inicio.");

MustAsync​

MustAsync es la versión asíncrona de Must. Úsalo cuando la validación requiera I/O.

Sin CancellationToken​

RuleFor(x => x.Email)
.MustAsync(async email =>
{
var exists = await _userRepository.ExistsByEmailAsync(email);
return !exists;
})
.WithMessage("Ya existe un usuario registrado con ese email.");

Con CancellationToken​

RuleFor(x => x.Email)
.MustAsync(async (email, ct) =>
{
var exists = await _userRepository.ExistsByEmailAsync(email, ct);
return !exists;
})
.WithMessage("Ya existe un usuario registrado con ese email.");

Con acceso al objeto raíz​

RuleFor(x => x.ProductId)
.MustAsync(async (request, productId, ct) =>
{
var product = await _productRepository.GetByIdAsync(productId, ct);
return product?.CategoryId == request.CategoryId;
})
.WithMessage("El producto no pertenece a la categoría especificada.");

Rendimiento: Si tienes múltiples MustAsync independientes, considera usar ValidateParallelAsync. Ver Validadores.


DependentRuleAsync​

DependentRuleAsync define una regla asíncrona que depende de dos propiedades del mismo objeto.

DependentRuleAsync(
x => x.ProductId,
x => x.WarehouseId,
async (productId, warehouseId) =>
{
return await _inventory.IsProductAvailableInWarehouseAsync(productId, warehouseId);
}
)
.WithMessage("El producto no está disponible en el almacén especificado.");

Custom​

Custom te da control total sobre la validación. Recibes el valor de la propiedad y un CustomContext<T> que te permite añadir errores de forma granular.

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

if (sourceAccount == null)
{
ctx.AddFailure("SourceAccountId", "La cuenta de origen no existe.", "ACCOUNT_NOT_FOUND");
return;
}

if (sourceAccount.Balance < request.Amount)
{
ctx.AddFailure("Amount", $"Saldo insuficiente. Disponible: {sourceAccount.Balance:C}.", "INSUFFICIENT_FUNDS");
}
});

SwitchOn — Reglas condicionales por valor en una propiedad​

SwitchOn te permite aplicar diferentes reglas a la misma propiedad dependiendo del valor de otra propiedad.

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("El pasaporte debe tener el formato AA999999."))
.Case("dni", b => b.NotEmpty().IsNumeric().MinimumLength(8).MaximumLength(8)
.WithMessage("El DNI debe tener exactamente 8 dígitos."))
.Case("ruc", b => b.NotEmpty().IsNumeric().MinimumLength(11).MaximumLength(11)
.WithMessage("El RUC debe tener exactamente 11 dígitos."))
.Default( b => b.NotEmpty());
}
}

Diferencia con When/Unless​

SwitchOnWhen / Unless
ExclusividadSolo un caso se ejecutaCada bloque When se evalúa independientemente
Múltiples variantesUn solo constructo cubre todas las variantesRequiere un RuleFor(...).When(...) por variante
SolapamientoImposible por diseñoPosible si las condiciones no son mutuamente excluyentes

Transform​

Transform convierte el valor de la propiedad antes de aplicar las reglas.

// Normalizar antes de validar
RuleFor(x => x.SearchTerm)
.Transform(term => term?.Trim().ToLowerInvariant())
.MinimumLength(2)
.MaximumLength(100)
.When(x => x.SearchTerm != null);
// Validar solo la extensión del archivo
RuleFor(x => x.FileName)
.Transform(name => Path.GetExtension(name).ToLowerInvariant())
.In(new[] { ".pdf", ".docx", ".xlsx", ".png", ".jpg" })
.WithMessage("El formato de archivo no está permitido.");

SetValidator​

SetValidator delega la validación de una propiedad compleja a otro validador. Los errores aparecen con el nombre de la propiedad como prefijo.

public class CreateShipmentValidator : AbstractValidator<CreateShipmentRequest>
{
public CreateShipmentValidator()
{
RuleFor(x => x.ShippingAddress)
.NotNull()
.WithMessage("La dirección de envío es obligatoria.")
.SetValidator(new AddressValidator());

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

Si ShippingAddress.Street está vacío, el resultado contendrá:

{
"ShippingAddress.Street": ["El campo Street no puede estar vacío."]
}

Reglas cross-property​

GreaterThanProperty / GreaterThanOrEqualToProperty​

RuleFor(x => x.EndDate)
.GreaterThanProperty(x => x.StartDate)
.WithMessage("La fecha de salida debe ser posterior a la fecha de entrada.");

LessThanProperty / LessThanOrEqualToProperty​

RuleFor(x => x.DiscountPrice)
.LessThanProperty(x => x.OriginalPrice)
.WithMessage("El precio con descuento debe ser menor que el precio original.");

NotEqualToProperty​

RuleFor(x => x.NewPassword)
.NotEqualToProperty(x => x.OldPassword)
.WithMessage("La nueva contraseña debe ser diferente de la actual.");

Requerido condicional​

RequiredIf(Func<T, bool> condition)​

RuleFor(x => x.CompanyName)
.RequiredIf(x => x.IsCompany)
.WithMessage("El nombre de la empresa es obligatorio para cuentas empresariales.");

RequiredUnless(Func<T, bool> condition)​

RuleFor(x => x.VatNumber)
.RequiredUnless(x => x.CustomerType == "individual")
.WithMessage("El CIF/NIF es obligatorio para facturas a empresas.");

Siguientes pasos​