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
// 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.
# 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.
// 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.
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.
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
-
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á
400com mensagem clara; (2) email duplicado dá409 Conflictcom 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. - 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.
- 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
- livro Domain-Driven Design — Eric Evans (Addison-Wesley, 2003).
- livro Implementing Domain-Driven Design — Vaughn Vernon (Addison-Wesley, 2013).
- livro Code That Fits in Your Head — Mark Seemann (Addison-Wesley, 2021).
- livro Patterns, Principles, and Practices of DDD — Scott Millett, Nick Tune (Wrox, 2015).
- artigo Validation is a Functor — Mark Seemann (blog, 2018).
- artigo Always-Valid vs Validation Domain Models — Greg Young (post antigo, ainda canônico).
- docs FluentValidation Documentation.
- docs Pydantic Documentation.
- docs go-playground/validator.
- docs RFC 7807 — Problem Details for HTTP APIs.
- artigo Parse, Don't Validate — Alexis King (blog, 2019).
- vídeo Validation in DDD — Vladimir Khorikov (Pluralsight + YouTube, 2019+).