Todos os artigos
129 artigos · atualizado semanalmente Veja nossas Ferramentas
Todos os artigos
Tutoriais

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.

Como validar dados antes de salvar no banco de dados
COVER · Tutoriais

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:

  1. 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).
  2. 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.
  3. 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.

RD
Autor
Rafael Duarte
Desenvolvedor backend com passagem por fintech e SaaS B2B — trabalhou em times que escalaram APIs de zero a milhões de requisições. Carrega cicatrizes de produção suficientes para ter opiniões fortes sobre ferramentas, padrões e decisões de arquitetura. Não é acadêmico: leu a RFC do UUID quando precisou escolher entre v4 e v7 para uma tabela de alta escrita.
Ver perfil