6 min

Priorize restrições do PostgreSQL: pare cedo os bugs de casos extremos gerados por IA

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.

Priorize restrições do PostgreSQL: pare cedo os bugs de casos extremos gerados por IA

Por que o código escrito por IA ainda falha com dados bagunçados

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.

Por que o banco deve ser sua última linha de defesa

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: impeça dados obrigatórios ausentes cedo

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 true

Defaults 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.

CHECK: codifique regras de negócio perto dos dados

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.

UNIQUE: impeça duplicatas que você vai se arrepender depois

Tenha controle do código gerado
Mantenha controle total exportando o código-fonte depois de apertar as regras do banco.

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.

FOREIGN KEY: mantenha relacionamentos consistentes

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.

Escolha o comportamento ON DELETE correto

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.

Schemas multi-tenant: modele propriedade explicitamente

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.

Passo a passo: adicionar restrições sem quebrar produção

Reduza bugs de casos extremos
Capture gravações duplicadas e campos faltantes cedo com um fluxo de trabalho que prioriza restrições.

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:

  • Adicione a nova coluna ou regra de forma não disruptiva primeiro.
  • Faça backfill das linhas existentes em lotes.
  • Corrija dados ruins (dedupe, conserte valores inválidos) ou coloque-os em quarentena para revisão.
  • Aplique a regra (NOT NULL, UNIQUE, CHECK, FOREIGN KEY).
  • Ajuste o comportamento da app para que erros sejam tratados claramente.

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”.

Um exemplo realista: um CRM pequeno que fica limpo

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.

As restrições que mantêm o CRM limpo

-- 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.

O que muda na aplicação depois disso

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.

Erros comuns que tornam restrições dolorosas

Do esquema ao deploy
Entregue mais rápido ao implantar o app quando restrições e validações concordarem.

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:

  • Tratar CASCADE como ferramenta de limpeza e depois apagar mais dados que o pretendido.
  • Fazer CHECKs tão estreitos que bloqueiam casos válidos futuros.
  • Assumir que UNIQUE impede duplicatas quando NULL está envolvido.
  • Mudar regras sem um plano para corrigir linhas já existentes que as violam.

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.

Checklist rápido e próximos passos para sua próxima construção

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:

  • O que nunca deve ser NULL?
  • O que nunca deve ser duplicado?
  • O que nunca deve ser negativo ou fora de alcance?
  • O que não deve existir sem um pai (sem linhas órfãs)?
  • O que deve seguir uma regra simples (estados permitidos, início antes do fim)?

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.

Perguntas frequentes

Por que devo colocar regras de dados no PostgreSQL em vez de apenas no meu app?

Coloque no PostgreSQL as regras que sempre devem ser cumpridas e deixe cada caminho do app segui-las. A validação na interface e na API pode fornecer mensagens amigáveis, enquanto as restrições impedem gravações inválidas vindas de importações, tarefas, novas tentativas e ferramentas administrativas.

Quando devo usar NOT NULL?

Use NOT NULL quando uma linha não fizer sentido sem um valor, como um ID de proprietário, nome ou horário de criação. Não use uma string vazia como substituta para uma entrada de usuário ausente, pois isso esconde o erro.

Para que serve uma restrição CHECK?

Use CHECK para regras dentro de uma única linha, como amount >= 0, um status permitido ou uma data de término posterior à data de início. Mantenha a regra curta e estável para que futuras mudanças no produto não transformem dados válidos em erros.

UNIQUE também impede valores ausentes?

Adicione UNIQUE quando duplicatas causariam problemas reais, como IDs externos repetidos ou dois contatos com o mesmo e-mail em uma conta. Combine-o com NOT NULL se todos os registros precisarem fornecer esse valor, pois o PostgreSQL permite vários NULLs em uma restrição única padrão.

Como as chaves estrangeiras impedem registros órfãos?

Uma chave estrangeira faz o banco de dados rejeitar uma linha filha que aponta para uma linha pai inexistente. Ela impede registros órfãos, como uma negociação sem uma empresa válida ou uma observação vinculada a um cliente excluído.

Devo usar ON DELETE CASCADE em todos os lugares?

Comece com RESTRICT quando a exclusão do registro pai exigir uma decisão deliberada. Escolha CASCADE apenas quando os registros filhos realmente não tiverem valor sem esse pai, como itens temporários de rascunho.

Como posso impedir e-mails duplicados com maiúsculas e minúsculas diferentes?

Use um índice único em um valor normalizado, por exemplo lower(email), quando os usuários esperarem que diferentes maiúsculas e minúsculas indiquem o mesmo e-mail. Também remova espaços e normalize a entrada no app, para que as pessoas recebam mensagens de erro mais claras.

Como adiciono restrições a um banco de dados de produção com segurança?

Primeiro, encontre as linhas que violam a nova regra. Corrija, mescle ou coloque-as em quarentena em lotes, depois adicione a restrição e atualize o app para mostrar um erro útil quando uma gravação falhar.

As chaves estrangeiras precisam de índices?

Uma restrição única cria seu próprio índice, mas uma chave estrangeira não indexa automaticamente a coluna filha. Adicione um índice à coluna de referência quando a tabela for grande ou quando as linhas pai forem atualizadas ou excluídas com frequência.

Quais restrições devo adicionar primeiro?

Comece por uma tabela que afete dinheiro, clientes ou identidade. Adicione uma ou duas regras com valor claro, como um proprietário obrigatório, um e-mail único por conta ou um valor não negativo, e então teste gravações que deveriam falhar.

Related posts