Entregue apps gerados por IA com mais segurança confiando nas restrições do PostgreSQL (NOT NULL, CHECK, UNIQUE e FOREIGN KEY) antes do código e dos testes.

Código gerado por IA frequentemente parece correto porque lida com o caminho feliz. Apps reais quebram no meio confuso: um formulário envia uma string vazia em vez de null, um job em background tenta de novo e cria o mesmo registro duas vezes, ou um delete remove uma linha pai e deixa filhos órfãos. Esses não são bugs exóticos. Aparecem como campos obrigatórios em branco, valores “únicos” duplicados e linhas órfãs que apontam para lugar nenhum.
Eles também escapam da revisão de código e de testes básicos por um motivo simples: revisores leem a intenção, não todos os casos de borda. Testes normalmente cobrem alguns exemplos típicos, não semanas de comportamento real do usuário, importações de CSV, retries por rede instável ou requisições concorrentes. Se um assistente gerou o código, ele pode não checar pequenas mas críticas verificações como remover espaços, validar intervalos ou proteger contra condições de corrida.
“Restrições primeiro, código depois” significa que você coloca regras inegociáveis no banco de dados para que dados ruins não possam ser armazenados, não importa qual caminho de código tente escrevê-los. Sua aplicação ainda deve validar entrada para mensagens de erro melhores, mas o banco impõe a verdade. É aí que as restrições do PostgreSQL brilham: elas protegem você de categorias inteiras de erros.
Um exemplo rápido: imagine um CRM pequeno. Um script de importação gerado por IA cria contatos. Uma linha tem email de "" (vazio), duas linhas repetem o mesmo email com diferenças de caixa, e um contato referencia um account_id que não existe porque a conta foi deletada em outro processo. Sem restrições, tudo isso pode chegar em produção e quebrar relatórios depois.
Com as regras certas no banco, essas gravações falham imediatamente, perto da fonte. Campos obrigatórios não podem faltar, duplicatas não entram durante retries, relacionamentos não podem apontar para registros deletados ou inexistentes, e valores não podem ficar fora de intervalos permitidos.
Restrições não evitam todo bug. Elas não consertam uma UI confusa, um cálculo de desconto errado, ou uma query lenta. Mas impedem que dados ruins se acumulem silenciosamente — que é onde frequentemente os “bugs de casos extremos gerados por IA” ficam caros.
Sua aplicação raramente é uma base de código falando com um único usuário. Um produto típico tem UI web, app móvel, telas administrativas, jobs em background, importações de CSV e, às vezes, integrações de terceiros. Cada caminho pode criar ou mudar dados. Se cada caminho tiver que lembrar das mesmas regras, um vai esquecer.
O banco é o lugar que todos compartilham. Quando você o trata como o guarda-final, as regras se aplicam a tudo automaticamente. Restrições do PostgreSQL transformam “assumimos que isso é sempre verdadeiro” em “isso deve ser verdadeiro, ou a gravação falha.”
Código gerado por IA torna isso ainda mais importante. Um modelo pode adicionar validação num formulário React mas perder um caso de borda num job de background. Ou pode lidar bem com dados do caminho feliz e quebrar quando um cliente real insere algo inesperado. Restrições capturam problemas no momento em que dados ruins tentam entrar, não semanas depois quando você está depurando relatórios estranhos.
Quando você pula restrições, dados ruins costumam ser silenciosos. O save é bem-sucedido, a aplicação segue, e o problema aparece depois como um ticket de suporte, uma divergência de cobrança ou um dashboard que ninguém confia. Limpar isso é caro porque você está consertando histórico, não apenas uma requisição.
Dados ruins costumam entrar por situações do dia a dia: uma nova versão do cliente envia um campo vazio em vez de ausente, um retry cria duplicatas, uma edição administrativa ignora checagens da UI, um arquivo de importação tem formatação inconsistente, ou dois usuários atualizam registros relacionados ao mesmo tempo.
Um modelo mental útil: aceite dados apenas se forem válidos na fronteira. Na prática, essa fronteira deve incluir o banco de dados, porque o banco vê todas as gravações.
NOT NULL é a restrição PostgreSQL mais simples e previne uma classe surpreendentemente grande de bugs. Se um valor precisa existir para a linha fazer sentido, faça o banco aplicar isso.
NOT NULL costuma ser correto para identificadores, nomes obrigatórios e timestamps. Se você não consegue criar um registro válido sem ele, não permita que fique vazio. Num CRM pequeno, um lead sem dono ou sem tempo de criação não é um “lead parcial”. É dado quebrado que causará comportamentos estranhos depois.
NULL aparece com mais facilidade em código gerado por IA porque é fácil criar caminhos “opcionais” sem perceber. Um campo pode ser opcional na UI, uma API pode aceitar uma chave ausente, e um ramo de uma função de criação pode pular a atribuição. Tudo compila e o teste do caminho feliz passa. Aí usuários reais importam um CSV com células vazias, ou um cliente móvel manda um payload diferente, e NULL aparece no banco.
Um bom padrão é combinar NOT NULL com um default sensato para campos que o sistema controla:
created_at TIMESTAMP NOT NULL DEFAULT now()status TEXT NOT NULL DEFAULT 'new'is_active BOOLEAN NOT NULL DEFAULT trueDefaults nem sempre são vantajosos. Não defina valores padrão para campos fornecidos pelo usuário como email ou company_name apenas para satisfazer NOT NULL. Uma string vazia não é “mais válida” que NULL. Ela apenas esconde o problema.
Quando tiver dúvida, decida se o valor é realmente desconhecido ou se representa um estado diferente. Se “ainda não fornecido” for significativo, considere uma coluna de estado separada em vez de permitir NULL em todo lugar. Por exemplo, mantenha phone nullable, mas adicione phone_status como missing, requested ou verified. Isso mantém o significado consistente em seu código.
Uma restrição CHECK é uma promessa que sua tabela faz: toda linha deve satisfazer uma regra, sempre. É uma das maneiras mais fáceis de evitar que casos de borda criem registros que parecem ok no código mas não fazem sentido na vida real.
CHECK funciona melhor para regras que dependem apenas de valores na mesma linha: intervalos numéricos, valores permitidos e relacionamentos simples entre colunas.
-- 1) Totals should never be negative
ALTER TABLE invoices
ADD CONSTRAINT invoices_total_nonnegative
CHECK (total_cents >= 0);
-- 2) Enum-like allowed values without adding a custom type
ALTER TABLE tickets
ADD CONSTRAINT tickets_status_allowed
CHECK (status IN ('new', 'open', 'waiting', 'closed'));
-- 3) Date order rules
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_date_order
CHECK (end_date IS NULL OR end_date >= start_date);
Um bom CHECK é legível de relance. Trate-o como documentação para seus dados. Prefira expressões curtas, nomes claros para as constraints e padrões previsíveis.
CHECK não é a ferramenta certa para tudo. Se uma regra precisa consultar outras linhas, agregar dados ou comparar entre tabelas (por exemplo, “uma conta não pode exceder o limite do seu plano”), mantenha essa lógica no código da aplicação, em triggers ou em um job controlado em background.
Uma regra UNIQUE é simples: o banco se recusa a armazenar duas linhas com o mesmo valor na coluna restringida (ou com a mesma combinação de valores em várias colunas). Isso elimina toda uma classe de bugs em que um caminho de criação roda duas vezes, um retry acontece, ou dois usuários submetem a mesma coisa ao mesmo tempo.
UNIQUE garante que não haja duplicatas para os valores exatos que você define. Não garante que o valor esteja presente (NOT NULL), que siga um formato (CHECK) ou que corresponda à sua ideia de igualdade (caixa, espaços, pontuação) a menos que você o defina.
Lugares comuns onde você provavelmente quer unicidade incluem email na tabela de usuários, external_id de outro sistema, ou um nome que deve ser único dentro de uma conta, como (account_id, name).
Um detalhe: NULL e UNIQUE. No PostgreSQL, NULL é tratado como “desconhecido”, então múltiplos NULLs são permitidos sob uma restrição UNIQUE. Se você quer “o valor deve existir e ser único”, combine UNIQUE com NOT NULL.
Um padrão prático para identificadores visíveis ao usuário é unicidade case-insensitive. Pessoas vão digitar “[email protected]” e depois “[email protected]” e esperar que sejam o mesmo.
-- Case-insensitive unique email
CREATE UNIQUE INDEX users_email_unique_ci
ON users (lower(email));
-- Unique contact name per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_name_unique UNIQUE (account_id, name);
Defina o que “duplicado” significa para seus usuários (caixa, espaço em branco, por conta vs global), e então codifique isso uma vez para que todo caminho de código siga a mesma regra.
Uma FOREIGN KEY diz: “essa linha deve apontar para uma linha real ali.” Sem ela, o código pode criar silenciosamente registros órfãos que parecem válidos isoladamente, mas quebram a aplicação depois. Por exemplo: uma nota que referencia um cliente que foi deletado, ou uma fatura que aponta para um user_id que nunca existiu.
Chaves estrangeiras importam mais quando duas ações acontecem próximas: um delete e uma criação, um retry depois de timeout, ou um job em background rodando com dados desatualizados. O banco é melhor em impor consistência do que fazer cada caminho da aplicação lembrar de checar.
A opção ON DELETE deve casar com o significado real do relacionamento. Pergunte: “Se o pai desaparece, o filho ainda deve existir?”
RESTRICT (ou NO ACTION): impede deletar o pai se existirem filhos.CASCADE: deletar o pai deleta os filhos também.SET NULL: mantém o filho mas remove o link.Cuidado com CASCADE. Pode estar correto, mas também pode apagar mais do que você esperava quando um bug ou ação administrativa deleta um registro pai.
Em apps multi-tenant, foreign keys não são só sobre correção. Elas também evitam vazamento entre contas. Um padrão comum é incluir account_id em toda tabela de propriedade do tenant e ligar relacionamentos através dele.
CREATE TABLE contacts (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
PRIMARY KEY (account_id, id)
);
CREATE TABLE notes (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
contact_id bigint NOT NULL,
body text NOT NULL,
PRIMARY KEY (account_id, id),
FOREIGN KEY (account_id, contact_id)
REFERENCES contacts (account_id, id)
ON DELETE RESTRICT
);
Isso aplica “quem possui o quê” no schema: uma nota não pode apontar para um contato de outra conta, mesmo se o código da app (ou uma query gerada por LLM) tentar.
Comece escrevendo uma lista curta de invariantes: fatos que devem ser sempre verdadeiros. Mantenha-os simples. “Todo contato precisa de um email.” “Um status deve ser um de alguns valores permitidos.” “Uma fatura deve pertencer a um cliente real.” Essas são as regras que você quer que o banco imponha sempre.
Implemente mudanças em migrations pequenas para que a produção não seja surpreendida:
NOT NULL, UNIQUE, CHECK, FOREIGN KEY).A parte complicada é lidar com dados ruins já existentes. Planeje isso. Para duplicatas, escolha uma linha vencedora, mescle o resto e mantenha uma nota de auditoria pequena. Para campos obrigatórios ausentes, escolha um default seguro só se realmente for seguro; caso contrário coloque em quarentena. Para relacionamentos quebrados, reatribua os filhos ao pai correto ou remova as linhas ruins.
Depois de cada migration, valide com algumas gravações que devem falhar: insira uma linha com um valor obrigatório faltando, insira uma chave duplicada, insira um valor fora do intervalo e referencie uma linha pai inexistente. Gravações que falham são sinais úteis. Elas mostram onde a aplicação estava confiando silenciosamente em um comportamento “melhor esforçado”.
Imagine um CRM pequeno: accounts (cada cliente do seu SaaS), empresas com as quais eles trabalham, contatos nessas empresas e negócios atrelados a uma empresa.
Esse é exatamente o tipo de app que as pessoas geram rapidamente com uma ferramenta de chat. Parece ok em demos, mas dados reais ficam bagunçados rápido. Dois bugs tendem a aparecer cedo: contatos duplicados (o mesmo email inserido duas vezes com formas levemente diferentes) e negócios criados sem empresa porque um caminho de código esqueceu de setar company_id. Outro clássico é um valor de negócio negativo após um refactor ou um erro de parsing.
A solução não é mais if-statements. São algumas restrições bem escolhidas que tornam impossível gravar dados ruins.
-- Contacts: prevent duplicates per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_email_uniq UNIQUE (account_id, email);
-- Deals: require a company and keep the relationship valid
ALTER TABLE deals
ALTER COLUMN company_id SET NOT NULL,
ADD CONSTRAINT deals_company_fk
FOREIGN KEY (company_id) REFERENCES companies(id);
-- Deals: deal value cannot be negative
ALTER TABLE deals
ADD CONSTRAINT deals_value_nonneg CHECK (deal_value >= 0);
-- A few obvious required fields
ALTER TABLE companies
ALTER COLUMN name SET NOT NULL;
ALTER TABLE contacts
ALTER COLUMN email SET NOT NULL;
Não se trata de ser estrito por esporte. Você está transformando expectativas vagas em regras que o banco pode impor sempre, não importa qual parte da aplicação escreva dados.
Com essas restrições em vigor, a aplicação fica mais simples. Dá para remover muitas checagens defensivas que tentam detectar duplicatas depois do fato. Falhas ficam claras e acionáveis (por exemplo, “email já existe para esta conta” em vez de comportamento estranho depois). E quando uma rota gerada esquece um campo ou trata mal um valor, a gravação falha imediatamente em vez de corromper silenciosamente o banco.
Restrições funcionam melhor quando combinam com o funcionamento real do negócio. A maior parte da dor vem de adicionar regras que parecem “seguras” no momento mas acabam sendo inesperadas depois.
Um erro comum é usar ON DELETE CASCADE em todo lugar. Parece arrumado até alguém deletar um pai e o banco apagar metade do sistema. Cascades podem estar corretos para dados realmente pertencentes (como itens de rascunho que nunca devem existir sozinhos), mas são arriscados para registros importantes (clientes, faturas, tickets). Se não tiver certeza, prefira RESTRICT e trate deletes de forma intencional.
Outro problema é criar CHECKs muito estreitos. “Status must be ‘new’, ‘won’, or ‘lost’” soa bem até você precisar de “paused” ou “archived”. Um bom CHECK descreve uma verdade estável, não uma escolha temporária da UI. “amount >= 0” envelhece bem. “country in (...)” frequentemente não.
Alguns problemas que aparecem repetidamente quando times adicionam restrições depois que código gerado já está em produção:
CASCADE como ferramenta de limpeza e depois apagar mais dados que o pretendido.Sobre performance: o PostgreSQL cria automaticamente um índice para UNIQUE, mas foreign keys não indexam automaticamente a coluna referenciante. Sem esse índice, updates e deletes no pai podem ficar lentos porque o Postgres precisa escanear a tabela filha para checar referências.
Antes de apertar uma regra, encontre linhas existentes que falhariam, decida se vai consertar ou colocar em quarentena, e rode a mudança em etapas.
Antes de enviar, reserve cinco minutos por tabela e escreva o que deve ser sempre verdade. Se você consegue dizer em inglês simples, geralmente pode aplicar com uma restrição.
Pergunte-se por tabela:
Se você usa uma ferramenta guiada por chat, trate essas invariantes como critérios de aceitação para os dados, não notas opcionais. Por exemplo: “Um valor de negócio deve ser não negativo”, “O email de um contato é único por workspace”, “Uma tarefa deve referenciar um contato real.” Quanto mais explícitas as regras, menos espaço para casos de borda acidentais.
Koder.ai (koder.ai) includes features like planning mode, snapshots and rollback, and source code export, which can make it easier to iterate on schema changes safely while you tighten constraints over time.
Um padrão de rollout simples que funciona em times reais: escolha uma tabela de alto valor (users, orders, invoices, contacts), adicione 1–2 restrições que impedem as piores falhas (frequentemente NOT NULL e UNIQUE), corrija as gravações que falharem, e repita. Apertar regras ao longo do tempo vence uma migração grande e arriscada.