MÓDULO 03 · CONCEITO 01 DE 8

Modelagem relacional & normalização

Codd, formas normais, e por que desnormalizar é decisão consciente — não preguiça. Modelagem do domínio em tabelas que envelhecem bem.

Tempo de leitura ~22 min Pré-requisito Módulo 00 (coesão) + SQL básico Próximo Índices em profundidade

Em junho de 1970, o matemático britânico Edgar F. Codd publicou pela IBM um artigo de doze páginas chamado A Relational Model of Data for Large Shared Data Banks. O texto começa quase como um manifesto: os dados, dizia Codd, deveriam ser representados como relações matemáticas, e os usuários jamais deveriam ter que se preocupar com onde ou como esses dados são armazenados fisicamente. O modelo de dados deveria ser independente da implementação. Em 1970 isso era heresia — sistemas comerciais como o IMS da própria IBM eram hierárquicos, e mover dados significava reescrever programas. Codd morreu em 2003 vendo seu modelo dominar a indústria por três décadas a fio.

Mais de meio século depois, o modelo relacional ainda é a forma pré-existente de representar quase qualquer domínio que tenha relacionamentos entre entidades — o que cobre a esmagadora maioria dos sistemas de negócio. Quando você modela bem, o schema sobrevive a reescritas inteiras de aplicação; é a parte mais durável do sistema. Quando modela mal, paga em queries impossíveis, dados duplicados que saem de sincronia, e migrações que duram meses.

Este conceito não é introdução a SQL — assume que você já lê e escreve queries. O foco é o pensamento por trás do schema: como modelar o domínio de forma que ele permaneça consistente sob pressão, e como decidir conscientemente quando — e somente quando — desnormalizar.

O modelo relacional, em três ideias

Codd reduziu a representação de dados a três primitivas. Tudo o que um SGBD relacional faz é construído em cima delas:

  1. Relação: uma tabela, no jargão moderno. Um conjunto de tuplas com mesma estrutura. Sem ordem inerente; conjunto, não lista.
  2. Atributo: uma coluna. Tem nome e domínio (tipo).
  3. Tupla: uma linha. Um registro concreto que satisfaz a estrutura da relação.

Em cima disso, três restrições estruturais governam o significado: chave primária identifica unicamente cada tupla; chave estrangeira referencia a primária de outra relação; integridade referencial garante que referências apontam para registros existentes. Esses três mecanismos, combinados, dão ao modelo sua principal virtude: você não pode ter dado órfão, dado duplicado por descuido, ou dado contraditório. O banco se recusa a aceitar.

Quem vem de NoSQL às vezes acha isso restritivo. É exatamente o ponto — restringir cedo é mais barato que limpar depois. Um schema bem-feito é uma máquina de prevenir bugs no nível mais profundo do sistema.

Normalização — uma escada com seis degraus

Normalizar significa reorganizar tabelas de forma que cada fato seja armazenado num único lugar. As formas normais (1FN, 2FN, 3FN, BCNF, 4FN, 5FN) são critérios cumulativos que progridem em rigor. Cada uma elimina uma classe específica de anomalia. Na prática profissional, três delas dominam.

Primeira forma normal — 1FN

Cada coluna contém apenas valores atômicos; nenhuma coluna contém lista, conjunto ou estrutura aninhada. Uma coluna telefones do tipo VARCHAR guardando "11999990000, 11888880000" viola a 1FN — você não consegue indexar nem filtrar por telefone individual. A solução é uma tabela cliente_telefones com uma linha por telefone. Para domínios em que a coleção é genuinamente parte do agregado, JSONB no Postgres oferece um meio-termo controlado — voltaremos a isso adiante.

Segunda forma normal — 2FN

Em tabelas com chave primária composta, todo atributo não-chave deve depender da chave inteira, não de parte dela. O exemplo clássico é uma tabela itens_pedido(pedido_id, produto_id, quantidade, nome_produto) com chave composta (pedido_id, produto_id). nome_produto depende só de produto_id, não da chave inteira — e portanto deveria viver na tabela produtos. Se ficar onde está, mudar o nome do produto exige atualizar todos os itens_pedido, e qualquer falha gera divergência.

Terceira forma normal — 3FN

Nenhum atributo não-chave depende de outro atributo não-chave. Numa tabela funcionarios(id, nome, cep, cidade, estado), cidade e estado são determinados pelo cep, não pelo id. Se você muda como determinado CEP é classificado, precisa atualizar potencialmente milhares de linhas. A 3FN move cidade e estado para uma tabela ceps, e funcionarios mantém apenas cep como chave estrangeira.

A regra mnemônica que se popularizou — "depender da chave, da chave inteira, e nada além da chave, então me ajude Codd" — é uma paráfrase atribuída a Bill Kent. Ela cobre 1FN, 2FN e 3FN num só gole. Para a maioria dos sistemas de negócio, atingir 3FN é o objetivo prático. Formas normais superiores (BCNF, 4FN, 5FN) tratam de casos mais sutis — multi-valued dependencies, join dependencies — que aparecem raramente fora de modelagem acadêmica.

heurística do sênior

Modele em 3FN como default. Conforme você desenha o schema, faça para cada coluna a pergunta: "esta informação é determinada pela chave primária desta tabela?". Se não, ela vive em outra tabela. Esse hábito faz a 3FN emergir naturalmente, sem ter que pensar em formas normais por nome.

Como modelar — do domínio para o schema

Schemas bons começam no domínio, não na tabela. Antes de abrir o editor, identifique entidades (substantivos do negócio: cliente, produto, pedido), relacionamentos (um pedido tem vários itens; um item refere-se a um produto), e cardinalidades (1-1, 1-N, N-N). Modelagem Entidade-Relacionamento, criada por Peter Chen em 1976, ainda é a melhor ferramenta para esse rascunho — diagramas ER continuam úteis hoje.

A passagem de modelo conceitual para schema é quase mecânica para relacionamentos 1-1 e 1-N: chave estrangeira no lado N. Para N-N entra uma tabela de junção: aluno_disciplina tem (aluno_id, disciplina_id) como chave composta, e pode carregar atributos próprios do relacionamento (nota, data de matrícula). Aqui há uma armadilha frequente: tabelas de junção tendem a virar entidades de pleno direito quando ganham atributos ricos. Quando isso acontece, dê a elas chave primária surrogate (id próprio) e nome substantivo (matricula em vez de aluno_disciplina) — é mais honesto.

Chaves naturais vs chaves surrogate

Chave natural é informação do próprio domínio: CPF, e-mail, ISBN. Chave surrogate é um identificador inventado pelo sistema: BIGSERIAL, UUID. A escolha tem consequências longas. Chave natural carrega significado e às vezes dispensa joins para mostrar informação. Chave surrogate é imutável por design e independente de mudanças de regulação ou negócio (um CPF pode ser corrigido; um ISBN pode ser revisado).

A prática contemporânea favorece surrogate como primária e naturais como índice único quando relevantes. Você ganha estabilidade e perde pouco — a chave natural permanece consultável e única, só não é a referência interna. Para sistemas distribuídos, UUID v7 (com componente temporal) virou padrão emergente: ordenado o suficiente para índices B-tree e gerável sem coordenação entre serviços.

Tipos importam — modelagem é tipagem

Postgres tem dezenas de tipos nativos, e usá-los corretamente é parte da modelagem. Armazenar dinheiro em FLOAT é defeito que segue para produção em volumes embaraçosos — NUMERIC(p,s) é a escolha. CEP em VARCHAR(10) é melhor que INTEGER porque CEP pode começar com zero e ter hífen. Datas com fuso devem ser TIMESTAMPTZ; sem fuso, raramente faz sentido em sistemas reais. Listas curtas e estáveis (status IN ('pendente','pago','cancelado')) podem ser ENUM nativo ou VARCHAR com CHECK — cada escolha tem trade-offs em versionamento.

Constraints são parte da modelagem, não decoração. NOT NULL, UNIQUE, CHECK e FOREIGN KEY são afirmações sobre o que é dado válido no domínio. Postos no banco, são impostos por todas as aplicações que conectarem — uma garantia que código de aplicação sozinho jamais entrega.

-- Bom: tipos certos, constraints expressivos
CREATE TABLE pedidos (
    id           BIGSERIAL PRIMARY KEY,
    cliente_id   BIGINT NOT NULL REFERENCES clientes(id),
    valor_total  NUMERIC(12,2) NOT NULL CHECK (valor_total >= 0),
    status       VARCHAR(20) NOT NULL
                 CHECK (status IN ('pendente','pago','cancelado','entregue')),
    criado_em    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    pago_em      TIMESTAMPTZ,
    CONSTRAINT pago_consistente
        CHECK ((status = 'pago') = (pago_em IS NOT NULL))
);

A CHECK final é uma constraint que captura uma regra de negócio: pago_em existe se e somente se status é 'pago'. Tentativas de escrita inconsistentes falham no banco. O domínio se torna inviolável estruturalmente.

Quando desnormalizar — e quando não

Desnormalização é a duplicação intencional de dados em troca de performance. Aparece em conversa de desenvolvedores como solução genérica para "queries lentas" — quase sempre erradamente. A desnormalização correta é cirúrgica: aplicada a um problema medido, com uma estratégia explícita de manutenção da consistência.

Antes de desnormalizar, esgote outras opções: índice ausente, query mal-escrita, falta de cache de aplicação, falta de view materializada. Frequentemente a "lentidão" desaparece com um índice composto bem-pensado. Quando todas as alternativas falharam e o problema persiste, três padrões justificam desnormalização.

Cache de campo cruzado

Você consulta nome do cliente em listagens de pedidos cem vezes por segundo. Em vez de joinear sempre, copie cliente_nome para pedidos. A consistência fica a cargo de um trigger ou de um event handler que atualiza pedidos quando o nome do cliente muda. O ganho é eliminar um join repetitivo; o custo é manter dois lugares sincronizados.

Agregações pré-computadas

Calcular SUM(valor_total) de pedidos de um cliente em tempo real é caro se cliente tem 50 mil pedidos. Manter cliente.total_gasto atualizado por trigger ou worker assíncrono evita o cálculo. Para muitos casos uma MATERIALIZED VIEW com refresh agendado é mais simples e suficiente — a desnormalização vive numa estrutura separada, sem poluir as tabelas operacionais.

Read models para histórias de leitura específicas

Um relatório que junta sete tabelas e roda em milissegundos é fantasia em sistemas grandes. CQRS prescreve um modelo de leitura separado, populado por eventos do modelo de escrita, com schema desenhado para a query específica. Isso é desnormalização arquitetural, não tabela bagunçada — vai discutida no Módulo 09 (comunicação entre serviços).

armadilha clássica

Desnormalizar "preventivamente" sem medição. Você acaba com três cópias de cada nome de cliente espalhadas por tabelas, e nenhuma estratégia de manutenção. Inevitavelmente uma delas sai de sincronia, e ninguém sabe qual é a fonte da verdade. Custo de manutenção dispara, ganho de performance é nulo porque o gargalo era outro.

JSONB — o meio-termo do Postgres

Postgres suporta JSONB nativamente desde a versão 9.4 (2014), com índices GIN e operadores eficientes. Para campos genuinamente livres — preferências do usuário, metadados de configuração, payloads de webhook — JSONB resolve um problema real: schema rígido onde os dados são realmente flexíveis. Permite consultar internamente (preferencias->'tema' = '"dark"') e indexar atributos específicos.

JSONB é tentação para fugir da modelagem. "Vou guardar tudo em um JSONB" é uma estratégia que envelhece mal — depois de seis meses você tem schema implícito sem checagem, queries lentas, e validação dispersa pelo código. A regra prática é: use JSONB quando você não pode prever o schema (configurações por tenant, atributos de produto que variam por categoria), use colunas tipadas quando pode. Híbrido funciona bem: produtos tem colunas tipadas para campos universais (nome, preço, sku) e um JSONB para atributos específicos da categoria.

O mesmo modelo nas três linguagens

A modelagem é da mesma essência em qualquer stack — o que muda é como cada ecossistema descreve o schema. Veja como expressar pedidos & itens em código:

C# — Entity Framework Core 9
public class Pedido {
    public long Id { get; set; }
    public long ClienteId { get; set; }
    public Cliente Cliente { get; set; } = null!;
    public decimal ValorTotal { get; set; }
    public string Status { get; set; } = "pendente";
    public DateTime CriadoEm { get; set; }
    public List<ItemPedido> Itens { get; set; } = new();
}

public class CatalogContext : DbContext {
    protected override void OnModelCreating(ModelBuilder b) {
        b.Entity<Pedido>(e => {
            e.Property(p => p.ValorTotal)
              .HasColumnType("numeric(12,2)");
            e.Property(p => p.Status).HasMaxLength(20);
            e.HasCheckConstraint("ck_status",
              "status IN ('pendente','pago','cancelado','entregue')");
            e.HasIndex(p => new { p.ClienteId, p.CriadoEm });
        });
    }
}

EF Core 9 expressa constraints e índices via Fluent API. Tipos numéricos exigem HasColumnType explícito para evitar que decimal vire numeric(18,2) default. Migrations geradas via dotnet ef migrations add.

Python — SQLAlchemy 2.0
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import ForeignKey, Numeric, CheckConstraint
from datetime import datetime

class Pedido(Base):
    __tablename__ = "pedidos"
    __table_args__ = (
        CheckConstraint(
          "status IN ('pendente','pago','cancelado','entregue')",
          name="ck_status"),
    )

    id: Mapped[int] = mapped_column(primary_key=True)
    cliente_id: Mapped[int] = mapped_column(ForeignKey("clientes.id"))
    valor_total: Mapped[Decimal] = mapped_column(Numeric(12, 2))
    status: Mapped[str] = mapped_column(String(20), default="pendente")
    criado_em: Mapped[datetime] = mapped_column(default=func.now())

    cliente: Mapped["Cliente"] = relationship(back_populates="pedidos")
    itens: Mapped[list["ItemPedido"]] = relationship(
        back_populates="pedido", cascade="all, delete-orphan")

SQLAlchemy 2.0 trouxe Mapped[] com tipagem completa. __table_args__ agrupa constraints. Alembic gera migrações a partir desse modelo via alembic revision --autogenerate.

Go — sqlc + migrations à mão
-- schema.sql (fonte da verdade)
CREATE TABLE pedidos (
    id          BIGSERIAL PRIMARY KEY,
    cliente_id  BIGINT NOT NULL REFERENCES clientes(id),
    valor_total NUMERIC(12,2) NOT NULL CHECK (valor_total >= 0),
    status      VARCHAR(20) NOT NULL DEFAULT 'pendente',
    criado_em   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT ck_status CHECK
      (status IN ('pendente','pago','cancelado','entregue'))
);
CREATE INDEX idx_pedidos_cliente_data ON pedidos(cliente_id, criado_em);

-- queries.sql (geradas em Go pelo sqlc)
-- name: BuscarPedidosPorCliente :many
SELECT * FROM pedidos WHERE cliente_id = $1 ORDER BY criado_em DESC;

A filosofia Go é manter SQL como fonte. sqlc lê schema.sql e queries.sql e gera código Go type-safe. Não há "model" em Go separado do banco — o schema é o modelo. Migrations vivem em migrations/ versionadas via golang-migrate.

Antipadrões de modelagem para reconhecer

Tabela "EAV" (entity-attribute-value)

atributos(entidade_id, chave, valor) guardando qualquer atributo de qualquer entidade num só lugar. Aparece em sistemas que tentam ser "infinitamente flexíveis". Resultado: consultas tornam-se árvores de joins, tipagem somem, performance desaba. Quase sempre JSONB resolve melhor o problema que EAV tentava resolver.

Coluna multi-propósito

numero VARCHAR(50) que guarda às vezes telefone, às vezes documento, dependendo do tipo em outra coluna. Validação some, índices são inúteis, cada query precisa do tipo junto. Separe em colunas distintas, mesmo que a maioria fique nula.

Tabela "deus"

Uma tabela cadastro com 80 colunas que mistura cliente, fornecedor, parceiro e funcionário. "Cadastro" não é conceito — é hospedeiro de várias entidades distintas. Separe. Para campos que realmente são compartilhados (endereço, contato), crie tabelas próprias relacionadas.

Soft delete via flag em todas as tabelas

deletado_em TIMESTAMPTZ em toda tabela. Toda query precisa lembrar de filtrar. Esquecimentos viram bugs sutis. Em Postgres, padrão emergente é usar tabelas de auditoria com versionamento, ou views que filtram automaticamente. Quando soft delete é mesmo necessário, use Row-Level Security ou views consistentes — não dependa de toda query lembrar.

O modelo evolui — e tudo bem

Schemas mudam. O nome do produto vira "denominação"; campo opcional vira obrigatório; relacionamento 1-N vira N-N. Modelar pensando que o schema é fixo é uma das premissas mais erradas que se carrega da universidade. O que distingue bons modelos não é "estar certo de primeira" — é tolerar a mudança sem precisar reescrever o sistema inteiro. Esse é o tema do conceito 05 (Migrations); por ora, basta saber que toda escolha de hoje vai ser questionada por alguém em dois anos, e seu trabalho é deixar o caminho livre para essa pessoa.

Como praticar

  1. Pegue um sistema seu e desenhe o ER da memória. Não consulte o schema atual. Compare depois — onde você modelaria diferente hoje? Onde a 3FN foi violada e por quê? Há tabelas-deus que merecem ser quebradas?
  2. Leia a documentação de tipos do Postgres por inteiro. São quatro horas, e você descobre tipos cuja existência mudaria decisões passadas — RANGE, INET, GENERATED ALWAYS AS, CITEXT.
  3. Modele o catálogo do projeto do módulo. Comece sem código: papel ou Mermaid. Liste entidades, relacionamentos, cardinalidades. Justifique cada chave (natural vs surrogate), cada constraint, cada índice candidato. Só depois traduza para DDL.

Referências para aprofundar

  1. livro Database Design for Mere Mortals — Michael J. Hernandez (4ª ed., 2024). A introdução mais didática a modelagem relacional. Atravessa entidade-relacionamento até 3FN sem pular passos.
  2. livro SQL Antipatterns — Bill Karwin (2010, atualização "Strikes Back" 2022). Catálogo de modelagens ruins e seus consertos. EAV, multi-propósito, soft-delete genérico — Karwin nomeia e dissolve cada um.
  3. livro The Art of PostgreSQL — Dimitri Fontaine (2ª ed., 2022). Postgres como ferramenta de design, não só storage. Fontaine é committer; o livro mostra recursos pouco usados que mudam decisões.
  4. livro Designing Data-Intensive Applications — Martin Kleppmann (2017). Cap. 2 cobre modelos de dados (relacional, documento, grafo) com clareza incomum. Leitura obrigatória para sêniores.
  5. paper A Relational Model of Data for Large Shared Data Banks — E. F. Codd (1970). O paper original. Doze páginas. Ainda lê-se com proveito — Codd era didático.
  6. artigo The Vietnam of Computer Science — Ted Neward (2006). odetocode.com — ensaio canônico sobre o impedance mismatch entre modelo objeto e modelo relacional. Ainda atual.
  7. artigo Choose Postgres queue technology — Brandur Leach (2024). brandur.org — não é sobre filas só; é sobre quanto Postgres entrega antes de você precisar de outra coisa. Modelagem como decisão de arquitetura.
  8. artigo Just use Postgres for everything — Stephan Schmidt (2023). amazingcto.com — provocação fundamentada: o que Postgres faz hoje cobre a esmagadora maioria das necessidades. Saudável calibragem.
  9. docs PostgreSQL Documentation — Data Types. postgresql.org/docs/current/datatype.html — Cap. 8. Lê-se em meio dia e calibra escolhas para o resto da carreira.
  10. docs PostgreSQL Documentation — Constraints. postgresql.org/docs/current/ddl-constraints.html — cobertura completa de CHECK, UNIQUE, FOREIGN KEY, exclusion constraints.
  11. vídeo Schema design tips and tricks — Lukas Fittl (PGCon). YouTube. Palestra de meia hora densa em decisões reais — JSONB vs colunas, índices parciais, particionamento.
  12. vídeo Database Schema Design Patterns — Hussein Nasser. YouTube. Várias palestras curtas catalogando padrões e antipadrões com exemplos visuais.