O banco não sabia quanto o aluno tinha pago.
Um dev recebe a tarefa “criar as tabelas do módulo de cursos”. Abre o dbdiagram, desenha curso com uma coluna preco, liga aluno a curso e sobe a migration no mesmo dia.
Três meses depois, o financeiro pergunta quanto cada aluno pagou na Black Friday. A resposta honesta: ninguém sabe. O preço foi sobrescrito.
Não faltou SQL pra esse dev. Faltou o modelo entidade relacionamento, a parte em que você pergunta o que o negócio precisa lembrar, e por quanto tempo.
Vou levar um exemplo do requisito em texto até o DDL rodando no PostgreSQL. No caminho, separo o que os resumos costumam misturar e mostro quando o MER é a ferramenta errada.
Modelo entidade relacionamento não é tabela
Quem propôs foi Peter Chen, no artigo “The Entity-Relationship Model: Toward a Unified View of Data”, publicado na ACM Transactions on Database Systems em março de 1976 (vol. 1, n. 1, p. 9-36).
A ideia é descrever o mundo como entidades (coisas que o negócio distingue umas das outras) e relacionamentos entre elas. Antes de decidir como guardar qualquer coisa.
MER é o modelo. DER é o desenho dele. O próprio Chen chama de “entity-relationship diagram” a técnica gráfica da seção 3.1 do artigo.
A confusão mais cara é entre níveis. Carlos Heuser, em Projeto de Banco de Dados (o livro que boa parte das faculdades brasileiras usa), separa o projeto em modelo conceitual, modelo lógico e projeto físico. Traduzindo:
Conceitual. Entidades, relacionamentos, atributos e restrições de cardinalidade. Não existe tabela, foreign key nem tipo de dado. É aqui que o MER mora.
Lógico. O modelo traduzido pra um tipo de banco, normalmente o relacional. Aparecem tabelas, primary keys, foreign keys e tabelas associativas. A normalização acontece aqui.
Físico. Decisões do SGBD específico: tipos (bigint, tstzrange), índices, particionamento, extensões.
Se você leu que “o MER divide tabelas grandes”, ou viu alguém falar em teste de carga do MER, o texto misturou níveis. (Queria ver o load test de um losango.)
O conceitual se discute com produto. O físico, com o DBA.
Chen, pé-de-galinha e UML: cada um com sua armadilha
Chen. Retângulo pra conjunto de entidades, losango pra relacionamento, rótulos 1, N e M nas linhas. Nas figuras 10 e 11 do artigo de 1976 não tem elipse de atributo. Elas vieram depois, nos livros-texto. É a notação da aula de banco de dados e do brModelo.
Pé-de-galinha (Crow’s Foot). Caixas e linhas, com a cardinalidade na ponta: traço pra “um”, tridente pra “muitos”, círculo pra “zero”. É o que aparece no MySQL Workbench, no dbdiagram, no Mermaid e na maior parte da documentação de time.
UML. Multiplicidades como 1 e 0..* nas pontas das associações do diagrama de classes.
A armadilha é onde o número fica.
No pé-de-galinha e na UML, o símbolo perto de PEDIDO responde “quantos pedidos um cliente tem”. Você lê do lado oposto.
Na notação (min, max) de Elmasri & Navathe (Sistemas de Banco de Dados, cap. 3), o par fica do lado da própria entidade.
Resultado: o mesmo “(1,N)” quer dizer coisas opostas dependendo do livro. Confirme a convenção antes de revisar o diagrama. Senão vocês vão passar meia hora discutindo e concordando um com o outro.
Cardinalidade não é participação
Muito resumo por aí define cardinalidade como “quantas vezes uma entidade se relaciona com outra” e para em 1:1, 1:N e N:N.
Isso é metade da informação.
Elmasri & Navathe chamam as duas metades de razão de cardinalidade (o máximo: 1 ou N) e restrição de participação (o mínimo: total ou parcial). Heuser junta as duas no par cardinalidade mínima e máxima.
Chen, em 1976, só anotava o máximo. Na página 20 ele explica que, num relacionamento 1:n, um departamento pode ter n empregados, “n = 0, 1, 2…”. O zero já estava lá.
E por que isso importa? Porque cada metade vira uma coisa diferente no SQL.
“Todo pedido tem exatamente um cliente” (mínimo 1 do lado do pedido) vira cliente_id NOT NULL REFERENCES cliente. Fácil.
“Todo curso tem pelo menos um instrutor” (mínimo 1 do lado do “muitos”) não vira nenhuma constraint declarativa. Foreign key não garante que a outra tabela tem linha.
Ou a aplicação garante, ou um trigger garante, ou você aceita curso em rascunho sem instrutor.
Anotar o mínimo no modelo conceitual faz essa lacuna aparecer numa conversa de cinco minutos, e não como bug em produção.
Do requisito ao SQL: uma escola online
O requisito, do jeito que chegaria num card:
Uma escola online vende cursos. Cada curso tem um ou mais instrutores, e um instrutor pode dar vários cursos. O curso é dividido em aulas numeradas. Alunos se matriculam em cursos; a matrícula registra quando começou, quanto o aluno pagou e a situação. O sistema registra quais aulas cada aluno concluiu. O preço muda em promoções e o financeiro quer saber o preço vigente em qualquer data. O aluno pode cancelar a conta.
Passo 1: o modelo conceitual
Substantivo com identidade própria vira candidato a entidade: ALUNO, CURSO, INSTRUTOR, AULA. Verbo vira relacionamento. Com mínimo e máximo de cada lado, fica assim:
- CURSO (1,N) ministra (0,N) INSTRUTOR.
- CURSO contém AULA: uma aula pertence a exatamente um curso e é identificada pelo número dentro dele. “Aula 3” sozinha não identifica nada. AULA é uma entidade fraca, o mesmo caso do DEPENDENT de Chen, que usa a chave de EMPLOYEE como parte da própria chave.
- ALUNO matricula-se em CURSO: N:N, com atributos (data, valor pago, situação).
- Conclusão de aula: ela se liga ao ALUNO ou à MATRÍCULA? Se o aluno refizer o curso no futuro, o progresso pertence à matrícula. Esse é o tipo de pergunta que o modelo conceitual força e o código esconde.
Decidir o que tem identidade própria muda até o jeito como as pessoas chegam no produto.
Na Tikal Tech, no seuprocesso.com, cada processo e cada citação viraram uma página própria, indexável no Google. Quem procurava por um advogado, uma parte ou um processo caía numa dessas landing pages. A aquisição foi monstruosa.
Passo 2: as decisões que o diagrama não toma por você
N:N vira tabela associativa. curso_instrutor tem primary key composta (curso_id, instrutor_id), porque ninguém aponta pra ela.
Já matricula ganha id próprio, porque aula_concluida precisa referenciar ela. E mantém UNIQUE (aluno_id, curso_id) pra não perder a regra de negócio.
Surrogate key sem a unique da natural key é a origem clássica de matrícula duplicada.
Histórico é entidade, não coluna. “O preço muda” quer dizer que o preço tem vigência. Ele sai de curso e vai pra curso_preco, com um intervalo tstzrange.
Uma constraint EXCLUDE impede dois preços vigentes no mesmo instante pro mesmo curso. Um UNIQUE comum não consegue expressar isso.
Foi exatamente isso que faltou pro dev da Black Friday lá do começo.
Valor pago não é redundância. matricula.valor_pago_centavos parece duplicar o preço, mas registra outro fato: o que foi cobrado naquela venda, com cupom. Se o preço for corrigido amanhã, o valor pago não muda.
Desnormalização de verdade seria um contador aulas_concluidas em matricula, copiando algo que dá pra derivar. Só vale quando uma consulta medida estiver lenta. Medida, não “acho que vai ficar lenta”.
Soft delete tem custo. Cancelar a conta marca aluno.excluido_em. O e-mail precisa ser único só entre contas ativas: um partial unique index com WHERE excluido_em IS NULL.
E soft delete guarda o dado. Diante de um pedido de exclusão com base na LGPD, você vai ter que anonimizar nome e e-mail. Combina isso com o jurídico antes.
Consistência entre caminhos. aula_concluida aponta pra matrícula e pra aula. Sem cuidado, dá pra concluir uma aula de outro curso.
Carregar curso_id em duas foreign keys compostas obriga os dois caminhos a concordarem.
Passo 3: o diagrama (pé-de-galinha, nível lógico)
O código em Mermaid, pra colar no mermaid.live ou num Markdown do GitHub:
erDiagram
ALUNO ||--o{ MATRICULA : faz
CURSO ||--o{ MATRICULA : recebe
CURSO ||--|{ CURSO_INSTRUTOR : "é ministrado via"
INSTRUTOR ||--o{ CURSO_INSTRUTOR : ministra
CURSO ||--|{ AULA : contém
CURSO ||--o{ CURSO_PRECO : "tem preço em"
MATRICULA ||--o{ AULA_CONCLUIDA : registra
AULA ||--o{ AULA_CONCLUIDA : "concluída em"
ALUNO {
bigint id PK
text nome
text email "único entre ativos"
timestamptz excluido_em "soft delete"
}
CURSO {
bigint id PK
text titulo
text slug UK
}
INSTRUTOR {
bigint id PK
text nome
}
CURSO_INSTRUTOR {
bigint curso_id PK, FK
bigint instrutor_id PK, FK
}
AULA {
bigint curso_id PK, FK
int numero PK
text titulo
}
CURSO_PRECO {
bigint id PK
bigint curso_id FK
int valor_centavos
tstzrange vigencia "sem sobreposição"
}
MATRICULA {
bigint id PK
bigint aluno_id FK
bigint curso_id FK
int valor_pago_centavos
text status
}
AULA_CONCLUIDA {
bigint matricula_id PK, FK
bigint curso_id FK
int aula_numero PK, FK
timestamptz concluida_em
}
Repara no ||--|{ entre CURSO e CURSO_INSTRUTOR. Ele documenta o mínimo 1 que o banco sozinho não garante. O diagrama registra a regra, a aplicação cumpre.
Passo 4: o DDL no PostgreSQL
Testei no PostgreSQL 17. A extensão btree_gist vem no pacote padrão (contrib).
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE aluno (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nome text NOT NULL,
email text NOT NULL,
criado_em timestamptz NOT NULL DEFAULT now(),
excluido_em timestamptz
);
CREATE UNIQUE INDEX aluno_email_ativo_uk
ON aluno (lower(email)) WHERE excluido_em IS NULL;
CREATE TABLE instrutor (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nome text NOT NULL
);
CREATE TABLE curso (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
titulo text NOT NULL,
slug text NOT NULL UNIQUE
);
CREATE TABLE curso_instrutor (
curso_id bigint NOT NULL REFERENCES curso (id) ON DELETE CASCADE,
instrutor_id bigint NOT NULL REFERENCES instrutor (id) ON DELETE RESTRICT,
PRIMARY KEY (curso_id, instrutor_id)
);
CREATE INDEX curso_instrutor_instrutor_idx ON curso_instrutor (instrutor_id);
CREATE TABLE aula (
curso_id bigint NOT NULL REFERENCES curso (id) ON DELETE CASCADE,
numero integer NOT NULL CHECK (numero > 0),
titulo text NOT NULL,
PRIMARY KEY (curso_id, numero)
);
CREATE TABLE curso_preco (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
curso_id bigint NOT NULL REFERENCES curso (id) ON DELETE CASCADE,
valor_centavos integer NOT NULL CHECK (valor_centavos >= 0),
vigencia tstzrange NOT NULL CHECK (NOT isempty(vigencia)),
EXCLUDE USING gist (curso_id WITH =, vigencia WITH &&)
);
CREATE TABLE matricula (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
aluno_id bigint NOT NULL REFERENCES aluno (id),
curso_id bigint NOT NULL REFERENCES curso (id),
matriculado_em timestamptz NOT NULL DEFAULT now(),
valor_pago_centavos integer NOT NULL CHECK (valor_pago_centavos >= 0),
status text NOT NULL DEFAULT 'ativa'
CHECK (status IN ('ativa', 'cancelada', 'concluida')),
UNIQUE (aluno_id, curso_id),
UNIQUE (id, curso_id)
);
CREATE INDEX matricula_curso_idx ON matricula (curso_id);
CREATE TABLE aula_concluida (
matricula_id bigint NOT NULL,
curso_id bigint NOT NULL,
aula_numero integer NOT NULL,
concluida_em timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (matricula_id, aula_numero),
FOREIGN KEY (matricula_id, curso_id) REFERENCES matricula (id, curso_id) ON DELETE CASCADE,
FOREIGN KEY (curso_id, aula_numero) REFERENCES aula (curso_id, numero) ON DELETE CASCADE
);
-- Preço vigente numa data
SELECT valor_centavos
FROM curso_preco
WHERE curso_id = 2
AND vigencia @> timestamptz '2026-11-28 10:00-03';
Repara nos ON DELETE. Apagar um curso leva junto aulas e preços (CASCADE), mas é bloqueado se tiver matrícula (padrão NO ACTION).
Integridade referencial inclui bloquear e propagar. Qual dos dois cada FK faz é regra de negócio.
Quando o MER é a ferramenta errada
O MER parte de duas premissas: o valor está nas relações entre as coisas, e você vai consultar por caminhos variados. Nem sempre é verdade.
Banco de documentos. Martin Kleppmann, no capítulo 2 de Designing Data-Intensive Applications, usa um currículo do LinkedIn como exemplo. Dado em árvore, de um-para-muitos, lido inteiro, cabe bem num documento JSON.
Quando aparecem muitos-para-um e muitos-para-muitos (região, empresa, recomendação entre usuários), voltam as referências e os joins.
Event sourcing. A fonte da verdade é o log de eventos (MatriculaRealizada, PrecoAlterado), não o estado atual. O MER continua útil pras projeções de leitura. Mas desenhar só o estado esconde justamente o que o sistema guarda.
Analytics. Num data warehouse, a modelagem dimensional de Kimball (fatos e dimensões) responde melhor a agregação.
Schema errado não se refatora numa sprint
Código ruim se refatora numa sprint. Schema errado com dois anos de dado exige migração. Backfill. Reunião com os times que leem aquela tabela.
Por isso “preço tem histórico” ou “cancelamento é soft delete” são decisões da mesma categoria que a escolha do banco. E quem conduz isso no time é o Tech Lead.
Decisão sobre onde guardar dado também chega na fatura. Eu aprendi do jeito caro.
Numa empresa minha, ainda pequena (não devíamos estar nem em 10 pessoas), pedi pra um engenheiro júnior excelente subir mais de 300 GB de dados pro ElasticSearch da AWS.
Era umas 9 horas da noite quando ele me perguntou se podia pegar uma instância maior. Claro que eu disse pra ele mandar bala.
Foi aí a minha cagada.
Ele não só aumentou a instância. Pra subir mais rápido, subiu o serviço pra 10 instâncias.
Eu não vi.
Dias depois, um consultor da AWS me liga do nada. Quando abri o cost explorer, estava lá: R$ 50 mil de ElasticSearch. Uma conta 10x maior que o normal.
O erro foi meu. Deixei uma decisão de custo com alguém que via aquelas opções pela primeira vez.
Conduzir esse tipo de decisão, pra mim, é:
- Exigir o modelo conceitual (nem que seja um Mermaid de dez linhas) no PR que cria tabela, antes de revisar o SQL.
- Registrar as decisões não óbvias num ADR (Architecture Decision Record). “Usamos
tstzrangecomEXCLUDEem vez dedata_inicio/data_fimporque…” é meia página que poupa uma discussão daqui a um ano. - Pedir revisão de quem vai consumir os dados (financeiro, BI, outro time) antes do merge, não depois do incidente.
- Olhar a Task Relevant Maturity de quem recebe cada decisão. Essa aprendi com o Andy Grove.
O passo a passo pra conduzir esse tipo de escolha com o time está em Decisões de arquitetura: framework prático para tech leads. Pro nível lógico, o guia de normalização de banco de dados completa este texto.
Antes de criar a próxima tabela
- Escrevi o requisito em texto e marquei substantivos e verbos?
- Anotei mínimo e máximo dos dois lados de cada relacionamento, e sei quais mínimos o banco não garante?
- Algum atributo muda com o tempo e alguém vai perguntar pelo valor antigo?
- Cada tabela associativa com surrogate key tem a unique da natural key?
- A estratégia de exclusão está decidida, inclusive pra pedido de LGPD?
- As decisões não óbvias estão num ADR?
Fazer essas perguntas antes do merge é trabalho de liderança técnica, mesmo quando o card só diz “criar as tabelas”.
É isso que eu ensino no First Lead, meu curso de liderança técnica, com os três pilares: técnico, negócios e pessoas. Os planos começam a partir de R$ 97.
E você: qual foi a coluna que mais se arrependeu de ter criado? Conta nos comentários.