Prevenire record duplicati nelle app CRUD richiede più livelli: vincoli unici nel database, chiavi di idempotenza e stati dell'interfaccia che impediscono invii doppi.

Un record duplicato è quando la tua app memorizza la stessa cosa due volte. Può essere due ordini per lo stesso checkout, due ticket di supporto con gli stessi dettagli o due account creati dallo stesso flusso di registrazione. In un'app CRUD i duplicati spesso sembrano righe normali prese singolarmente, ma sono sbagliati quando guardi i dati nel loro insieme.
La maggior parte dei duplicati nasce da comportamenti normali. Qualcuno clicca Create due volte perché la pagina sembra lenta. Su mobile, un doppio tap è facile da non notare. Anche utenti attenti ritenteranno se il pulsante sembra ancora attivo e non c'è un segnale chiaro che qualcosa stia succedendo.
Poi c'è il lato più complesso: reti e server. Una richiesta può andare in timeout e venire ritentata automaticamente. Una libreria client potrebbe ripetere una POST se pensa che il primo tentativo sia fallito. La prima richiesta potrebbe riuscire, ma la risposta va persa, così l'utente riprova e crea una seconda copia.
Non puoi risolvere tutto con un solo livello perché ogni livello vede solo una parte della storia. L'interfaccia può ridurre i doppi invii accidentali, ma non può fermare i retry dovuti a connessioni inaffidabili. Il server può rilevare ripetizioni, ma ha bisogno di un modo affidabile per riconoscere “questa è la stessa creazione di prima”. Il database può applicare regole, ma solo se definisci cosa significa “la stessa cosa”.
L'obiettivo è semplice: rendere le creazioni sicure anche quando la stessa richiesta arriva due volte. Il secondo tentativo dovrebbe diventare un no-op, una risposta pulita di “già creato”, o un conflitto gestito, non una seconda riga.
Molti team trattano i duplicati come un problema del database. In pratica, però, i duplicati nascono spesso prima, quando la stessa azione di creazione viene attivata più volte.
Un utente clicca Create e sembra non succedere nulla, quindi clicca di nuovo. Oppure preme Invio e poi clicca il pulsante subito dopo. Su mobile puoi avere due tap ravvicinati, eventi touch e click sovrapposti, o un gesto che viene registrato due volte.
Anche se l'utente invia una sola volta, la rete può comunque ripetere la richiesta. Un timeout può scatenare un retry. Un'app offline può mettere in coda un “Save” e rinviarlo quando torna online. Alcune librerie HTTP ritentano automaticamente su certi errori, e tu non lo noterai finché non vedrai righe duplicate.
I server ripetono il lavoro intenzionalmente. Le job queue ritentano i job falliti. I provider di webhook spesso consegnano lo stesso evento più di una volta, specialmente se il tuo endpoint è lento o restituisce uno status non 2xx. Se la tua logica di creazione è innescata da questi eventi, dai per scontato che i duplicati accadranno.
La concorrenza crea i duplicati più subdoli. Due tab inviano lo stesso form a pochi millisecondi di distanza. Se il server fa “esiste già?” e poi inserisce, entrambe le richieste possono superare il controllo prima che avvenga l'inserimento.
Tratta client, rete e server come fonti separate di ripetizioni. Avrai bisogno di difese in tutti e tre.
Se vuoi un posto affidabile per fermare i duplicati, metti la regola nel database. Le correzioni UI e i controlli server aiutano, ma possono fallire sotto retry, lag o due utenti che agiscono contemporaneamente. Un vincolo unico nel database è l'autorità finale.
Inizia scegliendo una regola di unicità concreta che corrisponda a come le persone pensano al record. Esempi comuni:
Fai attenzione a campi che sembrano unici ma non lo sono, come un nome completo.
Una volta scelta la regola, applicala con un vincolo unico (o indice unico). Questo fa sì che il database rifiuti un secondo insert che violerebbe la regola, anche se due richieste arrivano nello stesso momento.
Quando il vincolo scatta, decidi quale esperienza mostrare all'utente. Se creare un duplicato è sempre sbagliato, bloccalo con un messaggio chiaro (“Quell'email è già in uso”). Se i retry sono comuni e il record esiste già, spesso è meglio trattare il retry come un successo e restituire il record esistente (“Il tuo ordine è già stato creato”).
Se la tua operazione di create è davvero “crea o riutilizza”, un upsert può essere il pattern più pulito. Esempio: “crea cliente per email” può inserire una nuova riga o restituire quella esistente. Usalo solo quando corrisponde al significato di business. Se possono arrivare payload leggermente diversi per la stessa chiave, decidi quali campi possono aggiornarsi e quali devono rimanere invariati.
I vincoli unici non sostituiscono le chiavi di idempotenza o buoni stati UI, ma ti danno uno stop duro su cui tutto il resto può appoggiarsi.
Una chiave di idempotenza è un token unico che rappresenta una singola intenzione dell'utente, per esempio “crea questo ordine una volta”. Se la stessa richiesta viene inviata di nuovo (doppio click, retry di rete, ripresa mobile), il server la tratta come retry, non come nuova creazione.
Questo è uno degli strumenti più pratici per rendere sicuri gli endpoint di create quando il client non riesce a capire se il primo tentativo è riuscito.
Gli endpoint che ne traggono più beneficio sono quelli dove un duplicato è costoso o confonde, come ordini, fatture, pagamenti, inviti, abbonamenti e form che scatenano email o webhook.
Al retry, il server dovrebbe restituire il risultato originale del primo tentativo riuscito, incluso lo stesso ID creato e lo stesso codice di stato. Per farlo, conserva un piccolo record di idempotenza indicizzato da (utente o account) + endpoint + chiave di idempotenza. Salva sia l'esito (ID record, corpo della risposta) sia uno stato “in corso” in modo che due richieste quasi simultanee non creino due righe.
Conserva i record di idempotenza abbastanza a lungo da coprire i retry reali. Una baseline comune è 24 ore. Per i pagamenti, molti team tengono 48–72 ore. Una TTL mantiene lo storage limitato e corrisponde a quanto è probabile un retry.
Se generi API con un builder guidato come Koder.ai, è comunque utile rendere l'idempotenza esplicita: accetta una chiave inviata dal client (header o campo) e applica la regola “stessa chiave, stesso risultato” sul server.
L'idempotenza rende sicura una richiesta di create da ripetere. Se il client ritenta per un timeout (o l'utente clicca due volte), il server restituisce lo stesso risultato invece di creare una seconda riga.
Idempotency-Key), ma mandarla nel body JSON può andare bene ugualmente.Il dettaglio chiave è che "controlla + conserva" deve essere sicuro sotto concorrenza. In pratica, memorizzi il record di idempotenza con un vincolo unico su (scope, key) e tratti i conflitti come un segnale per riutilizzare il risultato.
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
Esempio: un cliente preme “Create invoice”, l'app invia la key abc123 e il server crea la fattura inv_1007. Se il telefono perde il segnale e ritenta, il server risponde con lo stesso inv_1007, non con inv_1008.
Quando testi, non fermarti al “double click”. Simula una richiesta che va in timeout sul client ma completa comunque sul server, poi ritenta con la stessa chiave.
Le difese server-side contano, ma molti duplicati ancora cominciano con una persona che fa la cosa normale due volte. Una buona UI rende ovvia la strada sicura.
Disabilita il pulsante di invio non appena l'utente invia. Fallo al primo click, non dopo la validazione o dopo che la richiesta è partita. Se il form può essere inviato tramite più controlli (un pulsante e Invio), blocca tutto lo stato del form, non solo un pulsante.
Mostra uno stato di progresso chiaro che risponde a una domanda: sta funzionando? Un semplice “Salvataggio...” o uno spinner bastano. Mantieni il layout stabile in modo che il pulsante non salti e non invogli un secondo click.
Un piccolo insieme di regole previene la maggior parte dei doppi invii: imposta un flag isSubmitting all'inizio dell'handler di submit, ignora nuovi submit mentre è true (per click e Invio) e non resettarlo finché non hai una risposta reale.
Le risposte lente sono dove molte app falliscono. Se riattivi il pulsante su un timer fisso (per esempio dopo 2 secondi), gli utenti possono inviare di nuovo mentre la prima richiesta è ancora in volo. Riattiva solo quando il tentativo è completo.
Dopo il successo, rendi improbabile un nuovo invio. Naviga lontano (alla pagina del nuovo record o alla lista) o mostra uno stato di successo con il record creato visibile. Evita di lasciare lo stesso form compilato con il pulsante abilitato.
I bug duplicati più ostinati nascono da comportamenti "strani ma comuni": due schede, un refresh o un telefono che perde il segnale.
Prima di tutto, definisci correttamente l'unicità. “Unico” raramente significa “unico nell'intero database”. Potrebbe significare uno per utente, uno per workspace o uno per tenant. Se sincronizzi con un sistema esterno, potresti aver bisogno di unicità per sorgente esterna più il suo ID esterno. Un approccio sicuro è scrivere la frase esatta che intendi (per esempio, “Un numero fattura per tenant per anno”) e poi applicarla.
Il comportamento multi-tab è una trappola classica. Gli stati di caricamento UI aiutano in una singola scheda, ma non fanno nulla tra schede diverse. Qui le difese server-side devono comunque reggere.
Il tasto Indietro e il refresh possono scatenare invii accidentali. Dopo una creazione riuscita, gli utenti spesso aggiornano per “controllare” o premono Indietro e reinviano un form che sembra ancora modificabile. Preferisci una vista del record creato invece del form originale e fai in modo che il server gestisca replay sicuri.
Il mobile aggiunge interruzioni: backgrounding, reti instabili e retry automatici. Una richiesta potrebbe riuscire, ma l'app non ricevere la risposta, quindi ritenta alla ripresa.
Il fallimento più comune è considerare la UI come l'unica rete di sicurezza. Un pulsante disabilitato e uno spinner aiutano, ma non coprono refresh, reti mobili instabili, utenti che aprono una seconda scheda o bug client. Server e database devono ancora poter dire “questa creazione è già avvenuta”.
Un'altra trappola è scegliere il campo sbagliato per l'unicità. Se imposti un vincolo unico su qualcosa che non è veramente unico (un cognome, un timestamp arrotondato, un titolo libero), bloccherai record validi. Usa un identificatore reale (come un ID esterno del provider) o una regola con ambito (unico per utente, per giorno o per record padre).
Le chiavi di idempotenza sono facili da implementare male. Se il client genera una chiave nuova a ogni retry, ottieni una nuova creazione ogni volta. Mantieni la stessa chiave per tutto l'intento utente, dal primo click fino ai retry.
Fai attenzione anche a cosa restituisci sui retry. Se la prima richiesta ha creato il record, un retry dovrebbe restituire lo stesso risultato (o almeno lo stesso ID record), non un errore vago che fa ritentare l'utente.
Se un vincolo unico blocca un duplicato, non nasconderlo dietro “Qualcosa è andato storto.” Dì cosa è successo in chiaro: “Questo numero fattura esiste già. Abbiamo mantenuto l'originale e non abbiamo creato una seconda fattura.”
Prima del deploy, fai un rapido controllo specifico per i percorsi di creazione. I risultati migliori arrivano impilando difese in modo che un click mancato, un retry o una rete lenta non possano creare due righe.
Conferma tre cose:
Un controllo pratico: apri il form, clicca submit due volte rapidamente, poi aggiorna a metà submit e riprova. Se riesci a creare due record, anche utenti reali lo faranno.
Immagina una piccola app di fatturazione. Un utente compila una nuova fattura e tocca Create. La rete è lenta, lo schermo non cambia subito e tocca Create di nuovo.
Solo con protezione UI, potresti disabilitare il pulsante e mostrare uno spinner. Questo aiuta, ma non basta. Un doppio tap può comunque scivolare su alcuni dispositivi, può avvenire un retry dopo un timeout, o l'utente può inviare da due schede.
Solo con un vincolo unico nel database puoi fermare i duplicati esatti, ma l'esperienza può risultare dura. La prima richiesta riesce, la seconda colpisce il vincolo e l'utente vede un errore anche se la fattura era stata creata.
Il risultato pulito è idempotenza più vincolo unico:
Un messaggio UI semplice dopo il secondo tap: “Fattura creata – abbiamo ignorato l'invio duplicato e mantenuto la tua prima richiesta.”
Una volta che hai le basi, i successi successivi riguardano visibilità, pulizia e coerenza.
Aggiungi logging leggero attorno ai percorsi di creazione così puoi distinguere tra un'azione utente reale e un retry. Registra la chiave di idempotenza, i campi unici coinvolti e l'esito (creato vs restituito esistente vs rifiutato). Non servono tool pesanti per cominciare.
Se i duplicati esistono già, ripuliscili con una regola chiara e una traccia di audit. Per esempio, conserva il record più vecchio come “vincitore”, riattacca le righe correlate (pagamenti, line items) e marca gli altri come uniti invece di cancellarli. Questo facilita supporto e reportistica.
Scrivi le tue regole di unicità e idempotenza in un unico posto: cosa è unico e in quale ambito, quanto tempo vivono le chiavi di idempotenza, come appaiono gli errori e cosa dovrebbe fare l'UI sui retry. Questo evita che nuovi endpoint aggirino silenziosamente le protezioni.
Se stai costruendo schermate CRUD velocemente in Koder.ai (koder.ai), vale la pena includere questi comportamenti nel template di default: vincoli unici nello schema, endpoint di create idempotenti nell'API e stati di caricamento chiari nella UI. Così la velocità non compromette la qualità dei dati.
Un record duplicato è quando la stessa entità del mondo reale viene memorizzata due volte, per esempio due ordini per lo stesso checkout o due ticket per lo stesso problema. Di solito accade perché la stessa azione di “create” viene eseguita più volte a causa di doppi invii da parte dell'utente, retry o richieste concorrenti.
Perché una seconda creazione può essere attivata senza che l'utente se ne renda conto: un doppio tap su mobile, premere Invio e poi cliccare il pulsante, ecc. Anche quando l'utente invia una sola volta, il client, la rete o il server possono ritentare la richiesta dopo un timeout e provocare una seconda creazione. Non si può assumere che “POST sia una sola volta”.
Non in modo affidabile. Disabilitare il pulsante e mostrare “Salvataggio…” riduce i doppi invii accidentali, ma non impedisce i retry dovuti a reti instabili, refresh, più schede, job in background o ridelivery di webhook. Servono anche difese a livello server e database.
Un vincolo unico è l'ultima linea di difesa che impedisce l'inserimento di due righe anche se arrivano due richieste simultanee. Funziona meglio quando definisci una regola di unicità realistica (spesso con ambito, per esempio per tenant o workspace) e la applichi direttamente nel database.
Risolvono problemi diversi. I vincoli unici bloccano duplicati basati su una regola di campo (es. numero fattura), mentre le chiavi di idempotenza rendono un tentativo di creazione specifico sicuro da ripetere (stessa chiave = stesso risultato). Usarli insieme offre sicurezza e una migliore esperienza utente sui retry.
Genera una chiave per ogni intento dell'utente (una pressione di “Create”), riutilizzala per tutti i retry di quell'intento e inviala con la richiesta. La chiave deve rimanere stabile attraverso timeout e riprese dell'app, ma non deve essere riutilizzata per una creazione diversa in seguito.
Conserva un record di idempotenza indicizzato per ambito (es. utente o account), endpoint e la chiave di idempotenza, e salva la risposta che hai restituito per la prima richiesta andata a buon fine. Se la stessa chiave arriva di nuovo, restituisci la risposta salvata con lo stesso ID creato invece di inserire una nuova riga.
Usa un approccio “check + store” sicuro per la concorrenza, tipicamente imponendo un vincolo unico sul record di idempotenza (per ambito e chiave). In questo modo due richieste quasi simultanee non possono entrambe comportarsi come “la prima” e una dovrà riutilizzare il risultato salvato.
Conservale abbastanza a lungo da coprire retry realistici; un valore comune è 24 ore, più lungo per flussi come pagamenti dove i retry possono avvenire più tardi. Usa una TTL in modo che lo storage non cresca all'infinito e che la durata corrisponda al periodo in cui un client potrebbe ragionevolmente ritentare.
Tratta una creazione duplicata come un retry riuscito quando è chiaramente lo stesso intento, restituendo il record originale (stesso ID) invece di un errore vago. Se invece l'utente sta cercando di creare qualcosa che deve essere unico (per esempio un'email), mostra un messaggio di conflitto chiaro che spiega cosa esiste già e cosa è stato fatto.