DevGuideTK
← Trilha Backend

Modelagem e normalização

Tabelas, chaves e formas normais, e quando desnormalizar de propósito.

O problema: o modelo dura mais que o código

O código de uma aplicação é reescrito, refatorado, trocado de framework. As tabelas ficam. Mudar uma coluna numa tabela com milhões de linhas exige uma migração cuidadosa, e os dados ruins que entraram por um modelo frouxo continuam lá, esperando a próxima consulta que não sabe tratá-los.

A modelagem relacional decide três coisas: quais tabelas existem, quais colunas cada uma tem e como elas se ligam.

Tabelas, linhas e chaves

Cada tabela representa um tipo de coisa: clientes, produtos, pedidos. Cada linha é uma ocorrência. E cada tabela tem uma chave primária, a coluna (ou o conjunto de colunas) que identifica a linha sem ambiguidade.

As ligações entre tabelas são feitas por chaves estrangeiras: a coluna pedidos.cliente_id aponta para clientes.id, e o banco recusa um pedido de um cliente que não existe.

Os relacionamentos têm três formas:

RelacionamentoExemploComo se modela
Um para muitosUm cliente tem vários pedidosA chave estrangeira fica no lado "muitos": pedidos.cliente_id
Muitos para muitosUm pedido tem vários produtos; um produto está em vários pedidosUma tabela no meio: itens_pedido (pedido_id, produto_id, quantidade)
Um para umUm cliente tem um endereço de cobrançaA chave estrangeira com UNIQUE, ou as colunas na mesma tabela

A tabela do meio, nos muitos para muitos, costuma ganhar vida própria: itens_pedido não guarda só os dois identificadores, mas a quantidade e o preço daquele item naquele pedido.

Normalização: cada fato num lugar só

Imagine uma tabela de pedidos que guarda, em cada linha, o nome e o e-mail do cliente:

pedidocliente_emailcliente_nometotal
1ana@exemplo.comAna120.00
2ana@exemplo.comAna80.00
3ana@exemplo.comAna Souza45.00

O nome da Ana aparece três vezes, e já está diferente numa delas. Qual é o certo? Para mudar o e-mail dela, é preciso atualizar todas as linhas, e uma atualização que esquece uma linha deixa o banco contraditório. Esses são os problemas de atualização que a normalização evita.

Normalizar é organizar as tabelas para que cada fato fique num lugar só. As formas normais formalizam isso. Na prática, as três primeiras resolvem quase tudo:

Forma normalRegraViolação típica
1ªCada coluna guarda um valor sótelefones = "1199..., 1198..." numa coluna de texto
2ªEm chaves compostas, cada coluna depende da chave inteiraitens_pedido com o nome do produto, que depende só de produto_id
3ªColunas não dependem de outras colunas que não são a chavepedidos com o nome do cliente, que depende de cliente_id

A frase que resume as três: cada coluna depende da chave, da chave inteira e de nada além da chave.

Desnormalizar de propósito

Normalizar não é dogma. Às vezes, repetir um dado é exatamente o certo:

  • O preço na hora da compra. itens_pedido.preco_unitario repete o preço do produto, mas não é redundância: o preço do produto muda amanhã, e o pedido de hoje precisa continuar com o valor cobrado. São dois fatos diferentes, que hoje coincidem.
  • Totais para leitura rápida. Guardar pedidos.total evita somar os itens a cada listagem, ao custo de manter o total certo sempre que um item muda.
  • Cópias para relatórios. Tabelas desnormalizadas, montadas a partir das normalizadas, para consultas analíticas.

A diferença entre desnormalizar e bagunçar é saber qual é a fonte da verdade e quem mantém a cópia atualizada.

As regras ficam no banco

As restrições do banco (NOT NULL, UNIQUE, CHECK, FOREIGN KEY) garantem as regras para qualquer código que escreva na tabela: a aplicação de hoje, o script de amanhã, a correção manual feita às pressas. A tentação é conferir na aplicação ("já existe um cliente com esse e-mail?") e pular a restrição. Medimos o que acontece com 20 cadastros simultâneos do mesmo e-mail, escrito ora em minúsculas, ora com maiúsculas, todos passando pela conferência antes de gravar:

TabelaCadastros criadosLinhas com o mesmo e-mail
Só a conferência na aplicação1818
Conferência e índice único em lower(email)11

Com a conferência sozinha, 18 dos 20 pedidos consultaram a tabela antes de qualquer outro gravar, não acharam nada e gravaram. Com o índice único, o banco recusou 16 e a conferência barrou 3. A conferência na aplicação continua útil para dar uma mensagem amigável, mas quem garante a regra é o banco. O índice em lower(email) também resolve a diferença entre Ana@Exemplo.com e ana@exemplo.com, que um UNIQUE (email) simples deixaria passar.

Os tipos certos

Três escolhas de tipo que custam caro quando erradas, medidas no Postgres 18:

Dinheiro não é ponto flutuante.

Contadouble precisionrealnumeric
Somar 0,1 dez vezes0,9999999999999999—1,0
0,1 + 0,2 = 0,3?falso—verdadeiro
19,99 × 359,9759,9699993133544959,97

Ponto flutuante representa números em base 2, e 0,1 não tem representação exata. Os erros aparecem em somas e comparações. Para dinheiro, use numeric(12, 2) ou um inteiro em centavos.

Datas precisam do fuso. Gravamos now() numa coluna timestamp e noutra timestamptz, com a sessão em America/Sao_Paulo, e lemos com a sessão em UTC. A coluna timestamptz mostrou 04:48 em UTC, o instante correto. A coluna timestamp mostrou 01:48, o horário de Brasília guardado sem fuso, agora interpretado como se fosse UTC: três horas de erro, sem nenhum aviso. Use timestamptz.

O tipo da chave primária pesa. Inserimos 2 milhões de linhas em três tabelas iguais, mudando só a chave:

Chave primáriaTempo de inserçãoTamanho do índice
bigint sequencial3,8 s43 MB
UUID v4 (aleatório)8,9 s76 MB
UUID v7 (ordenado pelo tempo)5,0 s60 MB

O UUID v4 é aleatório: cada linha nova cai num ponto qualquer do índice, que se divide em páginas meio vazias. O UUID v7 começa pelo carimbo de tempo, e as linhas novas vão para o fim do índice, como num sequencial. Os números são de uma inserção em lote numa máquina de desenvolvimento, e servem para comparar os três tipos entre si, não como medida absoluta.

A chave estrangeira precisa de índice

O Postgres cria um índice para a chave primária e para UNIQUE, mas não para a coluna da chave estrangeira. Isso pesa ao apagar ou atualizar a linha apontada: para cada cliente apagado, o banco precisa achar os pedidos dele.

Com 100 mil clientes e 2 milhões de pedidos, apagar 100 clientes (com ON DELETE CASCADE) levou:

pedidos.cliente_idTempo
Sem índice10,0 s
Com índice5 ms

Sem o índice, cada cliente apagado gerou uma varredura completa da tabela de pedidos. O mesmo índice serve para a consulta mais comum desse modelo: "os pedidos deste cliente".

Por que isso importa quando a IA escreve o código

Um assistente que cria as tabelas de um sistema costuma gerar um modelo que funciona nos testes e envelhece mal:

  • Nenhuma restrição além da chave primária: sem UNIQUE, sem CHECK, às vezes sem chave estrangeira.
  • Unicidade conferida só na aplicação.
  • float ou double para dinheiro, timestamp sem fuso para datas.
  • Chaves estrangeiras sem índice.
  • Dados copiados entre tabelas sem uma fonte da verdade, ou o oposto: o preço do item lido do produto, mudando pedidos antigos.
  • Listas guardadas como texto separado por vírgulas, ou um jsonb no lugar de tabelas.

O próximo nível compara as decisões de modelagem uma a uma: chaves, exclusão, histórico, tipos e quando desnormalizar.

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Modelagem relacional em cinco minutos

    Explique em no máximo cinco minutos de leitura o que é modelar um banco relacional, para alguém que já escreveu consultas SQL, mas nunca desenhou as tabelas de um sistema.
    
    Cubra só o essencial: tabelas e chaves, os três tipos de relacionamento, o que a normalização evita, por que as regras ficam no banco e quando repetir um dado é o certo.
    
    Use o exemplo de uma loja com clientes, produtos e pedidos. No final, me faça três perguntas curtas para eu verificar se entendi, e corrija as minhas respostas.
  • Formas normais na prática

    Quero entender as formas normais sem decorar definições.
    
    Aja como um mentor e vá por etapas, esperando a minha resposta em cada uma:
    1. Me mostre uma tabela única de pedidos com tudo dentro e peça que eu aponte os problemas de atualização.
    2. A 1ª forma normal, com um exemplo de violação para eu corrigir.
    3. A 2ª forma normal, com uma chave composta.
    4. A 3ª forma normal, com uma dependência entre colunas.
    5. Um caso em que repetir o dado é o certo, e por quê.
    
    Em cada etapa, me deixe propor o novo desenho antes de mostrar o seu.
  • Restrições do banco contra validação na aplicação

    Quero entender por que as regras de integridade devem ficar no banco, mesmo com validação na aplicação.
    
    Explique:
    1. NOT NULL, UNIQUE, CHECK e FOREIGN KEY, com um exemplo de cada.
    2. Por que conferir "já existe?" antes de gravar não impede duplicatas com pedidos simultâneos.
    3. Índices únicos com expressão, como lower(email), e índices parciais.
    4. Como a aplicação traduz a recusa do banco numa resposta útil.
    5. O que acontece com uma transação depois de uma recusa, e para que servem os savepoints.
    
    Depois me dê três regras de negócio e pergunte como eu as garantiria no banco.
  • Tipos de dados que custam caro

    Quero entender as escolhas de tipo que mais causam problemas em bancos relacionais.
    
    Explique, com um exemplo do erro e da correção:
    1. Dinheiro em ponto flutuante.
    2. Datas sem fuso horário.
    3. UUID aleatório como chave primária, comparado com bigint e UUID v7.
    4. Listas guardadas como texto separado por vírgulas.
    5. jsonb usado no lugar de tabelas.
    
    Para cada um, me faça uma pergunta para eu prever o problema antes de você explicar.