Evitar registros duplicados em apps CRUD exige camadas: constraints únicas no banco, chaves de idempotência e estados de UI que impedem envios duplos.

Um registro duplicado é quando seu app armazena a mesma coisa duas vezes. Pode ser duas ordens para o mesmo checkout, dois tickets de suporte com os mesmos detalhes ou duas contas criadas a partir do mesmo fluxo de cadastro. Em um app CRUD, duplicatas geralmente aparecem como linhas normais isoladas, mas estão erradas quando você olha os dados como um todo.
A maioria das duplicatas começa com um comportamento normal. Alguém clica em Criar duas vezes porque a página parece lenta. No mobile, um duplo toque é fácil de acontecer sem perceber. Mesmo usuários cuidadosos tentam de novo se o botão ainda parece ativo e não há sinal claro de que algo está acontecendo.
Depois vem o meio confuso: redes e servidores. Uma requisição pode expirar e ser reenviada automaticamente. Uma biblioteca cliente pode repetir um POST se achar que a primeira tentativa falhou. A primeira requisição pode ter sucesso, mas a resposta se perde, então o usuário tenta novamente e cria uma segunda cópia.
Você não pode resolver isso em apenas uma camada porque cada camada vê só parte da história. A UI pode reduzir envios acidentais, mas não pode impedir reenvios por conexões ruins. O servidor pode detectar repetições, mas precisa de uma forma confiável de reconhecer “isso é a mesma criação de novo”. O banco de dados pode impor regras, mas só se você definir o que significa “a mesma coisa”.
O objetivo é simples: tornar criações seguras mesmo quando a mesma requisição acontece duas vezes. A segunda tentativa deve virar uma no-op, uma resposta limpa de “já criado” ou um conflito controlado, não uma segunda linha.
Muitas equipes tratam duplicatas como um problema do banco de dados. Na prática, duplicatas geralmente nascem antes, quando a mesma ação de criar é disparada mais de uma vez.
Um usuário clica em Criar e nada parece acontecer, então clica de novo. Ou pressiona Enter e depois clica no botão. No mobile, podem ocorrer dois toques rápidos, eventos de touch e click sobrepostos ou um gesto que registra duas vezes.
Mesmo se o usuário enviar apenas uma vez, a rede pode repetir a requisição. Um timeout pode acionar um retry. Um app offline pode enfileirar um “Salvar” e reenvia-lo ao reconectar. Algumas bibliotecas HTTP re-tentam automaticamente em certos erros, e você só nota quando vê linhas duplicadas.
Servidores repetem trabalho de propósito. Filas de jobs re-executam jobs que falharam. Provedores de webhook frequentemente entregam o mesmo evento mais de uma vez, especialmente se seu endpoint estiver lento ou retornar um status não 2xx. Se sua lógica de criação é disparada por esses eventos, assuma que duplicatas vão acontecer.
A concorrência cria as duplicatas mais sorrateiras. Duas abas submetem o mesmo formulário em milissegundos. Se seu servidor faz “existe?” e depois insere, ambas as requisições podem passar na verificação antes de qualquer insert acontecer.
Trate cliente, rede e servidor como fontes separadas de repetições. Você precisará de defesas em todas as três camadas.
Se você quer um lugar confiável para impedir duplicatas, coloque a regra no banco de dados. Correções na UI e verificações no servidor ajudam, mas podem falhar sob reenvios, latência ou dois usuários agindo ao mesmo tempo. Uma constraint única no banco é a autoridade final.
Comece escolhendo uma regra de unicidade do mundo real que corresponda à forma como as pessoas pensam sobre o registro. Exemplos comuns:
Cuidado com campos que parecem únicos, mas não são, como nome completo.
Depois de ter a regra, aplique-a com uma constraint única (ou índice único). Isso faz o banco rejeitar um segundo insert que violaria a regra, mesmo se duas requisições chegarem ao mesmo momento.
Quando a constraint dispara, decida qual experiência o usuário deve ter. Se criar um duplicado for sempre errado, bloqueie com uma mensagem clara (“Esse e-mail já está em uso”). Se reenvios são comuns e o registro já existe, muitas vezes é melhor tratar o retry como sucesso e retornar o registro existente (“Seu pedido já foi criado”).
Se sua criação é realmente “criar ou reutilizar”, um upsert pode ser o padrão mais limpo. Exemplo: “criar cliente por email” pode inserir uma nova linha ou retornar a existente. Use isso só quando fizer sentido no negócio. Se payloads ligeiramente diferentes podem chegar para a mesma chave, decida quais campos podem atualizar e quais devem permanecer inalterados.
Constraints únicas não substituem chaves de idempotência ou bons estados de UI, mas dão um limite rígido que todo o resto pode apoiar.
Uma chave de idempotência é um token único que representa uma intenção do usuário, como “criar este pedido uma vez”. Se a mesma requisição for enviada de novo (duplo clique, retry de rede, retomada do mobile), o servidor a trata como retry, não como uma nova criação.
Essa é uma das ferramentas mais práticas para deixar endpoints de criação seguros quando o cliente não consegue saber se a primeira tentativa teve sucesso.
Endpoints que mais se beneficiam são os em que uma duplicata é cara ou confusa, como pedidos, faturas, pagamentos, convites, assinaturas e formulários que disparam emails ou webhooks.
No retry, o servidor deve retornar o resultado original da primeira tentativa bem-sucedida, incluindo o mesmo ID de registro criado e o mesmo código de status. Para isso, armazene um pequeno registro de idempotência indexado por (usuário ou conta) + endpoint + chave de idempotência. Salve tanto o resultado (ID do registro, body da resposta) quanto um estado “em progresso” para que duas requisições quase simultâneas não criem duas linhas.
Mantenha registros de idempotência tempo suficiente para cobrir reenvios reais. Um baseline comum é 24 horas. Para pagamentos, muitas equipes mantêm 48–72 horas. Um TTL mantém o armazenamento limitado e corresponde a quanto tempo um retry é provável.
Se você gera APIs com um construtor guiado por chat como Koder.ai, você ainda quer tornar a idempotência explícita: aceite uma chave enviada pelo cliente (header ou campo) e imponha “mesma chave, mesmo resultado” no servidor.
Idempotência torna uma requisição de criação segura para repetir. Se o cliente reenviar por causa de um timeout (ou o usuário clicar duas vezes), o servidor retorna o mesmo resultado em vez de criar uma segunda linha.
Idempotency-Key), mas enviá-la no corpo JSON também pode funcionar.O detalhe-chave é que “verificar + gravar” precisa ser seguro sob concorrência. Na prática, você armazena o registro de idempotência com uma constraint única em (scope, key) e trata conflitos como sinal para reutilizar.
if idempotency_record exists for (user_id, key):
return saved_response
else:
begin transaction
create the row
save response under (user_id, key)
commit
return response
Exemplo: um cliente aperta “Criar fatura”, o app envia a chave abc123 e o servidor cria a fatura inv_1007. Se o telefone perde sinal e reenvia, o servidor responde com o mesmo inv_1007, não com inv_1008.
Ao testar, não pare em “duplo clique”. Simule uma requisição que expira no cliente mas ainda completa no servidor, então reenvie com a mesma chave.
Defesas no servidor importam, mas muitas duplicatas ainda começam com um humano fazendo algo normal duas vezes. Uma boa UI torna o caminho seguro óbvio.
Desabilite o botão de envio assim que o usuário submeter. Faça isso no primeiro clique, não depois da validação ou depois que a requisição começa. Se o formulário pode ser submetido por múltiplos controles (um botão e Enter), bloqueie todo o estado do formulário, não apenas um botão.
Mostre um estado de progresso claro que responda a uma pergunta: está funcionando? Um simples rótulo “Salvando...” ou um spinner é suficiente. Mantenha o layout estável para que o botão não pule e provoque um segundo clique.
Um pequeno conjunto de regras evita a maioria dos envios duplos: defina uma flag isSubmitting no início do handler de submit, ignore novos envios enquanto ela for verdadeira (para click e Enter), e não limpe a flag até receber uma resposta real.
Respostas lentas são onde muitos apps falham. Se você reabilitar o botão em um timer fixo (por exemplo depois de 2 segundos), usuários podem enviar de novo enquanto a primeira requisição ainda está em voo. Reabilite somente quando a tentativa terminar.
Após o sucesso, torne o reenvio improvável. Navegue para outro lugar (para a página do novo registro ou lista) ou mostre um estado de sucesso claro com o registro criado visível. Evite deixar o mesmo formulário preenchido na tela com o botão habilitado.
Os bugs de duplicação persistentes vêm de comportamentos “estranhos mas comuns”: duas abas, um refresh ou um telefone que perde sinal.
Primeiro, delimite corretamente a unicidade. “Único” raramente significa “único em todo o banco”. Pode significar um por usuário, um por workspace ou um por tenant. Se você sincroniza com um sistema externo, talvez precise de unicidade por fonte externa + seu ID externo. Uma abordagem segura é escrever exatamente a frase que você quer (por exemplo, “Um número de fatura por tenant por ano”), e então aplicar isso.
O comportamento em múltiplas abas é uma armadilha clássica. Estados de carregamento na UI ajudam em uma aba, mas não cruzam abas. É aí que defesas no servidor ainda precisam segurar a ponta.
Botão de voltar e refresh podem disparar envios acidentais. Depois de uma criação bem-sucedida, usuários frequentemente atualizam para “verificar” ou voltam e reenviam um formulário que ainda parece editável. Prefira uma view do registro criado em vez do formulário original, e faça o servidor lidar com replays seguros.
Mobile adiciona interrupções: backgrounding, redes instáveis e reenvios automáticos. Uma requisição pode ter sucesso, mas o app nunca receber a resposta, então tenta de novo ao retomar.
O modo de falha mais comum é tratar a UI como o único trilho de proteção. Um botão desabilitado e um spinner ajudam, mas não cobrem refreshes, redes móveis instáveis, usuários abrindo uma segunda aba ou um bug cliente. O servidor e o banco de dados ainda precisam ser capazes de dizer “essa criação já aconteceu”.
Outra armadilha é escolher o campo errado para unicidade. Se você aplica uma constraint única em algo que não é realmente único (um sobrenome, um timestamp arredondado, um título livre), você vai bloquear registros válidos. Em vez disso, use um identificador real (como um ID de provedor externo) ou uma regra com escopo (único por usuário, por dia, ou por registro pai).
Chaves de idempotência também são fáceis de implementar de forma incorreta. Se o cliente gera uma chave nova a cada retry, você terá uma criação nova a cada vez. Mantenha a mesma chave para toda a intenção do usuário, desde o primeiro clique até quaisquer reenvios.
Também observe o que você retorna em reenvios. Se a primeira requisição criou o registro, um retry deveria retornar o mesmo resultado (ou pelo menos o mesmo ID), não um erro vago que faça o usuário tentar de novo.
Se uma constraint única bloquear um duplicado, não esconda isso atrás de “Algo deu errado”. Diga o que aconteceu em linguagem simples: “Esse número de fatura já existe. Mantivemos o original e não criamos um segundo.”
Antes do release, faça uma verificação rápida especificamente para caminhos de criação. Os melhores resultados vêm de empilhar defesas para que um clique perdido, retry ou rede lenta não criem duas linhas.
Confirme três coisas:
Um teste prático: abra o formulário, clique em enviar duas vezes rapidamente, depois atualize no meio do envio e tente de novo. Se você conseguir criar dois registros, usuários reais também conseguirão.
Imagine um pequeno app de faturamento. Um usuário preenche uma nova fatura e toca em Criar. A rede está lenta, a tela não muda imediatamente e ele toca Criar de novo.
Com apenas proteção na UI, você pode desabilitar o botão e mostrar um spinner. Isso ajuda, mas não é suficiente. Um duplo toque ainda pode escapar em alguns dispositivos, um retry pode acontecer após um timeout ou o usuário pode submeter de duas abas.
Com apenas uma constraint única no banco, você pode impedir duplicatas exatas, mas a experiência pode ficar ruim. A primeira requisição tem sucesso, a segunda bate na constraint e o usuário vê um erro mesmo que a fatura já tenha sido criada.
O resultado limpo é idempotência mais constraint única:
Uma mensagem simples na UI após o segundo toque: “Fatura criada - ignoramos o envio duplicado e mantivemos sua primeira requisição.”
Depois de ter o básico em funcionamento, os próximos ganhos vêm de visibilidade, limpeza e consistência.
Adicione logging leve em caminhos de criação para que você consiga distinguir entre uma ação de usuário real e um retry. Logue a chave de idempotência, os campos únicos envolvidos e o resultado (criado vs retornado existente vs rejeitado). Você não precisa de uma ferramenta pesada para começar.
Se duplicatas já existem, limpe-as com uma regra clara e um trilho de auditoria. Por exemplo, mantenha o registro mais antigo como o “vencedor”, reanexe linhas relacionadas (pagamentos, itens) e marque os outros como mesclados em vez de deletá-los. Isso facilita suporte e relatórios.
Escreva suas regras de unicidade e idempotência em um só lugar: o que é único e em qual escopo, quanto tempo chaves de idempotência vivem, como os erros devem parecer e o que a UI deve fazer em reenvios. Isso impede que novos endpoints burlem silenciosamente as salvaguardas.
Se você está construindo telas CRUD rapidamente no Koder.ai (koder.ai), vale tornar esses comportamentos parte do seu template padrão: constraints únicas no esquema, endpoints de criação idempotentes na API e estados de carregamento claros na UI. Assim, velocidade não vira sinônimo de dados bagunçados.
Um registro duplicado é quando a mesma entidade do mundo real é armazenada duas vezes, por exemplo duas ordens para um mesmo checkout ou dois tickets para o mesmo problema. Normalmente isso acontece porque a mesma ação de “criar” foi executada mais de uma vez devido a cliques duplos do usuário, reenvios ou requisições concorrentes.
Porque uma segunda criação pode ser disparada sem o usuário perceber — um duplo toque no mobile, pressionar Enter e depois clicar no botão, etc. Mesmo com um único envio, o cliente, a rede ou o servidor podem reenviar a requisição depois de um timeout, já que não se pode assumir que “POST = uma vez”.
Não de forma confiável. Desabilitar o botão e mostrar “Salvando…” reduz envios acidentais, mas não impede reenvios por redes instáveis, atualizações de página, múltiplas abas, workers em segundo plano ou reentregas de webhooks. Você também precisa de defesas no servidor e no banco de dados.
Uma constraint única no banco é a última linha de defesa que impede duas linhas idênticas mesmo se duas requisições chegarem ao mesmo tempo. Funciona melhor quando você define uma regra de unicidade do mundo real (frequentemente com escopo, como por tenant ou workspace) e a aplica no banco.
Eles resolvem problemas diferentes. Constraints únicas bloqueiam duplicatas com base em campos (por exemplo número de fatura), enquanto chaves de idempotência tornam uma tentativa específica de criação segura para reenvios (mesma chave = mesmo resultado). Usar ambos dá segurança e uma melhor experiência ao usuário em reenvios.
Gere uma chave por intenção do usuário (um pressionar de “Criar”), reutilize-a para quaisquer reenvios dessa mesma intenção e envie-a com a requisição sempre. A chave deve ser estável através de timeouts e retomadas do app, mas não deve ser reutilizada para uma criação diferente mais tarde.
Armazene um registro de idempotência com escopo (por exemplo usuário ou conta), endpoint e a chave, e salve a resposta que você retornou na primeira requisição bem-sucedida. Se a mesma chave chegar de novo, retorne a resposta salva com o mesmo ID criado em vez de inserir uma nova linha.
Use uma abordagem “verificar + gravar” segura para concorrência, normalmente aplicando uma constraint única no próprio registro de idempotência (escopo + chave). Assim, duas requisições quase simultâneas não podem ambas se declarar como “primeira”; uma delas será forçada a reutilizar o resultado armazenado.
Mantenha-as tempo suficiente para cobrir reenvios realistas; um padrão comum é cerca de 24 horas, mais longo para fluxos como pagamentos, onde reenvios podem acontecer mais tarde. Adicione um TTL para que o armazenamento não cresça indefinidamente e faça o TTL corresponder ao período em que um cliente pode razoavelmente reenviar.
Trate um reenvio como um retry bem-sucedido quando for claramente a mesma intenção, retornando o registro original (mesmo ID) em vez de um erro vago. Se a criação exige unicidade (por exemplo um email), retorne uma mensagem de conflito clara que explique o que já existe e o que foi feito.