MÓDULO 05 · CONCEITO 13 DE 14

Validação cross-cutting

FluentValidation, Pydantic v2, validator tags em Go. Forma na borda, invariantes no domínio — duas validações distintas que protegem coisas distintas, e por que confundir as duas é o atalho mais comum para domínio anêmico.

Tempo de leitura ~22 min Pré-requisito Conceitos 04 e 05 (middleware, interceptors) Próximo Trade-offs e anti-padrões de AOP

Validação parece tópico simples até a equipe começar a discutir onde ela deve viver. Algumas pessoas a colocam no controller — if request.email is None: raise BadRequest() espalhado nos handlers. Outras colocam no domínio — uma classe Email que recusa string mal-formada no construtor. Outras dependem só do framework — Pydantic decora o input, FluentValidation valida o command, e isso é "validação". Cada uma dessas escolhas tem mérito em um cenário e custa caro em outro. A confusão é estrutural, não acidental: validação é cross-cutting na borda e parte do domínio mais para dentro, e ignorar essa dualidade é o caminho mais curto para sistemas frágeis.

Eric Evans, em Domain-Driven Design (2003), articulou a distinção implicitamente quando diferenciou value objects de entities e propôs que invariantes de domínio fossem garantidos pelo modelo, não por código externo. A formulação mais explícita veio depois, com Vaughn Vernon e a comunidade DDD: validação é dois trabalhos. Um é proteger o domínio de input mal-formado antes que ele chegue lá ("forma" — campos obrigatórios, tipos, formatos); o outro é garantir que o domínio jamais entre em estado inválido ("invariante" — saldo não-negativo, datas coerentes, transição de estado válida). Os dois trabalhos precisam de aspects diferentes.

Este conceito articula a separação, mostra os mecanismos idiomáticos em três ecossistemas, e enuncia a heurística central: validar duas vezes é maturidade, não desperdício. Validar só na borda deixa caminhos não-HTTP (jobs, mensagens, testes) vulneráveis; validar só no domínio deixa a API retornando 500 para input absurdo.

Há também a tensão pragmática que aparece em código real — duplicação aparente entre validação de borda (FluentValidation, Pydantic) e validação de domínio (construtor, factory). Não é duplicação verdadeira: a borda valida forma e dá erro amigável; o domínio valida invariante e dá erro estrutural. Sêniores que enxergam isso entregam sistemas que envelhecem sem virar pântano.

Os dois trabalhos da validação

Validação de forma — borda

Forma é o que define se o input está bem formado para ser interpretado pelo sistema. Tem campo obrigatório? Os tipos batem? E-mail tem formato de e-mail? Data está em ISO 8601? Valor é numérico? Item da lista existe na enum? Esse tipo de validação acontece antes de qualquer regra de negócio. Se o input falha aqui, o sistema responde com 400 Bad Request e mensagens amigáveis para o cliente — o pedido nem chegou ao domínio.

O mecanismo idiomático varia por linguagem. Em .NET, FluentValidation. Em Python, Pydantic. Em Go, struct tags com go-playground/validator ou parsing customizado. O ponto é que a validação é declarativa, atrelada ao tipo do input, e roda automaticamente em pipeline antes do handler.

Invariantes — domínio

Invariante é a regra que o domínio jamais pode violar, independente de quem chamou ou como chamou. Saldo de conta não pode ficar negativo. Pedido não pode ter data de finalização anterior à data de criação. Item de pedido tem quantidade positiva. Estado de pedido só transita criado → pago → enviado → entregue, nunca pula etapas, nunca volta. Esses invariantes vivem no construtor e nos métodos do agregado, não em validators externos.

A diferença prática é que invariante é responsabilidade do modelo, não da camada externa. Mesmo que a borda passe input "válido", o construtor da entidade pode recusar se o estado resultante violaria invariante. Mesmo se o handler chamar o domínio com argumentos verificados, o domínio mantém suas regras. Domínio é defensivo por construção; é por isso que DDD insiste em métodos de fábrica e construtores que validam.

Por que validar duas vezes não é redundância

A objeção recorrente em revisão é "se a borda já valida, por que o domínio precisa validar de novo?". Quatro razões:

Caminhos não-HTTP existem. Jobs em background, consumidores de fila, scripts de migração, testes — todos criam objetos de domínio sem passar pela validação HTTP. Se a proteção só existe na borda, esses caminhos podem produzir estado inválido. O domínio precisa se proteger independentemente.

Validação de forma não cobre invariante. "Quantidade é número positivo" é validação de forma. "Soma das quantidades não pode passar do estoque disponível" é invariante — depende de estado, não cabe em DSL declarativo. A borda não consegue fazer essa verificação razoavelmente; é trabalho do domínio.

Borda evolui mais rápido. Endpoints mudam, novos endpoints aparecem, validações são esquecidas em rotas novas. Domínio é mais estável; protegido lá, fica protegido independente do esquecimento na borda.

Mensagens de erro distintas. Borda devolve mensagens orientadas ao cliente ("o campo email é obrigatório"); domínio devolve erros estruturais (InvariantViolation: cannot adicionar item if order is finalized). O cliente HTTP nunca deveria ver InvariantViolation direto — é sinal de que a borda devia ter pego antes.

Quando a equipe articula essa divisão, validação para de ser tópico de fricção em revisão e vira disciplina. Toda PR que adiciona endpoint novo ganha validator de borda. Toda PR que adiciona método de domínio ganha validação de invariante. As duas convivem.

FluentValidation em .NET

FluentValidation, criado por Jeremy Skinner em 2008, é a biblioteca canônica de validação de forma em .NET. A interface é encadeada e legível, e integra-se a ASP.NET Core via pipeline.

// validator separado do command (Single Responsibility)
public class CriarPedidoCmdValidator : AbstractValidator<CriarPedidoCmd>
{
    public CriarPedidoCmdValidator()
    {
        RuleFor(x => x.ClienteId).NotEmpty();
        RuleFor(x => x.Itens).NotEmpty().WithMessage("o pedido precisa de pelo menos um item");
        RuleForEach(x => x.Itens).ChildRules(item =>
        {
            item.RuleFor(i => i.SkuId).NotEmpty();
            item.RuleFor(i => i.Quantidade).GreaterThan(0);
            item.RuleFor(i => i.Quantidade).LessThanOrEqualTo(999)
                .WithMessage("quantidade máxima por item é 999");
        });
        RuleFor(x => x.EnderecoEntrega).SetValidator(new EnderecoValidator());
    }
}

// integração via MediatR pipeline behavior
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;
    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
        => _validators = validators;

    public async Task<TResponse> Handle(TRequest req, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        if (!_validators.Any()) return await next();

        var ctx = new ValidationContext<TRequest>(req);
        var failures = (await Task.WhenAll(_validators.Select(v => v.ValidateAsync(ctx, ct))))
            .SelectMany(r => r.Errors).Where(e => e is not null).ToList();

        if (failures.Count != 0) throw new ValidationException(failures);
        return await next();
    }
}

O behavior aplica o validator antes do handler, e ValidationException é convertida em 400 Bad Request via middleware de erro previamente registrado. O handler nunca recebe input mal-formado.

Pydantic em Python

Pydantic, criado por Samuel Colvin em 2017, virou base de FastAPI e padrão de fato em Python para validação de input em fronteira. A versão 2 (lançada em julho de 2023) trouxe core em Rust, performance ordens de magnitude melhor, e API refinada.

from pydantic import BaseModel, Field, EmailStr, field_validator
from datetime import date

class ItemPedidoIn(BaseModel):
    sku_id: str = Field(min_length=1)
    quantidade: int = Field(gt=0, le=999, description="qtd entre 1 e 999")

class EnderecoIn(BaseModel):
    cep: str = Field(pattern=r"^\d{5}-?\d{3}$")
    rua: str = Field(min_length=1, max_length=120)
    numero: str = Field(min_length=1, max_length=10)
    cidade: str = Field(min_length=1, max_length=80)

class CriarPedidoIn(BaseModel):
    cliente_email: EmailStr
    itens: list[ItemPedidoIn] = Field(min_length=1)
    endereco_entrega: EnderecoIn
    data_entrega: date

    @field_validator("data_entrega")
    @classmethod
    def deve_ser_futuro(cls, v: date) -> date:
        if v <= date.today():
            raise ValueError("data de entrega deve ser futura")
        return v

# integração FastAPI: o framework valida automaticamente
@app.post("/pedidos")
async def criar_pedido(cmd: CriarPedidoIn) -> PedidoOut:
    # cmd já chega validado; se input inválido, FastAPI já devolveu 422
    pedido = await service.criar(cmd.to_command())
    return PedidoOut.from_domain(pedido)

Note três detalhes. Primeiro: tipos viram contratos — EmailStr, int com gt/le, date. Pydantic coerce e valida em uma operação. Segundo: field_validator permite validações que dependem do valor (não só de forma); estão na borda da fronteira — regras de "data deve ser futura" são forma genérica, não invariante de domínio. Terceiro: FastAPI retorna 422 Unprocessable Entity automaticamente com detalhes campo a campo.

Validator tags em Go

Em Go, validação de input é tipicamente feita via tags em structs com github.com/go-playground/validator/v10 — biblioteca canônica de Daron Greenwell e contribuidores.

type ItemPedidoIn struct {
    SkuID      string `json:"sku_id" validate:"required"`
    Quantidade int    `json:"quantidade" validate:"required,gt=0,lte=999"`
}

type EnderecoIn struct {
    CEP    string `json:"cep" validate:"required,len=9"`        // ou regex
    Rua    string `json:"rua" validate:"required,max=120"`
    Numero string `json:"numero" validate:"required,max=10"`
    Cidade string `json:"cidade" validate:"required,max=80"`
}

type CriarPedidoIn struct {
    ClienteEmail    string         `json:"cliente_email" validate:"required,email"`
    Itens           []ItemPedidoIn `json:"itens" validate:"required,min=1,dive"`
    EnderecoEntrega EnderecoIn     `json:"endereco_entrega" validate:"required"`
    DataEntrega     time.Time      `json:"data_entrega" validate:"required,gtfield=now"`
}

// helper de decode + validate
var validate = validator.New()

func decodeAndValidate(r *http.Request, v any) error {
    if err := json.NewDecoder(r.Body).Decode(v); err != nil {
        return ErrInvalidJSON
    }
    return validate.Struct(v)
}

// uso no handler
func (h *Handlers) CriarPedido(w http.ResponseWriter, r *http.Request) {
    var cmd CriarPedidoIn
    if err := decodeAndValidate(r, &cmd); err != nil {
        respondError(w, 400, err)
        return
    }
    pedido, err := h.uc.Criar(r.Context(), cmd.ToCommand())
    if err != nil { respondError(w, 422, err); return }
    respondJSON(w, 201, pedido)
}

Em Go, a validação de borda é mais explícita — não há pipeline behavior automático. Cada handler chama decodeAndValidate ou padrão equivalente. A verbosidade é cultural; a comunidade prefere assim.

Invariantes no domínio — a outra metade

Invariantes vivem em construtores, factories e métodos de mutação do agregado. A linguagem importa pouco — o padrão é o mesmo:

// .NET — invariante no construtor
public sealed class Pedido
{
    public Guid Id { get; }
    public Guid ClienteId { get; }
    public IReadOnlyList<ItemPedido> Itens => _itens.AsReadOnly();
    public StatusPedido Status { get; private set; }

    private readonly List<ItemPedido> _itens = new();

    public Pedido(Guid clienteId, IEnumerable<ItemPedido> itens)
    {
        if (clienteId == Guid.Empty)
            throw new DomainException("clienteId obrigatório");

        var itensLista = itens?.ToList() ?? throw new ArgumentNullException();
        if (itensLista.Count == 0)
            throw new DomainException("pedido precisa de pelo menos um item");

        Id = Guid.NewGuid();
        ClienteId = clienteId;
        _itens.AddRange(itensLista);
        Status = StatusPedido.Criado;
    }

    public void AdicionarItem(ItemPedido item)
    {
        if (Status != StatusPedido.Criado)
            throw new DomainException("pedido finalizado não aceita item novo");
        _itens.Add(item);
    }

    public void TransitarPara(StatusPedido proximo)
    {
        if (!ValidTransitions(Status).Contains(proximo))
            throw new DomainException($"transição {Status}→{proximo} inválida");
        Status = proximo;
    }
}

Repare que essas verificações não duplicam o validator de borda. itensLista.Count == 0 aqui pega o caso de o domínio ser instanciado em outro caminho (job, teste, evento de fila); não é a mesma proteção do RuleFor(x => x.Itens).NotEmpty() de FluentValidation, que é mensagem amigável para o cliente HTTP. Os dois protegem coisas diferentes.

Mapeamento de erro — tipo vira status HTTP

A separação entre validação de borda e invariante de domínio sugere mapeamento de erro distinto. Erros de validação de borda viram 400 Bad Request (ou 422 Unprocessable Entity em FastAPI/REST mais formal) com lista de erros campo a campo. Erros de invariante de domínio viram 400 também, mas com mensagem diferente — geralmente um único erro estrutural ("transição inválida"). Erros de "recurso não encontrado" viram 404; erros de autorização viram 403. O middleware de erro centraliza esse mapeamento (visto no conceito 04).

Há tendência moderna de usar ProblemDetails (RFC 7807, 2016) como formato padronizado de resposta de erro. ASP.NET Core tem Results.Problem(...); FastAPI permite com formato custom; em Go é manual mas trivial. Adoção uniforme do formato facilita clientes (especialmente OpenAPI consumers).

O mesmo cenário, três stacks

C# — FluentValidation + invariantes no domínio
// borda
public class CriarPedidoCmdValidator : AbstractValidator<CriarPedidoCmd>
{
    public CriarPedidoCmdValidator()
    {
        RuleFor(x => x.ClienteId).NotEmpty();
        RuleFor(x => x.Itens).NotEmpty();
        RuleFor(x => x.DataEntrega).GreaterThan(DateOnly.FromDateTime(DateTime.Today));
    }
}

// domínio
public sealed class Pedido
{
    public Pedido(Guid clienteId, IEnumerable<ItemPedido> itens, DateOnly dataEntrega)
    {
        if (clienteId == Guid.Empty) throw new DomainException("clienteId");
        var itensList = itens.ToList();
        if (itensList.Count == 0) throw new DomainException("itens vazio");
        if (dataEntrega <= DateOnly.FromDateTime(DateTime.Today))
            throw new DomainException("data de entrega passada");
        // ...
    }
}

As mesmas regras aparecem em dois lugares — proposital. Borda dá erro amigável; domínio garante que estado inválido nunca existe.

Python — Pydantic + dataclass de domínio
# borda
class CriarPedidoIn(BaseModel):
    cliente_id: UUID
    itens: list[ItemPedidoIn] = Field(min_length=1)
    data_entrega: date

    @field_validator("data_entrega")
    @classmethod
    def deve_ser_futura(cls, v):
        if v <= date.today():
            raise ValueError("data passada")
        return v

# domínio
@dataclass(frozen=True)
class Pedido:
    id: UUID
    cliente_id: UUID
    itens: tuple[ItemPedido, ...]
    data_entrega: date

    def __post_init__(self):
        if not self.cliente_id: raise DomainError("cliente_id")
        if not self.itens: raise DomainError("itens vazio")
        if self.data_entrega <= date.today():
            raise DomainError("data de entrega passada")

frozen=True torna o dataclass imutável — invariantes podem ser verificados no __post_init__ e ficam garantidos.

Go — validator tags + factory de domínio
// borda (input externo)
type CriarPedidoIn struct {
    ClienteID   uuid.UUID `validate:"required"`
    Itens       []ItemPedidoIn `validate:"required,min=1,dive"`
    DataEntrega time.Time `validate:"required,gtfield=now"`
}

// domínio
type Pedido struct {
    id          uuid.UUID
    clienteID   uuid.UUID
    itens       []ItemPedido
    dataEntrega time.Time
}

// factory que enforce invariantes
func NewPedido(clienteID uuid.UUID, itens []ItemPedido, dataEntrega time.Time) (*Pedido, error) {
    if clienteID == uuid.Nil {
        return nil, errors.New("clienteID obrigatório")
    }
    if len(itens) == 0 {
        return nil, errors.New("itens vazio")
    }
    if !dataEntrega.After(time.Now()) {
        return nil, errors.New("data de entrega passada")
    }
    return &Pedido{
        id:          uuid.New(),
        clienteID:   clienteID,
        itens:       slices.Clone(itens),
        dataEntrega: dataEntrega,
    }, nil
}

Sem construtor "default" público — campos privados, factory público que valida. Se a factory falhou, o objeto não existe; estado inválido é impossível por construção.

Validação cross-aggregate — o terceiro caso

Há um caso que não cabe nem na borda nem no construtor de um agregado: validações que dependem de vários agregados ou de consulta ao banco. "Email único" é o exemplo canônico — o agregado Usuario sozinho não pode garantir unicidade; precisa consultar o repositório de usuários. Essas verificações ficam no domain service ou no application service, antes de instanciar o agregado.

// Python — application service com validação cross-aggregate
async def criar_usuario(cmd: CriarUsuarioCmd, repo: UsuarioRepo) -> Usuario:
    # validação cross-aggregate primeiro
    existente = await repo.buscar_por_email(cmd.email)
    if existente is not None:
        raise EmailJaCadastrado(cmd.email)

    # invariantes do agregado depois (no construtor)
    usuario = Usuario(
        email=cmd.email,
        nome=cmd.nome,
        senha_hash=hash_senha(cmd.senha),
    )
    await repo.salvar(usuario)
    return usuario

A condição "email único" não cabe no Usuario.__init__ — o construtor não tem acesso ao repositório, e mesmo se tivesse, é responsabilidade ambígua. Cabe no application service que orquestra. Esse é o terceiro tipo de validação, que mora ainda mais para fora que o domínio puro mas dentro do limite da operação de aplicação.

Anti-padrões frequentes

Validação só na borda → modelo anêmico. A tendência preguiçosa é "Pydantic já valida, deixa o domínio sem regra". O modelo vira saco de getters/setters; invariantes ficam só no validator de input. Em projetos longos, isso degrada — outros caminhos criam objetos inválidos, e diagnóstico vira caça ao tesouro.

Validação só no domínio → API ruim. Sistema sem Pydantic/FluentValidation devolve 500 Internal Server Error (porque o construtor lançou exceção) para input absurdo. Cliente HTTP fica sem mensagem útil. UX ruim, observabilidade contaminada.

Mistura entre borda e domínio. Validator de borda chamando o repositório para "verificar se email já existe" é mistura — borda devia ser puro check de forma. A verificação cross-aggregate vai no application service.

Mensagens vazadas. InvariantViolation: cliente_id is empty retornando como 400 Bad Request sem tradução é vazamento de implementação. Erros de domínio precisam ser mapeados em mensagens orientadas ao cliente. Se a borda já tinha pego, este erro nem aparece — se aparece, é sintoma de borda fraca.

Validators duplicados — mesma regra escrita duas vezes. Se "data de entrega tem que ser futura" aparece em FluentValidation e em Pedido.NewPedido, há duplicação literal. É aceitável quando borda dá mensagem amigável e domínio dá defesa estrutural — mas é problema quando uma diverge da outra (borda exige >= hoje, domínio exige > hoje). Higiene: lint/teste que verifica consistência, ou centralizar a regra em um Spec compartilhado.

armadilha em produção

Validação que faz I/O dentro do validator de borda. Alguém adiciona "verificar se cliente existe no banco" ao FluentValidator/Pydantic. Funciona em desenvolvimento. Em produção, cada request abre conexão extra (validator roda fora do escopo de transação do handler), e em pico de tráfego o pool de conexão estoura. Sintoma: timeouts aleatórios em request banal. Regra: validators de borda são puros — só checam o input. Verificações que dependem de estado vão para application service.

heurística do sênior

Ao adicionar regra de validação, faça três perguntas. "Essa regra depende só do input ou também de estado externo?". Se só do input, vai para borda. Se de estado, vai para application service ou domain service. "Se essa regra falhar, o cliente HTTP precisa de mensagem específica?". Se sim, validator de borda precisa pegar primeiro com mensagem amigável. "Pode ser violada por caminho não-HTTP?". Se sim, invariante no agregado é defesa final. Em casos onde as três respostas pedem três lugares, escreva nos três — e compartilhe a regra como spec/constante para evitar drift.

Por que importa para a sua carreira

Validação revela maturidade arquitetural. Sêniores que articulam a separação entregam APIs que falham bem (mensagens úteis no 400) e sistemas que envelhecem com domínio íntegro. Em entrevista de design, "como você organizaria validação?" é convite para mostrar a divisão de responsabilidades em três camadas (borda, domínio, application service) e justificar com casos. Em revisão de código, perceber que validator está fazendo I/O ou que o domínio está sem invariante é trabalho de senior. Em diagnóstico de produção, saber em qual camada o erro aconteceu — e por que não foi pego antes — é parte de pos-mortem útil.

Como praticar

  1. Validação em três camadas em três linguagens. Implemente em ASP.NET Core (FluentValidation behavior), FastAPI (Pydantic + service), e Go (validator + factory de domínio) o mesmo cenário: criar usuário com email único, idade mínima, e formato válido. Verifique que: (1) input mal-formado dá 400 com mensagem clara; (2) email duplicado dá 409 Conflict com motivo; (3) tentar criar objeto de domínio com argumento inválido em código (ignorando borda) dispara exceção/erro estrutural. Esse exercício consolida a divisão.
  2. Caminho não-HTTP. No mesmo sistema, adicione um job que cria usuários a partir de uma fila. Verifique que se a fila enviar mensagem inválida, o domínio recusa — sem precisar de Pydantic/FluentValidation. Documente como o job lida com mensagens inválidas (dead-letter queue? retry?) — é decisão arquitetural que o time precisa articular.
  3. Auditoria de modelo anêmico. Pegue um projeto seu e identifique entidades que são puro getter/setter sem invariantes. Para cada uma, liste pelo menos uma invariante que deveria existir e proponha refatoração para construtor/factory + métodos que garantem. Mostre antes/depois e discuta como isso muda os testes — invariante no domínio facilita teste, não complica.

Referências para aprofundar

  1. livro Domain-Driven Design — Eric Evans (Addison-Wesley, 2003). Cap. 5 e 6 cobrem value objects, entities, e a centralidade de invariantes no modelo. Texto fundador da disciplina.
  2. livro Implementing Domain-Driven Design — Vaughn Vernon (Addison-Wesley, 2013). Vernon articula em mais detalhe que Evans a relação entre validação e invariantes. Cap. 5 e 9 são particularmente úteis.
  3. livro Code That Fits in Your Head — Mark Seemann (Addison-Wesley, 2021). Cap. 5 e 7 tratam da divisão entre borda e domínio com clareza ímpar. Seemann tem um post canônico sobre validation chamado "Validation is a Functor".
  4. livro Patterns, Principles, and Practices of DDD — Scott Millett, Nick Tune (Wrox, 2015). Cap. 11 (Validation) é o tratamento mais didático em livro técnico de validation em camadas.
  5. artigo Validation is a Functor — Mark Seemann (blog, 2018). blog.ploeh.dk — Argumenta que validation é estrutura algébrica que compõe — ideia que vira aplicabilidade prática em FluentValidation e em monadas de Either em Haskell/F#.
  6. artigo Always-Valid vs Validation Domain Models — Greg Young (post antigo, ainda canônico). Texto clássico que articula "modelo sempre válido" como diretriz. Disponível em diversas reposições — vale procurar.
  7. docs FluentValidation Documentation. docs.fluentvalidation.net — Documentação canônica. As páginas "Built-in Validators" e "Cross-property Validators" cobrem os padrões necessários.
  8. docs Pydantic Documentation. docs.pydantic.dev — Documentação completa. "Concepts → Validators" e "Migration Guide v1→v2" são essenciais para sêniores em projetos atuais.
  9. docs go-playground/validator. github.com/go-playground/validator — README cobre quase todos os casos. Para regras complexas, ver os exemplos no diretório _examples.
  10. docs RFC 7807 — Problem Details for HTTP APIs. datatracker.ietf.org/doc/html/rfc7807 — Padrão de formato de erro para APIs. Curto, prático, recomendado.
  11. artigo Parse, Don't Validate — Alexis King (blog, 2019). lexi-lambda.github.io — Argumento provocador (parse fortemente tipado em vez de validar) que influenciou Pydantic v2 e tipologia em geral. Leitura essencial para sêniores que pensam em validação.
  12. vídeo Validation in DDD — Vladimir Khorikov (Pluralsight + YouTube, 2019+). YouTube. Khorikov articula em vídeo a separação que sêniores precisam dominar.