Validation

Escopo: C#. Idiomas específicos deste ecossistema.

Validar uma entrada envolve três trabalhos diferentes, e cada um acontece num ponto do caminho:

[Entrada]LimpaValida formato → Aplica regras de negócio → [Filtra saída]Resposta

Primeiro o dado bruto é limpo (espaços sobrando, maiúsculas no e-mail). Depois o formato é conferido: campo obrigatório presente, número dentro da faixa. Só então entram as regras que dependem do domínio, como saber se o produto está disponível, e essas precisam consultar o banco. Quando os três se misturam no mesmo lugar, o validator passa a acessar o banco, e testar uma regra de formato exige subir um banco junto.

Conceitos fundamentais

ConceitoO que é
sanitization (sanitização)Limpeza de entrada antes de validar: Trim, ToLowerInvariant, normalização de Unicode
schema validation (validação de esquema)Checagem de forma e tipo: campo obrigatório, formato, faixa numérica
business rule (regra de negócio)Validação que depende de estado do domínio (ex: e-mail já existe)
FluentValidation (biblioteca de validação para .NET)API fluente para definir regras de schema fora dos atributos do modelo
DataAnnotations (atributos de validação do .NET)[Required], [StringLength] etc. aplicados ao modelo; útil para casos simples
ModelState (estado do modelo no MVC)Coleção que agrega erros de binding e validação na pipeline ASP.NET
input boundary (limite de entrada)Camada externa onde dados crus entram e são sanitizados antes de qualquer regra
output filter (filtro de saída)Etapa final que remove campos sensíveis antes de serializar a resposta

Sanitização de entrada

Limpe antes de validar: Trim nas strings, ToLowerInvariant no e-mail. O motivo aparece no caso concreto: " Admin@Email.com " passa por qualquer validador de formato de e-mail, e depois não encontra ninguém no banco, porque lá o endereço está gravado em minúsculas e sem espaço. O erro aparece longe da causa, num "usuário não encontrado" que não explica nada.

❌ Ruim: dados brutos chegam direto na validação
public async Task<Result<User>> CreateUserAsync(CreateUserRequest request, CancellationToken ct)
{
    var validationResult = await _validator.ValidateAsync(request, ct); // " Admin@Email.com " passa
    if (!validationResult.IsValid)
        return Result<User>.Fail(validationResult.Errors.First().ErrorMessage, "INVALID_INPUT");

    var user = await _repository.CreateAsync(request, ct);

    return Result<User>.Success(user);
}
✅ Bom: sanitize antes de validar
private static CreateUserRequest Sanitize(CreateUserRequest request)
{
    var sanitized = request with
    {
        Name = request.Name.Trim(),
        Email = request.Email.Trim().ToLowerInvariant(),
    };

    return sanitized;
}

public async Task<Result<User>> CreateUserAsync(CreateUserRequest request, CancellationToken ct)
{
    var sanitized = Sanitize(request);

    var validationResult = await _validator.ValidateAsync(sanitized, ct);
    if (!validationResult.IsValid)
        return Result<User>.Fail(validationResult.Errors.First().ErrorMessage, "INVALID_INPUT");

    var user = await _repository.CreateAsync(sanitized, ct);

    return Result<User>.Success(user);
}

Validar o formato com FluentValidation

O AbstractValidator reúne num só lugar as regras de forma: campo preenchido, texto no formato de um Guid, quantidade maior que zero. Sem ele, essas checagens se espalham como uma fila de if no começo de cada handler, e a mesma regra acaba escrita de três jeitos diferentes em três lugares.

❌ Ruim: validação manual espalhada no handler
public async Task<Result<Invoice>> HandleAsync(CreateOrderRequest request, CancellationToken ct)
{
    if (string.IsNullOrWhiteSpace(request.ProductId))
        return Result<Invoice>.Fail("ProductId required", "INVALID");

    if (request.Quantity <= 0)
        return Result<Invoice>.Fail("Quantity must be positive", "INVALID");

    if (string.IsNullOrWhiteSpace(request.CustomerId))
        return Result<Invoice>.Fail("CustomerId required", "INVALID");
}
✅ Bom: AbstractValidator centraliza o contrato, handler recebe dado validado
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderValidator()
    {
        RuleFor(r => r.ProductId).NotEmpty().Must(BeValidGuid).WithMessage("Invalid product ID");
        RuleFor(r => r.Quantity).GreaterThan(0);
        RuleFor(r => r.CustomerId).NotEmpty().Must(BeValidGuid).WithMessage("Invalid customer ID");
    }

    private static bool BeValidGuid(string value) => Guid.TryParse(value, out _);
}

Regras de negócio

"O produto está disponível?" é uma pergunta que só o banco responde. Ela pertence ao handler, depois da validação de formato. Colocar essa consulta dentro do validator, com MustAsync, mistura duas camadas: o validator deixa de ser uma função de formato e passa a depender de repositório, e o teste dele passa a exigir dados fictícios de banco para checar se o campo estava preenchido.

❌ Ruim: I/O dentro do validator mistura camadas
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderValidator(IProductRepository products)
    {
        RuleFor(r => r.ProductId)
            .MustAsync(async (id, ct) =>
            {
                var product = await products.FindByIdAsync(Guid.Parse(id), ct);
                return product?.IsAvailable ?? false; // regra de domínio escondida no validator
            })
            .WithMessage("Product not available");
    }
}
✅ Bom: validator valida shape, regras de negócio no handler após
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderValidator()
    {
        RuleFor(r => r.ProductId).NotEmpty().Must(BeValidGuid);
        RuleFor(r => r.Quantity).GreaterThan(0);
    }

    private static bool BeValidGuid(string value) => Guid.TryParse(value, out _);
}

public async Task<Result<Invoice>> HandleAsync(CreateOrderRequest request, CancellationToken ct)
{
    var validationResult = _validator.Validate(request);
    if (!validationResult.IsValid)
    {
        var error = validationResult.Errors.First();
        return Result<Invoice>.Fail(error.ErrorMessage, "INVALID_INPUT");
    }

    var product = await _products.FindByIdAsync(Guid.Parse(request.ProductId), ct);
    if (product is null)
        return Result<Invoice>.Fail("Product not found", "NOT_FOUND");

    if (!product.IsAvailable)
        return Result<Invoice>.Fail("Product unavailable", "UNAVAILABLE");

    var invoice = await BuildInvoiceAsync(request, product, ct);

    return Result<Invoice>.Success(invoice);
}

Filtrar o que sai na resposta

Devolver a entidade do banco direto na resposta entrega junto tudo o que ela carrega: PasswordHash, SecurityStamp, IsDeleted. Ninguém decidiu publicar esses campos, eles apenas moram na mesma classe. Declare um record de resposta com os campos que a API expõe e monte-o a partir da entidade. O que a API publica passa a ser uma lista escrita, e um campo novo na tabela não vaza sozinho para o cliente.

❌ Ruim: entidade direta vaza campos internos
public async Task<User?> FindUserByIdAsync(Guid id, CancellationToken ct)
{
    var user = await _repository.FindByIdAsync(id, ct);

    return user; // PasswordHash, SecurityStamp, IsDeleted, InternalFlags...
}
✅ Bom: response record como projeção explícita do que sai
public record UserResponse(Guid Id, string Name, string Email, DateTime CreatedAt);

public async Task<UserResponse?> FindUserByIdAsync(Guid id, CancellationToken ct)
{
    var user = await _repository.FindByIdAsync(id, ct);
    if (user is null) return null;

    var userResponse = new UserResponse(user.Id, user.Name, user.Email, user.CreatedAt);
    return userResponse;
}

DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.