.NET FluentValidation ASP.NET Core Validação Qualidade Arquitetura

FluentValidation avançado no .NET: validators complexos, reutilização e integração com APIs

Além do básico: validators hierárquicos, regras condicionais, validação assíncrona com banco, custom validators reutilizáveis.

N
Neryx Digital Architects
9 de novembro de 2025
12 min de leitura
230 profissionais leram
Categoria: Arquitetura Público: Times de engenharia e produto Etapa: Aprendizado

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.

Precisa desenhar a próxima fase com menos retrabalho?

Fazemos discovery técnico para mapear riscos, arquitetura-alvo e sequência de execução antes de investir pesado.

Solicitar Discovery

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.