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

Como modelar tabelas sem criar um banco impossível de manter

Normalização sem dogma, uma coluna por fato, foreign keys de verdade e tipos honestos: como desenhar um schema que sobrevive ao tempo e evita os anti-padrões caros.

Como modelar tabelas sem criar um banco impossível de manter
COVER · Tutoriais

Quase todo banco difícil de manter começou com um schema que parecia esperto. Alguém quis poupar uma tabela e meteu três telefones numa coluna contatos separada por vírgula. Alguém guardou preco como FLOAT porque "é número". Alguém decidiu que data_nascimento podia ser VARCHAR porque o formato variava. Seis meses depois, ninguém consegue rodar um relatório sem um SPLIT improvisado, e cada deploy é uma reza. O problema raramente é falta de conhecimento avançado — é a recusa de modelar a verdade do domínio antes de pensar em atalho.

Este texto é sobre como desenhar tabelas que sobrevivem ao tempo: normalização sem dogma, tipos honestos, integridade no banco, e os anti-padrões que parecem economia e cobram juros.

Normalização é ponto de partida, não religião

A terceira forma normal (3NF) resolve a maioria dos seus problemas: cada coluna depende da chave, da chave inteira, e de nada além da chave. Não é teoria acadêmica — é a defesa contra dado duplicado que sai do sincronismo. Se o nome do cliente está copiado em mil linhas de pedido, o dia em que ele casa e muda de sobrenome você tem mil verdades conflitantes.

Comece em 3NF por padrão. Modele entidades como elas existem no mundo: um cliente é uma tabela, um pedido é outra, e a relação entre eles é uma foreign key. Não desnormalize "por performance" antes de ter um problema de performance medido. Otimização especulativa é a forma mais cara de complexidade: você paga o custo de manutenção todo dia e talvez nunca colha o ganho. Modele para a verdade primeiro; otimize depois, com dado real e um EXPLAIN na mão.

Uma coluna é um fato

A regra mais violada e a mais barata de seguir: cada coluna guarda exatamente um fato atômico. No instante em que você está pensando em colocar "11999999999,1133334444" numa coluna, ou um JSON improvisado com uma lista que você vai filtrar depois, pare. Isso é uma tabela disfarçada.

-- Errado: a coluna esconde uma relação 1:N
CREATE TABLE cliente (
  id        BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  nome      TEXT NOT NULL,
  telefones TEXT  -- "11999999999,1133334444" — impossível indexar, validar ou consultar
);

-- Certo: a relação vira tabela
CREATE TABLE telefone (
  id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  cliente_id BIGINT NOT NULL REFERENCES cliente (id) ON DELETE CASCADE,
  numero     TEXT   NOT NULL,
  tipo       TEXT   NOT NULL DEFAULT 'celular'
);

JSON tem seu lugar — atributos genuinamente sem schema, payloads de integração, configurações que você nunca consulta por campo. Mas se você vai dar WHERE, JOIN ou ORDER BY num pedaço daquele JSON, ele queria ser uma coluna ou uma tabela. Campo CSV nunca tem lugar.

Chaves e foreign keys de verdade

Integridade referencial é responsabilidade do banco, não da aplicação. "Mas a gente valida no código" não basta: você tem migrações, scripts ad-hoc, dois serviços escrevendo na mesma tabela, e o estagiário rodando UPDATE no console de produção às onze da noite. A foreign key é a única defesa que não dorme.

Declare a foreign key com a ação certa: ON DELETE CASCADE quando o filho não existe sem o pai (um item de pedido sem pedido é lixo), ON DELETE RESTRICT quando apagar o pai deveria ser bloqueado, ON DELETE SET NULL quando a relação é opcional. Toda primary key deve existir e ser estável — prefira uma surrogate key (IDENTITY ou UUID) a uma chave natural que pode mudar. CPF parece imutável até o dia em que foi digitado errado.

Tipos certos, sempre

O tipo de dado é a primeira camada de validação e você ganha de graça. Errar aqui contamina tudo que vem depois.

  • Dinheiro nunca é FLOAT. Ponto flutuante não representa 0.10 exatamente; sua soma de centavos vai derivar. Use NUMERIC(12,2) (ou inteiro em centavos).
  • Data é DATE/TIMESTAMPTZ, não VARCHAR. String não ordena por tempo, não soma intervalo, e aceita "31/02/2026". Com TIMESTAMPTZ você ainda resolve fuso de uma vez.
  • Booleano é BOOLEAN, não CHAR(1) com 'S'/'N' nem inteiro 0/1.
  • Enumeração curta e estável vira enum nativo ou uma tabela de lookup com foreign key — nunca texto livre que aceita 'ativo', 'Ativo' e 'atvo'.

Ao revisar um CREATE TABLE longo cheio de tipos e constraints, formatar o SQL deixa as colunas alinhadas e os erros de tipo saltam aos olhos antes de chegarem em produção.

NULL com intenção

NULL significa "desconhecido" ou "não aplicável" — não "vazio" e muito menos "zero". Essa distinção é semântica e tem consequência: NULL se propaga em comparações (NULL = NULL é falso), é ignorado por agregações, e quebra suposições ingênuas no código.

Decida coluna por coluna. Se data_cancelamento é NULL, o pedido não foi cancelado — NULL aqui carrega informação e está correto. Mas se quantidade pode ser NULL, pergunte o que isso significa de verdade; provavelmente você quer NOT NULL DEFAULT 0. Cada coluna anulável é uma ramificação a mais que todo JOIN e todo relatório precisa tratar. Torne NOT NULL o padrão e justifique cada exceção.

Nomeação consistente e índices onde a busca acontece

Escolha uma convenção e não negocie: snake_case, tabelas no singular (cliente) ou plural (clientes) — tanto faz qual, contanto que seja uma só no schema inteiro. Foreign key como <entidade>_id (cliente_id). Nada de cli_cod, idCliente e cliente_codigo convivendo. Inconsistência de nome é atrito cognitivo em toda query que alguém escreve.

Índices vêm depois do schema honesto, mas não muito depois. Toda coluna de foreign key que você usa em JOIN deve ser indexada — o Postgres não cria esse índice sozinho, ao contrário do que muita gente assume. Indexe também as colunas que aparecem em WHERE e ORDER BY de consultas reais. Não saia indexando tudo: índice acelera leitura e penaliza escrita, então deixe o pg_stat e os planos de execução guiarem. Se você curte os detalhes de como cada engine trata índices e tipos, vale ler PostgreSQL vs MySQL antes de decidir.

CHECK constraints como rede de segurança

Quando o tipo não basta para expressar a regra, o CHECK entra. É a invariante do domínio escrita onde ela não pode ser contornada.

CREATE TABLE pedido_item (
  id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  pedido_id  BIGINT  NOT NULL REFERENCES pedido (id) ON DELETE CASCADE,
  quantidade INTEGER NOT NULL CHECK (quantidade > 0),
  preco      NUMERIC(12,2) NOT NULL CHECK (preco >= 0),
  desconto   NUMERIC(12,2) NOT NULL DEFAULT 0
             CHECK (desconto >= 0 AND desconto <= preco)
);

Quantidade negativa, preço negativo, desconto maior que o preço — nenhum desses estados deveria existir, e o CHECK garante que não vão existir, venha o dado de onde vier. É barato de declarar e impagável quando segura o bug que a aplicação esqueceu de validar.

Os anti-padrões que cobram juros

Três armadilhas reincidentes:

EAV genérico (entity-attribute-value): uma tabela atributos (entidade_id, chave, valor) que promete flexibilidade infinita e entrega queries ilegíveis, tipos perdidos (tudo vira texto) e zero integridade. Quase sempre você queria colunas de verdade, ou um campo JSON num canto, não um meta-schema reinventando o banco dentro do banco.

Tabela-deus: uma dados com sessenta colunas, metade NULL em qualquer linha, misturando cliente, pedido e pagamento. É a 3NF que nunca aconteceu. Quebre por entidade.

Desnormalizar cedo demais "por performance": você copia o nome do produto na linha do pedido "pra não dar JOIN", e agora tem dois lugares pra atualizar e um deles vai ficar pra trás. Desnormalização é uma decisão consciente, tomada depois de medir, com um caminho de sincronização explícito — não um reflexo.

Perguntas frequentes

Preciso sempre chegar na 3NF?

A 3NF é um bom ponto de partida, não uma meta sagrada. Ela elimina a maioria das redundâncias que causam dados inconsistentes, e por isso vale como padrão. Desnormalizar acima dela é uma decisão consciente, tomada depois de medir um gargalo real — não o ponto de partida.

Devo usar chave natural ou surrogate key?

Prefira surrogate key (IDENTITY ou UUID) na maioria dos casos. Chaves naturais como CPF ou e-mail parecem estáveis até o dia em que mudam ou foram digitadas erradas, e aí a alteração se propaga por toda foreign key que aponta para elas. A surrogate key te dá um identificador que nunca precisa mudar.

Posso guardar JSON numa coluna em vez de criar tabelas?

Pode, para dados genuinamente sem estrutura fixa ou que você nunca consulta por dentro (um payload de log, configurações soltas). O erro é usar JSON para fugir de modelar uma relação clara: se você precisa filtrar, juntar ou validar aqueles campos, eles querem ser colunas e tabelas de verdade, com tipos e foreign keys.

Quando devo criar um índice?

Em toda coluna de foreign key usada em JOIN (o Postgres não cria esse índice sozinho) e nas colunas que aparecem em WHERE e ORDER BY de consultas reais. Não indexe tudo por precaução: índice acelera leitura e penaliza escrita. Deixe os planos de execução e as estatísticas guiarem o que realmente precisa de índice.

O que levar daqui

Um schema honesto é mais fácil de manter que um esquema esperto. Modele as entidades como elas existem no domínio, deixe cada coluna guardar um fato, e faça o banco defender a própria integridade com foreign keys, tipos certos e CHECK constraints. Normalize por padrão e desnormalize só quando um número medido — não um pressentimento — disser que vale a pena. O dia em que o schema fica chato e previsível é o dia em que ele parou de te acordar de madrugada.

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