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:
- Relação: uma tabela, no jargão moderno. Um conjunto de tuplas com mesma estrutura. Sem ordem inerente; conjunto, não lista.
- Atributo: uma coluna. Tem nome e domínio (tipo).
- 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.
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).
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:
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.
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.
-- 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
- 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?
-
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. - 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
- livro Database Design for Mere Mortals — Michael J. Hernandez (4ª ed., 2024).
- livro SQL Antipatterns — Bill Karwin (2010, atualização "Strikes Back" 2022).
- livro The Art of PostgreSQL — Dimitri Fontaine (2ª ed., 2022).
- livro Designing Data-Intensive Applications — Martin Kleppmann (2017).
- paper A Relational Model of Data for Large Shared Data Banks — E. F. Codd (1970).
- artigo The Vietnam of Computer Science — Ted Neward (2006).
- artigo Choose Postgres queue technology — Brandur Leach (2024).
- artigo Just use Postgres for everything — Stephan Schmidt (2023).
- docs PostgreSQL Documentation — Data Types.
- docs PostgreSQL Documentation — Constraints.
- vídeo Schema design tips and tricks — Lukas Fittl (PGCon).
- vídeo Database Schema Design Patterns — Hussein Nasser.