Use Cases (Atualizado: 02/06/2026)

Design de banco de dados com Claude Code: workflow prático de revisão

Use Claude Code para revisar design de banco de dados: tabelas, constraints, índices, migrações e rollback.

Design de banco de dados com Claude Code: workflow prático de revisão

Design de banco de dados não é apenas escolher nomes de tabelas. Você decide quais fatos ficam juntos, quais colunas não podem ser vazias, quais valores precisam ser únicos, como as linhas se relacionam e se as consultas continuarão rápidas quando o produto crescer. Um schema fraco pode passar na primeira demo e depois gerar contas duplicadas, billing inconsistente, dashboards lentos ou migrações sem rollback seguro.

Claude Code ajuda muito nessa etapa, mas não deve ser tratado como fonte de verdade. Use como assistente de schema e revisor. Ele pode criar uma proposta inicial, listar riscos, escrever SQL de teste e questionar suposições. A decisão final precisa continuar com quem conhece as regras do produto, as consultas reais, a documentação oficial e a operação.

Para conectar o fluxo, leia também automação de migração de banco de dados e Prisma ORM com Claude Code. Para validar decisões, consulte a documentação oficial de constraints no PostgreSQL, índices no PostgreSQL e OWASP Top 10 quando houver dados sensíveis.

Fundamentos sem complicar

Tabela é um conjunto de registros do mesmo tipo, como users, teams e projects. Coluna é um campo do registro, como email, status e created_at. Chave primária identifica uma linha. Chave estrangeira garante que uma linha aponte para um pai existente. Constraint única impede duplicidade de valores de negócio, como email, slug dentro de um time ou ID de cliente no provedor de pagamento. Índice acelera leituras comuns. Migração é o script repetível que altera o schema, e rollback é o caminho de volta quando a release dá errado.

O erro comum é deixar essas regras só na aplicação. Validação de formulário é ótima para UX, mas não basta contra requisições concorrentes ou jobs batch. O banco precisa ser a última barreira. Claude Code é útil quando você pede para ele encontrar as barreiras que ainda faltam.

claude -p "Read docs/database/schema.sql and migrations/*.sql.
Act as a database design reviewer, not as the owner of truth.

Check for:
- Missing primary keys, foreign keys, unique constraints, and NOT NULL rules
- status or role columns that are too loose
- Indexes needed for WHERE, JOIN, and ORDER BY patterns
- Destructive migrations and rollback gaps
- PII leaking into audit logs or search/reporting tables
- API shapes that may trigger N+1 queries

Return findings first. Include why it matters, how to verify it, and only then suggest a minimal patch."

Quatro casos de uso concretos

O primeiro caso é um SaaS com usuários, times e projetos. Um usuário pode participar de vários times, e cada time tem projetos. O slug de projeto costuma ser único dentro do time, não no produto inteiro. Por isso UNIQUE (team_id, slug) expressa melhor a regra.

O segundo caso é audit log. Você quer saber quem alterou o quê e quando, mas uma tabela de auditoria não pode virar depósito de JSON. Colocar email, telefone ou endereço em metadata parece prático, porém complica retenção, acesso e pedidos de remoção.

O terceiro caso é billing e subscription. O ID de cliente do provedor não pode repetir, e um time não deveria ter duas assinaturas ativas. Ao mesmo tempo, o histórico cancelado precisa continuar consultável.

O quarto caso é search ou reporting. Uma tabela otimizada para leitura evita JOINs pesados em cada tela. O preço é desenhar atualização, índices e verificação para não exibir dados antigos.

DDL PostgreSQL executável

O SQL abaixo recria apenas um schema de demonstração. Dá para colar em um PostgreSQL local sem mexer em outros schemas.

DROP SCHEMA IF EXISTS claude_db_design_demo CASCADE;
CREATE SCHEMA claude_db_design_demo;
SET search_path = claude_db_design_demo;

CREATE TABLE users (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email TEXT NOT NULL,
  display_name TEXT NOT NULL,
  password_hash TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT users_email_unique UNIQUE (email),
  CONSTRAINT users_email_has_at CHECK (position('@' in email) > 1)
);

CREATE TABLE teams (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name TEXT NOT NULL,
  slug TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT teams_slug_unique UNIQUE (slug)
);

CREATE TABLE team_members (
  team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
  user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
  joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (team_id, user_id)
);

CREATE TABLE projects (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  slug TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
  created_by BIGINT NOT NULL REFERENCES users(id),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT projects_team_slug_unique UNIQUE (team_id, slug)
);

CREATE TABLE audit_logs (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
  actor_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
  action TEXT NOT NULL,
  entity_type TEXT NOT NULL,
  entity_id TEXT NOT NULL,
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT audit_action_not_blank CHECK (length(action) > 0),
  CONSTRAINT audit_metadata_no_email CHECK (
    metadata::text !~* '[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}'
  )
);

CREATE TABLE subscriptions (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
  provider TEXT NOT NULL CHECK (provider IN ('stripe', 'manual')),
  provider_customer_id TEXT NOT NULL,
  plan_code TEXT NOT NULL CHECK (plan_code IN ('free', 'pro', 'business')),
  status TEXT NOT NULL CHECK (status IN ('trialing', 'active', 'past_due', 'canceled')),
  current_period_end DATE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT subscriptions_provider_customer_unique UNIQUE (provider, provider_customer_id)
);

CREATE TABLE project_search_documents (
  project_id BIGINT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
  team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  search_text TEXT NOT NULL,
  refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX subscriptions_one_open_per_team
  ON subscriptions(team_id)
  WHERE status IN ('trialing', 'active', 'past_due');

CREATE INDEX idx_team_members_user_id ON team_members(user_id);
CREATE INDEX idx_projects_team_status_created ON projects(team_id, status, created_at DESC);
CREATE INDEX idx_audit_logs_team_created ON audit_logs(team_id, created_at DESC);
CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id);
CREATE INDEX idx_project_search_documents_search
  ON project_search_documents USING GIN (to_tsvector('simple', search_text));

Seed data e testes de constraint

Um schema bom precisa provar que os dados corretos entram e os dados errados são bloqueados. Os blocos DO transformam falhas esperadas em mensagens claras.

SET search_path = claude_db_design_demo;

INSERT INTO users (email, display_name, password_hash) VALUES
  ('[email protected]', 'Masa', '$2b$demo-hash-1'),
  ('[email protected]', 'Aiko', '$2b$demo-hash-2'),
  ('[email protected]', 'Ren', '$2b$demo-hash-3');

INSERT INTO teams (name, slug) VALUES
  ('ClaudeCodeLab', 'clab'),
  ('Northstar Apps', 'northstar');

INSERT INTO team_members (team_id, user_id, role) VALUES
  (1, 1, 'owner'),
  (1, 2, 'admin'),
  (2, 3, 'owner');

INSERT INTO projects (team_id, name, slug, status, created_by) VALUES
  (1, 'Schema Review', 'schema-review', 'active', 1),
  (1, 'Billing Console', 'billing-console', 'active', 2),
  (2, 'Customer Portal', 'customer-portal', 'active', 3);

INSERT INTO audit_logs (team_id, actor_user_id, action, entity_type, entity_id, metadata) VALUES
  (1, 1, 'project.created', 'project', '1', '{"project_slug":"schema-review"}'::jsonb),
  (1, 2, 'billing.plan_changed', 'subscription', '1', '{"from":"free","to":"pro"}'::jsonb);

INSERT INTO subscriptions (team_id, provider, provider_customer_id, plan_code, status, current_period_end) VALUES
  (1, 'stripe', 'cus_demo_1', 'pro', 'active', DATE '2026-07-31'),
  (2, 'manual', 'manual_northstar_1', 'free', 'canceled', NULL);

INSERT INTO project_search_documents (project_id, team_id, title, search_text)
SELECT
  id,
  team_id,
  name,
  name || ' project notes for database design, billing, audit, and reporting.'
FROM projects;
SET search_path = claude_db_design_demo;

DO $$
BEGIN
  INSERT INTO users (email, display_name, password_hash)
  VALUES ('[email protected]', 'Duplicate Masa', '$2b$demo-hash-x');
EXCEPTION WHEN unique_violation THEN
  RAISE NOTICE 'OK duplicate email blocked: %', SQLERRM;
END $$;

DO $$
BEGIN
  INSERT INTO projects (team_id, name, slug, created_by)
  VALUES (1, NULL, 'bad-null', 1);
EXCEPTION WHEN not_null_violation THEN
  RAISE NOTICE 'OK NULL project name blocked: %', SQLERRM;
END $$;

DO $$
BEGIN
  INSERT INTO team_members (team_id, user_id, role)
  VALUES (999, 1, 'member');
EXCEPTION WHEN foreign_key_violation THEN
  RAISE NOTICE 'OK missing team blocked: %', SQLERRM;
END $$;

DO $$
BEGIN
  INSERT INTO projects (team_id, name, slug, created_by)
  VALUES (1, 'Duplicate Slug', 'schema-review', 1);
EXCEPTION WHEN unique_violation THEN
  RAISE NOTICE 'OK duplicate team slug blocked: %', SQLERRM;
END $$;

DO $$
BEGIN
  INSERT INTO audit_logs (team_id, actor_user_id, action, entity_type, entity_id, metadata)
  VALUES (1, 1, 'project.invited', 'project', '1', '{"email":"[email protected]"}'::jsonb);
EXCEPTION WHEN check_violation THEN
  RAISE NOTICE 'OK email in audit metadata blocked: %', SQLERRM;
END $$;

DO $$
BEGIN
  INSERT INTO subscriptions (team_id, provider, provider_customer_id, plan_code, status, current_period_end)
  VALUES (1, 'stripe', 'cus_demo_2', 'business', 'active', DATE '2026-08-31');
EXCEPTION WHEN unique_violation THEN
  RAISE NOTICE 'OK second open subscription blocked: %', SQLERRM;
END $$;

SELECT p.id, p.name, count(a.id) AS audit_events
FROM projects p
LEFT JOIN audit_logs a
  ON a.entity_type = 'project'
 AND a.entity_id = p.id::text
WHERE p.team_id = 1
GROUP BY p.id, p.name
ORDER BY audit_events DESC, p.id;

SELECT project_id, title
FROM project_search_documents
WHERE to_tsvector('simple', search_text) @@ plainto_tsquery('simple', 'billing');

EXPLAIN (COSTS OFF)
SELECT *
FROM audit_logs
WHERE team_id = 1
ORDER BY created_at DESC
LIMIT 20;

Armadilhas comuns

A primeira armadilha é falta de constraint única. Email, conta OAuth, customer ID e slug por time devem ser protegidos no banco. A segunda é permitir NULL sem motivo. Se projects.name, team_members.role ou subscriptions.status puderem ficar vazios, interface, permissões e billing começam a adivinhar.

Também revise migrações destrutivas. DROP COLUMN, mudança de tipo, NOT NULL em dados existentes e default em tabela gigante podem travar produção ou perder dados. Peça a Claude Code um plano expand-and-contract e valide manualmente. Outro risco é N+1: uma lista de 100 projetos que busca contagem de auditoria projeto por projeto vira 101 queries. Por fim, cuidado com PII em logs, search e reporting.

Checklist de migração e rollback

# Migration checklist: add projects.archived_at

## Design
- [ ] Why this column is needed is written in the issue or PR.
- [ ] Existing rows and backfill behavior are defined.
- [ ] NOT NULL, default value, and CHECK constraint choices are explained.
- [ ] Index impact was checked against real WHERE / ORDER BY queries.

## Safety
- [ ] This migration does not drop data in the first deploy.
- [ ] Large-table locks were considered.
- [ ] The application can run during the expand-and-contract period.
- [ ] Backfill can be retried safely.

## Up SQL
    ALTER TABLE projects ADD COLUMN archived_at TIMESTAMPTZ;
    UPDATE projects
    SET archived_at = now()
    WHERE status = 'archived' AND archived_at IS NULL;
    ALTER TABLE projects
      ADD CONSTRAINT projects_archived_at_matches_status
      CHECK (
        (status = 'archived' AND archived_at IS NOT NULL)
        OR (status = 'active' AND archived_at IS NULL)
      ) NOT VALID;
    ALTER TABLE projects VALIDATE CONSTRAINT projects_archived_at_matches_status;

## Down SQL
    ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_archived_at_matches_status;
    ALTER TABLE projects DROP COLUMN IF EXISTS archived_at;

## Verification
- [ ] Constraint test SQL was run locally.
- [ ] Read queries still use expected indexes.
- [ ] Rollback was reviewed, including data-loss notes.
- [ ] Claude Code findings were reviewed by a human owner.

Leia o rollback com rigor. Um down migration pode restaurar a forma da tabela, mas não recuperar dados apagados. Se a reversão não for completa, escreva isso no PR e prepare backup, rollout gradual ou recuperação.

Diagrama para compartilhar

erDiagram
    users ||--o{ team_members : joins
    teams ||--o{ team_members : has
    teams ||--o{ projects : owns
    users ||--o{ projects : creates
    teams ||--o{ audit_logs : records
    users ||--o{ audit_logs : acts
    teams ||--o{ subscriptions : pays
    projects ||--|| project_search_documents : indexes

O diagrama ajuda a conversa, mas não substitui DDL, migrations e testes SQL. Relações bonitas sem constraints ainda deixam dados quebrados entrar.

CTA e resultado prático

Se o time vai usar Claude Code para design de banco, escreva as regras no CLAUDE.md: não conectar em produção, dividir migrações destrutivas, exigir testes de constraint e não gravar PII em logs sem aprovação. ClaudeCodeLab ajuda com adoção de Claude Code, templates de review, checklists de PR e treinamento usando repositórios reais. Para aplicar isso em um produto existente, comece pela página de treinamento e consultoria Claude Code. Para estudo individual, veja a cheatsheet gratuita e os templates de produtos.

Quando Masa testou esse fluxo, o maior ganho não foi o primeiro ER diagram. Foram os INSERTs que deveriam falhar: email duplicado, nome de projeto nulo, time inexistente, slug repetido, email no audit log e segunda assinatura ativa. Claude Code acelerou o checklist, mas a confiança veio de ver o PostgreSQL rejeitar dados ruins.

#claude-code #database #prisma #sql
Grátis

PDF grátis: cheatsheet do Claude Code

Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.

Cuidamos dos seus dados e não enviamos spam.

Masa

Sobre o autor

Masa

Engenheiro focado em workflows práticos com Claude Code.