DevGuideTK
← Trilha Backend

Modelagem e normalização

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

O esquema desta página foi criado num Postgres 18, e o código foi executado nas cinco linguagens contra ele.

O esquema da loja

CREATE TABLE clientes (
  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email     text NOT NULL,
  nome      text NOT NULL CHECK (length(trim(nome)) > 0),
  criado_em timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX clientes_email_unico ON clientes (lower(email));

CREATE TABLE produtos (
  id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  nome  text NOT NULL,
  preco numeric(12, 2) NOT NULL CHECK (preco >= 0)
);

CREATE TABLE pedidos (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  cliente_id bigint NOT NULL REFERENCES clientes (id),
  status     text NOT NULL DEFAULT 'aberto'
             CHECK (status IN ('aberto', 'pago', 'enviado', 'cancelado')),
  criado_em  timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON pedidos (cliente_id); -- o Postgres não cria índice para a chave estrangeira

-- Muitos para muitos: um pedido tem vários produtos, um produto está em vários pedidos
CREATE TABLE itens_pedido (
  pedido_id      bigint NOT NULL REFERENCES pedidos (id) ON DELETE CASCADE,
  produto_id     bigint NOT NULL REFERENCES produtos (id),
  quantidade     int NOT NULL CHECK (quantidade > 0),
  preco_unitario numeric(12, 2) NOT NULL, -- o preço na hora da compra, de propósito
  PRIMARY KEY (pedido_id, produto_id)
);
CREATE INDEX ON itens_pedido (produto_id);

Repare nas decisões do nível Soluções aplicadas:

  • bigint gerado pelo banco como chave primária, e o e-mail como UNIQUE, e não como chave.
  • Um índice único em lower(email), para Ana@Exemplo.com e ana@exemplo.com serem o mesmo cliente.
  • numeric(12, 2) para dinheiro e timestamptz para datas.
  • CHECK na lista de status, no preço e na quantidade.
  • Índices nas chaves estrangeiras pedidos.cliente_id e itens_pedido.produto_id. A chave primária composta de itens_pedido já serve às buscas por pedido_id, porque ele é a primeira coluna.
  • ON DELETE CASCADE só nos itens, que não existem sem o pedido. Um cliente com pedidos não pode ser apagado.
  • preco_unitario repetido no item, de propósito.

Traduzir a recusa do banco, em cada linguagem

O banco recusa o que viola as regras. A aplicação traduz cada recusa numa resposta: o e-mail repetido vira um 409, o dado inválido vira um 422. O Postgres informa o tipo da recusa por um código (SQLSTATE) e o nome da restrição.

import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

type Resultado =
  | { tipo: "criado"; id: number }
  | { tipo: "conflito" | "invalido" | "referencia_inexistente"; detalhe: string };

// O banco garante as regras; a aplicação traduz a recusa numa resposta
function traduzir(erro: any): Resultado {
  switch (erro.code) {
    case "23505": return { tipo: "conflito", detalhe: erro.constraint }; // UNIQUE
    case "23514": return { tipo: "invalido", detalhe: erro.constraint }; // CHECK
    case "23503": // FOREIGN KEY
      return { tipo: "referencia_inexistente", detalhe: erro.constraint };
    default: throw erro;
  }
}

async function cadastrarCliente(email: string, nome: string): Promise<Resultado> {
  try {
    const { rows } = await pool.query(
      "INSERT INTO clientes (email, nome) VALUES ($1, $2) RETURNING id", [email, nome]);
    return { tipo: "criado", id: Number(rows[0].id) };
  } catch (erro) {
    return traduzir(erro);
  }
}

async function abrirPedido(clienteId: number): Promise<Resultado> {
  try {
    const { rows } = await pool.query(
      "INSERT INTO pedidos (cliente_id) VALUES ($1) RETURNING id", [clienteId]);
    return { tipo: "criado", id: Number(rows[0].id) };
  } catch (erro) {
    return traduzir(erro);
  }
}

console.log(await cadastrarCliente("ana@exemplo.com", "Ana"));
console.log(await cadastrarCliente("ANA@exemplo.com", "Ana Maria")); // mesmo e-mail
console.log(await cadastrarCliente("bia@exemplo.com", "   "));       // nome vazio
console.log(await abrirPedido(999_999));                             // cliente inexistente
await pool.end();

O resultado, nas cinco linguagens

OperaçãoEsperadoNas cinco
Cadastrar ana@exemplo.comcriado, id 1criado, id 1
Cadastrar ANA@exemplo.comconflito em clientes_email_unicoconflito em clientes_email_unico
Cadastrar com nome em brancoinválido em clientes_nome_checkinválido em clientes_nome_check
Abrir pedido do cliente 999999referência inexistente em pedidos_cliente_id_fkeyreferência inexistente em pedidos_cliente_id_fkey

O nome da restrição é o que permite uma mensagem precisa: clientes_email_unico vira "este e-mail já está cadastrado". Dê nomes claros às restrições que a aplicação vai traduzir, como o índice clientes_email_unico do esquema. As outras receberam o nome automático do Postgres (clientes_nome_check, pedidos_cliente_id_fkey).

O que muda de uma linguagem para outra

DriverComo identificar a recusaNome da restrição
TypeScriptpgerro.code, o SQLSTATEerro.constraint
Pythonpsycopg 3Classes como errors.UniqueViolationerro.diag.constraint_name
Gopgxerrors.As para *pgconn.PgError e CodeConstraintName
JavaJDBC, driver postgresqlgetSQLState()PSQLException.getServerErrorMessage().getConstraint()
C#NpgsqlPostgresException.SqlState e PostgresErrorCodesConstraintName

Lista de revisão

Estrutura

  • Cada tabela representa uma coisa, e cada fato fica num lugar só?
  • Os muitos para muitos têm uma tabela no meio, com a chave primária composta?
  • As cópias de dados (como o preço no item) têm motivo e fonte da verdade claros?

Chaves

  • A chave primária é bigint gerado, ou UUID v7 quando o identificador nasce fora do banco?
  • Toda chave estrangeira tem índice?
  • A ação de exclusão (RESTRICT, CASCADE, SET NULL) de cada chave estrangeira foi escolhida, e não deixada no padrão por acaso?

Regras

  • Campos obrigatórios têm NOT NULL?
  • A unicidade está no banco, inclusive sem diferenciar maiúsculas quando for o caso?
  • Listas fechadas e faixas de valores têm CHECK?
  • As restrições que a aplicação traduz têm nomes claros?

Tipos

  • Dinheiro em numeric ou centavos inteiros, nunca ponto flutuante?
  • Instantes em timestamptz, e datas sem hora em date?
  • Listas em tabelas filhas, e jsonb só para o que varia de fato?

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.

  • Escrever o esquema e as migrações

    Quero transformar o modelo do meu sistema em SQL e em migrações.
    
    Pergunte qual banco, linguagem e ferramenta de migrações eu uso, e me peça o modelo ou a descrição das entidades.
    
    Vamos em etapas:
    1. As tabelas com chaves primárias e tipos.
    2. As chaves estrangeiras com a ação de exclusão e os índices.
    3. As restrições UNIQUE, inclusive com expressão, e CHECK, com nomes claros.
    4. Dados de exemplo que provem que cada restrição recusa o que deve.
    5. Uma migração para cada etapa, na ferramenta que eu uso.
  • Traduzir as recusas do banco na minha API

    Quero que a minha API traduza as recusas do banco (chave única, CHECK, chave estrangeira) em respostas claras.
    
    Pergunte qual linguagem, framework, driver e banco eu uso.
    
    Explique, com código:
    1. Como identificar o tipo da recusa pelo SQLSTATE e o nome da restrição no meu driver.
    2. Um mapa de nomes de restrição para mensagens e status HTTP.
    3. Onde fazer essa tradução, num só lugar.
    4. Como lidar com a transação abortada e com savepoints.
    5. Testes que provocam cada recusa.
  • Revisar um modelo de dados

    Vou colar abaixo o SQL das tabelas de um sistema (ou as entidades do ORM). Revise o modelo.
    
    Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o trecho:
    1. Cada fato fica num lugar só? Há cópias sem motivo ou sem fonte da verdade?
    2. As chaves primárias e estrangeiras estão definidas, e toda chave estrangeira tem índice?
    3. As ações de exclusão fazem sentido?
    4. Há NOT NULL, UNIQUE e CHECK onde as regras exigem?
    5. Dinheiro, datas e listas usam os tipos certos?
    6. jsonb ou texto livre está sendo usado no lugar de tabelas?
    
    Para cada problema, descreva o efeito em produção e mostre a correção.
    
    SQL ou entidades:
    [cole aqui]