DataAnnotations resolve casos simples. Mas quando você precisa validar que um e-mail não está cadastrado no banco, que a soma dos itens de um pedido bate com o total, ou que uma data de início é anterior à data de fim — FluentValidation é onde a validação séria acontece no .NET.
Setup e integração com ASP.NET Core
dotnet add package FluentValidation.AspNetCore
// Program.cs
builder.Services.AddFluentValidationAutoValidation() // Validação automática em Controllers
.AddFluentValidationClientsideAdapters(); // Adaptadores para client-side (MVC)
// Registrar todos os validators do assembly automaticamente
builder.Services.AddValidatorsFromAssembly(
Assembly.GetExecutingAssembly(),
includeInternalTypes: true);
// Ou registrar manualmente (mais controle)
builder.Services.AddScoped<IValidator<CreateOrderRequest>, CreateOrderRequestValidator>();
builder.Services.AddScoped<IValidator<OrderItemRequest>, OrderItemRequestValidator>();
Validator básico com regras reutilizáveis
// Validators para tipos comuns — reutilizados em vários validators
public static class CommonRules
{
public static IRuleBuilderOptions<T, string> ValidCpf<T>(
this IRuleBuilder<T, string> ruleBuilder)
{
return ruleBuilder
.NotEmpty().WithMessage("CPF é obrigatório")
.Length(11, 14).WithMessage("CPF deve ter 11 dígitos (com ou sem formatação)")
.Must(BeValidCpf).WithMessage("CPF inválido");
}
public static IRuleBuilderOptions<T, string> ValidPhone<T>(
this IRuleBuilder<T, string> ruleBuilder)
{
return ruleBuilder
.NotEmpty().WithMessage("Telefone é obrigatório")
.Matches(@"^\(?\d{2}\)?\s?\d{4,5}-?\d{4}$")
.WithMessage("Telefone inválido. Use formato: (11) 99999-9999");
}
public static IRuleBuilderOptions<T, string> ValidCep<T>(
this IRuleBuilder<T, string> ruleBuilder)
{
return ruleBuilder
.NotEmpty().WithMessage("CEP é obrigatório")
.Matches(@"^\d{5}-?\d{3}$").WithMessage("CEP inválido. Use formato: 00000-000");
}
private static bool BeValidCpf(string cpf)
{
if (string.IsNullOrWhiteSpace(cpf)) return false;
cpf = new string(cpf.Where(char.IsDigit).ToArray());
if (cpf.Length != 11 || cpf.Distinct().Count() == 1) return false;
int[] multiplicador1 = [10, 9, 8, 7, 6, 5, 4, 3, 2];
int[] multiplicador2 = [11, 10, 9, 8, 7, 6, 5, 4, 3, 2];
var soma = cpf.Take(9).Select((d, i) => (d - '0') * multiplicador1[i]).Sum();
var resto = soma % 11;
var digito1 = resto < 2 ? 0 : 11 - resto;
soma = cpf.Take(10).Select((d, i) => (d - '0') * multiplicador2[i]).Sum();
resto = soma % 11;
var digito2 = resto < 2 ? 0 : 11 - resto;
return cpf[9] - '0' == digito1 && cpf[10] - '0' == digito2;
}
}
Validator com validação assíncrona (banco de dados)
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
private readonly ICustomerRepository _customerRepository;
private readonly IProductRepository _productRepository;
public CreateOrderRequestValidator(
ICustomerRepository customerRepository,
IProductRepository productRepository)
{
_customerRepository = customerRepository;
_productRepository = productRepository;
// Regras síncronas primeiro
RuleFor(x => x.CustomerId)
.NotEmpty().WithMessage("Cliente é obrigatório")
.NotEqual(Guid.Empty).WithMessage("ID do cliente inválido");
RuleFor(x => x.DeliveryDate)
.NotEmpty().WithMessage("Data de entrega é obrigatória")
.GreaterThan(DateTime.Today).WithMessage("Data de entrega deve ser no futuro")
.LessThanOrEqualTo(DateTime.Today.AddDays(90))
.WithMessage("Data de entrega não pode ser mais de 90 dias no futuro");
RuleFor(x => x.Items)
.NotEmpty().WithMessage("Pedido deve ter pelo menos um item")
.Must(items => items.Count <= 100)
.WithMessage("Pedido não pode ter mais de 100 itens")
.Must(HaveUniqueProductIds)
.WithMessage("Pedido não pode ter o mesmo produto duplicado");
// Validação de cada item da coleção
RuleForEach(x => x.Items)
.SetValidator(new OrderItemRequestValidator());
// Regra de negócio: total calculado vs total declarado
RuleFor(x => x)
.Must(HaveConsistentTotal)
.WithMessage("Total declarado não confere com a soma dos itens")
.OverridePropertyName("Total");
// Regras assíncronas (chamam banco de dados)
RuleFor(x => x.CustomerId)
.MustAsync(CustomerExistsAndIsActive)
.WithMessage("Cliente não encontrado ou inativo")
.WithSeverity(Severity.Error);
RuleForEach(x => x.Items)
.MustAsync(async (item, ct) =>
await productRepository.ExistsAndIsActiveAsync(item.ProductId, ct))
.WithMessage((request, item) =>
$"Produto {item.ProductId} não encontrado ou inativo");
}
private static bool HaveUniqueProductIds(List<OrderItemRequest> items)
=> items.Select(i => i.ProductId).Distinct().Count() == items.Count;
private static bool HaveConsistentTotal(CreateOrderRequest request)
{
var calculatedTotal = request.Items.Sum(i => i.Quantity * i.UnitPrice);
return Math.Abs(calculatedTotal - request.TotalAmount) < 0.01m;
}
private async Task<bool> CustomerExistsAndIsActive(
Guid customerId,
CancellationToken cancellationToken)
{
var customer = await _customerRepository.GetByIdAsync(customerId, cancellationToken);
return customer is { IsActive: true };
}
}
public class OrderItemRequestValidator : AbstractValidator<OrderItemRequest>
{
public OrderItemRequestValidator()
{
RuleFor(x => x.ProductId)
.NotEmpty().WithMessage("Produto é obrigatório");
RuleFor(x => x.Quantity)
.GreaterThan(0).WithMessage("Quantidade deve ser maior que zero")
.LessThanOrEqualTo(1000).WithMessage("Quantidade máxima por item é 1000");
RuleFor(x => x.UnitPrice)
.GreaterThan(0).WithMessage("Preço unitário deve ser maior que zero")
.LessThanOrEqualTo(1_000_000m).WithMessage("Preço unitário inválido");
}
}
Validators hierárquicos e composição
// Validator base reutilizável para endereço
public class AddressValidator : AbstractValidator<AddressRequest>
{
public AddressValidator()
{
RuleFor(x => x.Street)
.NotEmpty().WithMessage("Logradouro é obrigatório")
.MaximumLength(200);
RuleFor(x => x.Number)
.NotEmpty().WithMessage("Número é obrigatório")
.MaximumLength(20);
RuleFor(x => x.City)
.NotEmpty().WithMessage("Cidade é obrigatória")
.MaximumLength(100);
RuleFor(x => x.State)
.NotEmpty().WithMessage("Estado é obrigatório")
.Length(2).WithMessage("Use a sigla do estado (ex: SP)")
.Matches("^[A-Z]{2}$").WithMessage("Estado deve ser a sigla em maiúsculas");
RuleFor(x => x.ZipCode).ValidCep(); // Extensão customizada
RuleFor(x => x.Country)
.NotEmpty().WithMessage("País é obrigatório")
.Equal("BR").WithMessage("Apenas endereços brasileiros são aceitos");
}
}
// Reutilizar em múltiplos validators
public class CreateCustomerRequestValidator : AbstractValidator<CreateCustomerRequest>
{
public CreateCustomerRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Nome é obrigatório")
.MinimumLength(3)
.MaximumLength(200);
RuleFor(x => x.Email)
.NotEmpty().WithMessage("E-mail é obrigatório")
.EmailAddress().WithMessage("E-mail inválido");
RuleFor(x => x.Cpf).ValidCpf();
RuleFor(x => x.Phone).ValidPhone();
// Reutiliza AddressValidator como validator do objeto aninhado
RuleFor(x => x.BillingAddress)
.NotNull().WithMessage("Endereço de cobrança é obrigatório")
.SetValidator(new AddressValidator());
// Endereço de entrega é opcional, mas válido se informado
When(x => x.ShippingAddress is not null, () =>
{
RuleFor(x => x.ShippingAddress!)
.SetValidator(new AddressValidator());
});
}
}
Regras condicionais e dependências entre campos
public class UpdatePaymentRequestValidator : AbstractValidator<UpdatePaymentRequest>
{
public UpdatePaymentRequestValidator()
{
RuleFor(x => x.PaymentMethod)
.NotEmpty()
.IsInEnum().WithMessage("Método de pagamento inválido");
// Regras condicionais por PaymentMethod
When(x => x.PaymentMethod == PaymentMethod.CreditCard, () =>
{
RuleFor(x => x.CardNumber)
.NotEmpty().WithMessage("Número do cartão é obrigatório")
.CreditCard().WithMessage("Número do cartão inválido");
RuleFor(x => x.CardHolderName)
.NotEmpty().WithMessage("Nome do titular é obrigatório");
RuleFor(x => x.ExpiryMonth)
.InclusiveBetween(1, 12).WithMessage("Mês de validade inválido");
RuleFor(x => x.ExpiryYear)
.GreaterThanOrEqualTo(DateTime.Today.Year)
.WithMessage("Cartão expirado");
RuleFor(x => x.Cvv)
.NotEmpty()
.Matches(@"^\d{3,4}$").WithMessage("CVV inválido");
// Regra composta: validade no futuro
RuleFor(x => x)
.Must(x => new DateTime(x.ExpiryYear, x.ExpiryMonth, 1) > DateTime.Today)
.WithMessage("Data de validade do cartão está no passado")
.OverridePropertyName("ExpiryDate")
.When(x => x.ExpiryMonth is >= 1 and <= 12);
});
When(x => x.PaymentMethod == PaymentMethod.Pix, () =>
{
RuleFor(x => x.PixKey)
.NotEmpty().WithMessage("Chave Pix é obrigatória")
.Must(BeValidPixKey).WithMessage("Chave Pix inválida");
});
When(x => x.PaymentMethod == PaymentMethod.BankSlip, () =>
{
RuleFor(x => x.DueDays)
.InclusiveBetween(1, 30)
.WithMessage("Vencimento do boleto deve ser entre 1 e 30 dias");
});
}
private static bool BeValidPixKey(string key)
{
if (string.IsNullOrWhiteSpace(key)) return false;
// CPF/CNPJ, e-mail, telefone ou chave aleatória (UUID)
return Regex.IsMatch(key, @"^\d{11}$") || // CPF
Regex.IsMatch(key, @"^\d{14}$") || // CNPJ
Regex.IsMatch(key, @"^[^@]+@[^@]+$") || // E-mail
Regex.IsMatch(key, @"^\+55\d{11}$") || // Telefone
Guid.TryParse(key, out _); // Chave aleatória
}
}
Integração com Minimal APIs via endpoint filter
// Filtro genérico de validação para Minimal APIs
public class ValidationFilter<TRequest> : IEndpointFilter
where TRequest : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var validator = context.HttpContext.RequestServices
.GetService<IValidator<TRequest>>();
if (validator is null) return await next(context);
var request = context.Arguments.OfType<TRequest>().FirstOrDefault();
if (request is null) return await next(context);
var result = await validator.ValidateAsync(request);
if (!result.IsValid)
{
return Results.ValidationProblem(
result.ToDictionary(),
title: "Dados de entrada inválidos",
detail: "Corrija os erros indicados e tente novamente.");
}
return await next(context);
}
}
// Extensão para uso limpo
public static class EndpointFiltersExtensions
{
public static RouteHandlerBuilder WithValidation<TRequest>(
this RouteHandlerBuilder builder) where TRequest : class
=> builder.AddEndpointFilter<ValidationFilter<TRequest>>();
}
// Uso nos endpoints
app.MapPost("/api/orders", async (CreateOrderRequest request, IOrderService service) =>
{
var id = await service.CreateAsync(request);
return Results.CreatedAtRoute("GetOrder", new { id }, new { id });
})
.WithValidation<CreateOrderRequest>()
.WithTags("Orders");
Localização de mensagens de erro
// Configurar linguagem padrão das mensagens do FluentValidation
ValidatorOptions.Global.LanguageManager.Culture = new CultureInfo("pt-BR");
// Sobrescrever mensagens padrão globalmente
ValidatorOptions.Global.LanguageManager.AddTranslation("pt-BR", nameof(NotNullValidator),
"'{PropertyName}' é obrigatório.");
ValidatorOptions.Global.LanguageManager.AddTranslation("pt-BR", nameof(NotEmptyValidator),
"'{PropertyName}' não pode ser vazio.");
ValidatorOptions.Global.LanguageManager.AddTranslation("pt-BR", nameof(EmailValidator),
"'{PropertyName}' não é um endereço de e-mail válido.");
ValidatorOptions.Global.LanguageManager.AddTranslation("pt-BR", nameof(MaximumLengthValidator),
"'{PropertyName}' deve ter no máximo {MaxLength} caracteres. Você inseriu {TotalLength}.");
Testando validators
public class CreateOrderRequestValidatorTests
{
private readonly CreateOrderRequestValidator _validator;
private readonly Mock<ICustomerRepository> _customerRepo;
private readonly Mock<IProductRepository> _productRepo;
public CreateOrderRequestValidatorTests()
{
_customerRepo = new Mock<ICustomerRepository>();
_productRepo = new Mock<IProductRepository>();
_customerRepo
.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), default))
.ReturnsAsync(new Customer { IsActive = true });
_productRepo
.Setup(r => r.ExistsAndIsActiveAsync(It.IsAny<Guid>(), default))
.ReturnsAsync(true);
_validator = new CreateOrderRequestValidator(_customerRepo.Object, _productRepo.Object);
}
[Fact]
public async Task Validate_ValidRequest_PassesValidation()
{
var request = new CreateOrderRequest
{
CustomerId = Guid.NewGuid(),
DeliveryDate = DateTime.Today.AddDays(5),
TotalAmount = 199.80m,
Items =
[
new OrderItemRequest
{
ProductId = Guid.NewGuid(),
Quantity = 2,
UnitPrice = 99.90m
}
]
};
var result = await _validator.ValidateAsync(request);
result.IsValid.Should().BeTrue();
}
[Fact]
public async Task Validate_PastDeliveryDate_FailsWithExpectedMessage()
{
var request = BuildValidRequest() with
{
DeliveryDate = DateTime.Today.AddDays(-1)
};
var result = await _validator.ValidateAsync(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().ContainSingle(e =>
e.PropertyName == "DeliveryDate" &&
e.ErrorMessage.Contains("futuro"));
}
[Fact]
public async Task Validate_DuplicateProducts_FailsValidation()
{
var productId = Guid.NewGuid();
var request = BuildValidRequest() with
{
Items =
[
new OrderItemRequest { ProductId = productId, Quantity = 1, UnitPrice = 10m },
new OrderItemRequest { ProductId = productId, Quantity = 2, UnitPrice = 10m }
],
TotalAmount = 30m
};
var result = await _validator.ValidateAsync(request);
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.ErrorMessage.Contains("duplicado"));
}
}
Validação robusta é a primeira camada de proteção da sua lógica de negócio. Se você quer uma arquitetura .NET com validação confiável e testável, fale com a Neryx.