Como validar dados antes de salvar no banco de dados
Onde validar dados antes de salvar no banco — front, back e constraints — e como fazer isso de forma segura sem espalhar ifs.
Um campo de e-mail que aceita joao@@gmail, um CPF gravado com 10 dígitos, um preço negativo que zera o caixa no fim do mês. Quase todo bug de dados sujos começa do mesmo jeito: alguém confiou que o dado chegaria limpo e salvou direto no banco. O problema é que, depois de persistido, um dado inválido para de ser um bug de formulário e vira um problema de negócio — relatório errado, integração que quebra, cliente irritado. Validar antes de salvar é o ponto onde você ainda controla o estrago.
Este guia mostra onde validar, o que cada camada resolve (e o que ela não resolve), e como montar uma validação que segura tanto o usuário distraído quanto o cliente malicioso — sem espalhar if por todo lado.
Validação não é uma camada só
A confusão mais comum é tratar validação como uma decisão única: "valido no front ou no back?". A resposta honesta é: nos dois, mais no banco, e cada um com um objetivo diferente.
- Front-end (browser): feedback imediato. Mostra o erro antes de o usuário enviar o formulário, sem round-trip. É experiência, não segurança. Qualquer pessoa abre o DevTools e contorna.
- Back-end (aplicação): a fronteira real. É aqui que você decide se o dado entra no sistema. Tudo que chega via HTTP é hostil até prova em contrário, inclusive requisições que não passaram pelo seu formulário.
- Banco de dados: a última trava. Constraints (
NOT NULL,UNIQUE,CHECK, foreign keys) garantem invariantes mesmo que um bug na aplicação deixe passar, ou que outro serviço escreva na mesma tabela.
A regra de ouro: nunca confie na validação que você não controla no momento da escrita. O front valida para o usuário; o back valida para o sistema; o banco valida para a verdade.
Comece pela aplicação: valide na borda
A primeira coisa a fazer é validar o input assim que ele entra, antes de qualquer regra de negócio. Em PHP/Laravel isso é declarativo:
$dados = $request->validate([
'email' => 'required|email|max:255',
'idade' => 'required|integer|min:18|max:120',
'preco' => 'required|numeric|min:0',
'cpf' => 'required|cpf', // via laravel-validation-rules/cpf-cnpj
]);
Em Node, a mesma ideia com Zod — com a vantagem de o tipo sair de graça:
import { z } from "zod";
const Usuario = z.object({
email: z.string().email().max(255),
idade: z.number().int().min(18).max(120),
preco: z.number().nonnegative(),
});
const result = Usuario.safeParse(req.body);
if (!result.success) return res.status(422).json(result.error.flatten());
O ponto importante: valide e transforme no mesmo passo. " joao@gmail.com " deve virar joao@gmail.com (trim + lowercase) antes de ser salvo. Validação sem normalização gera duplicatas que passam no UNIQUE por causa de um espaço invisível.
Distinga os três tipos de regra
Misturar tudo num validador só é o que torna o código ilegível. Separe mentalmente:
- Formato/sintaxe: o dado tem a forma certa? E-mail com
@, data parseável, número dentro do range. Resolve-se com schema (Zod, Laravel rules, JSON Schema). - Regra de domínio: o dado faz sentido no negócio? Data de fim depois da data de início, total do pedido igual à soma dos itens. Isso é código, não schema.
- Regra de estado/consistência: o dado é único agora? E-mail já cadastrado, estoque disponível, slug livre. Só o banco sabe — e só de forma confiável dentro de uma transação.
O erro clássico é resolver o item 3 com um SELECT antes do INSERT:
// Frágil: race condition entre o SELECT e o INSERT
const existe = await db.user.findUnique({ where: { email } });
if (existe) throw new Error("E-mail já cadastrado");
await db.user.create({ data: { email } });
Entre o findUnique e o create, duas requisições simultâneas passam pelo mesmo if. A defesa correta é uma constraint UNIQUE no banco e capturar a violação:
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE (email);
try {
await db.user.create({ data: { email } });
} catch (e) {
if (e.code === "P2002") return res.status(409).json({ erro: "E-mail já existe" });
throw e;
}
O banco é o único árbitro de unicidade que não tem race condition.
Use o banco como rede de segurança, não como validador principal
Constraints são excelentes para invariantes simples e baratas: CHECK (preco >= 0), NOT NULL, foreign keys, UNIQUE. Elas protegem contra bugs e contra outras aplicações que escrevem na mesma base. Mas têm limites: a mensagem de erro é técnica, a validação acontece tarde (depois de montar a query), e regras complexas viram triggers difíceis de manter.
A divisão prática que funciona:
- Aplicação: validação rica, mensagens amigáveis, regras de domínio.
- Banco: invariantes que jamais podem ser violadas, custe o que custar.
As duas se sobrepõem de propósito. É redundância intencional, não desperdício.
Validação ponta a ponta tem mais de um portão
Vale notar que validar o input do formulário é só um dos portões. Dados que chegam por importação em massa também precisam passar pela mesma validação — e ali entram problemas que o formulário nunca tem, como encoding quebrado. Se você processa uploads de planilha, vale ler como importar CSV sem corromper caracteres, porque um acento virado em ç passa em qualquer validador de "string não vazia" e só aparece como dado sujo lá na frente.
A ideia é tratar toda fronteira de entrada — formulário, API, fila, import — com o mesmo rigor. Um schema reutilizável ajuda: defina o contrato uma vez e aplique em todos os pontos de entrada.
Schema como contrato compartilhado
Quando front, back e integrações precisam concordar sobre o que é um dado válido, escrever a regra três vezes garante que elas vão divergir. A saída é declarar a estrutura num formato neutro — JSON Schema — e validar contra ele em qualquer linguagem:
{
"type": "object",
"required": ["email", "idade"],
"properties": {
"email": { "type": "string", "format": "email" },
"idade": { "type": "integer", "minimum": 18, "maximum": 120 }
},
"additionalProperties": false
}
O additionalProperties: false é subestimado: ele rejeita campos que você não esperava, o que bloqueia mass assignment (alguém enviando "is_admin": true no payload). Antes de cravar um schema desses no código, vale testá-lo com payloads reais no validador de JSON Schema e confirmar que ele aceita o que deve e rejeita o que não deve.
Perguntas frequentes
Preciso validar no front e no back, ou um só basta?
Os dois, mas por motivos diferentes. O front melhora a experiência (erro instantâneo, sem reload); o back garante a segurança. Validar só no front é furo de segurança — qualquer cliente HTTP ignora seu JavaScript. Validar só no back é uma experiência ruim, mas segura. Em produção séria, ambos, mais constraints no banco.
Como validar usuário e senha salvos no banco?
Na leitura (login), você não valida formato — você compara. Nunca guarde senha em texto puro: salve o hash (bcrypt ou Argon2) e, no login, compare a senha digitada com o hash via password_verify (PHP) ou bcrypt.compare (Node). A validação de formato da senha (mínimo de caracteres, força) acontece no cadastro, antes do hash; a verificação no login é uma comparação criptográfica, não uma validação de formato.
Validar no banco com CHECK constraints é boa prática?
Sim, para invariantes simples e inegociáveis (preco >= 0, quantidade > 0). É a única camada que protege contra bugs da aplicação e contra outros serviços escrevendo na mesma tabela. Não use o banco para regras de negócio complexas — a mensagem de erro é ruim e a manutenção via triggers é dolorosa. Banco = rede de segurança; aplicação = validação rica.
O que fazer quando a validação falha numa importação grande?
Não pare tudo no primeiro erro. Valide cada linha, acumule os erros com número da linha e campo, e devolva um relatório. Para o registro válido, decida a política: pular as linhas ruins e importar o resto, ou rejeitar o lote inteiro numa transação. As duas são legítimas — o errado é importar metade e deixar o usuário sem saber o que falhou.
O que levar deste guia
Validação não é um if no controller; é uma estratégia em camadas. Valide o formato na borda da aplicação, com normalização junto. Trate regras de domínio como código separado do schema. Deixe unicidade e invariantes para o banco, dentro de transações, e capture as violações em vez de tentar prevê-las com SELECT. E quando o contrato precisa ser compartilhado entre serviços, declare-o uma vez como schema e valide contra ele em todo ponto de entrada. O dado limpo no banco é consequência de não confiar em ninguém no momento da escrita — nem no usuário, nem na sua própria aplicação.
- 01 O que é software open source Open source não significa gratuito. Entenda o que a licença realmente define, as diferenças entre MIT, GPL e Apache, e como projetos open source se sustentam financeiramente.
- 02 PostgreSQL vs MySQL: diferenças práticas para quem precisa escolher Comparativo honesto entre PostgreSQL e MySQL: tipos nativos, JSONB vs JSON_EXTRACT, concorrência MVCC e extensões. Por que PostgreSQL como default faz sentido.