Due applicazioni possono apparire identiche dall'esterno e comportarsi in modo opposto sotto carico o dopo due anni di modifiche. La differenza non sta nel linguaggio o nel framework: sta in alcune decisioni di struttura che si prendono presto e si pagano tardi.
Race condition: il problema non è il controllo#
// La race condition più comune del web, e la più costosa: il controllo// e l'azione sono due operazioni separate, e in mezzo può succedere tutto.async function prenota(eventoId, utenteId) { const e = await db.get('SELECT posti FROM eventi WHERE id = ?', [eventoId]); if (e.posti < 1) throw new Error('esaurito'); // ← controllo await db.run('UPDATE eventi SET posti = posti - 1 WHERE id = ?', [eventoId]); await db.run('INSERT INTO prenotazioni ...'); // ← azione}// t0 richiesta A: legge posti = 1 → passa il controllo// t1 richiesta B: legge posti = 1 → passa il controllo// t2 richiesta A: posti = 0// t3 richiesta B: posti = -1 ← due biglietti per un posto solo//// Il difetto è strutturale: fra il SELECT e l'UPDATE c'è un await, e// durante un await arrivano altre richieste. Nessun "controlla meglio"// risolve il problema, perché il problema non è il controllo.Questo schema — leggere, verificare, agire — è talmente naturale che si scrive senza pensarci. Ed è sbagliato ogni volta che fra la verifica e l'azione c'è un punto di sospensione, cioè sempre, in qualunque codice asincrono.
L'errore da evitare, quando lo si scopre, è cercare di rafforzare il controllo: rileggere il valore, controllare due volte, aggiungere un ritardo. Non funziona, perché il controllo non è il problema. Il problema è la distanza fra il controllo e l'azione.
// ✓ Soluzione 1 — il controllo dentro la scrittura. Il database valuta// la condizione e applica la modifica come UNA operazione indivisibile.const r = await db.run( 'UPDATE eventi SET posti = posti - 1 WHERE id = ? AND posti > 0', [eventoId]);if (r.changes === 0) throw new Error('esaurito'); // ← ha perso la corsa// ✓ Soluzione 2 — il vincolo nello schema. La rete di sicurezza che// regge anche quando qualcuno scriverà una query dimenticandosi// della condizione: il database rifiuta e basta.ALTER TABLE eventi ADD CONSTRAINT posti_non_negativi CHECK (posti >= 0);// ✓ Soluzione 3 — quando servono più righe insieme: transazione con// blocco esplicito della riga.BEGIN; SELECT posti FROM eventi WHERE id = ? FOR UPDATE; -- blocca la riga UPDATE eventi SET posti = posti - 1 WHERE id = ?; INSERT INTO prenotazioni ...;COMMIT;La prima soluzione è quella da preferire: si chiede al database di fare le due cose insieme. UPDATE ... WHERE posti > 0 valuta la condizione e applica la modifica come operazione indivisibile, e il numero di righe modificate dice chi ha vinto la corsa.
La stessa famiglia, lato browser#
// La stessa famiglia, lato browser: il doppio clic sul pulsante "Invia".// ✗ Due richieste identiche, due ordini creati.bottone.addEventListener('click', async () => { await fetch('/api/ordini', { method: 'POST', body: ... });});// ✓ Disabilitare non basta (il clic può partire prima del re-render):// serve una chiave di idempotenza generata dal CLIENT. Il server// riconosce la ripetizione e restituisce lo stesso risultato.let inCorso = false;bottone.addEventListener('click', async () => { if (inCorso) return; inCorso = true; bottone.disabled = true; try { await fetch('/api/ordini', { method: 'POST', headers: { 'Idempotency-Key': chiaveOrdine }, // stabile per QUESTO ordine body: ... }); } finally { inCorso = false; bottone.disabled = false; }});// Lato server: la chiave è UNIQUE in tabella. La seconda richiesta// trova il record già creato e restituisce quello, senza duplicare.Il doppio clic sul pulsante «conferma ordine» è la versione quotidiana dello stesso problema, e la soluzione istintiva — disabilitare il pulsante — è insufficiente: il secondo clic può partire prima che il ridisegno abbia effetto, e comunque non copre il caso in cui l'utente ricarichi la pagina e riprovi.
La difesa robusta è una chiave di idempotenza generata dal client e stabile per quel tentativo: il server la memorizza con vincolo di unicità, e alla seconda richiesta con la stessa chiave restituisce il risultato della prima invece di creare un secondo ordine. È il meccanismo che usano tutti i sistemi di pagamento seri, per lo stesso motivo.
Responsabilità singola: la domanda è «chi chiede la modifica»#
// Il principio di responsabilità singola non dice "funzioni corte".// Dice: un modulo deve avere UNA ragione per cambiare.// La domanda operativa è "chi chiede la modifica?".// ✗ Tre committenti diversi sullo stesso file: il commerciale (il prezzo),// il grafico (il layout dell'email), il sistemista (il fornitore SMTP).// Ogni loro richiesta rischia di rompere il lavoro degli altri.class Ordine { calcolaTotale() { ... } // regole commerciali generaHtmlEmail() { ... } // presentazione inviaEmail() { ... } // infrastruttura salva() { ... } // persistenza}// ✓ Separati per COMMITTENTE, non per dimensione del file.class Ordine { calcolaTotale() { ... } } // cambia se cambia il listinoclass EmailOrdine { render(ordine) { ... } } // cambia se cambia la graficaclass Postino { invia(messaggio) { ... } } // cambia se cambia il fornitoreclass OrdiniRepo { salva(ordine) { ... } } // cambia se cambia lo schemaIl principio viene quasi sempre riassunto come «una funzione, una cosa», che è un consiglio inutile perché «una cosa» non ha una definizione. La formulazione originale è più utile: un modulo dovrebbe avere una sola ragione per cambiare — e la ragione ha un nome e un cognome.
Nella classe di partenza convivono tre committenti: il responsabile commerciale che cambia il listino, il grafico che cambia il modello dell'email, il sistemista che cambia il fornitore SMTP. Ogni loro richiesta obbliga a toccare un file in cui vivono anche le altre due, con il rischio di romperle. Separare per committente elimina il rischio; separare per numero di righe non elimina niente.
Dove i principi si contraddicono#
// Il conflitto che i manuali non affrontano: DRY e responsabilità singola// tirano in direzioni opposte, e chi cerca di soddisfarli entrambi al// massimo produce astrazioni che nessuno riesce più a seguire.// Queste due funzioni sono IDENTICHE oggi:function validaFattura(d) { return d.importo > 0 && d.data <= oggi(); }function validaPreventivo(d) { return d.importo > 0 && d.data <= oggi(); }// Unificarle sembra DRY. Ma rispondono a due autorità diverse: la fattura// segue la normativa fiscale, il preventivo segue la politica commerciale.// Il giorno in cui i preventivi potranno avere data futura, l'astrazione// va smontata — e nel frattempo qualcuno ci avrà appeso altre tre cose.// Il criterio: DRY riguarda la CONOSCENZA, non il testo del codice.// Due frammenti identici che rispondono a due regole diverse non sono// una duplicazione: sono una coincidenza.Questa è la parte che i manuali saltano. La responsabilità singola spinge a separare, DRY spinge ad accorpare, e applicati entrambi al massimo producono gerarchie di astrazioni in cui per capire cosa fa una funzione bisogna aprire sei file.
La riformulazione che scioglie il conflitto è nella definizione originale di DRY, che non parla di codice: ogni pezzo di conoscenza deve avere una rappresentazione unica e autorevole nel sistema. Conoscenza, non testo. Due funzioni identiche che rispondono a due autorità diverse non violano DRY: si somigliano per caso, e unificarle crea un accoppiamento fra cose che devono poter evolvere separatamente.
La regola dei tre, e perché tre#
Il criterio operativo, e perché la soglia è tre: 1ª occorrenza → si scrive. 2ª occorrenza → si copia. Non si sa ancora COSA varia fra le due, e un'astrazione costruita su un solo confronto indovina il punto di variazione quasi sempre male. 3ª occorrenza → adesso la forma comune si vede: due assi di variazione confermati, non ipotizzati. Si astrae.Il costo asimmetrico che giustifica l'attesa: - duplicazione di troppo → si trova con una ricerca testuale - astrazione sbagliata → tre chiamanti accoppiati, un parametro booleano che ne governa il comportamento, e nessuno che osi toccarlaLa ragione per cui la soglia è tre e non due è che con due esempi non si sa ancora cosa varia. Un'astrazione costruita su un solo confronto indovina l'asse di variazione quasi sempre male, e il modo in cui sbaglia è riconoscibile:
// Come si riconosce un'astrazione sbagliata: il parametro booleano che// seleziona il comportamento. È il segnale che dentro una funzione ce// ne sono due, tenute insieme dal fatto che si somigliavano.function invia(dest, testo, isUrgente, isHtml, saltaCoda) { ... }// ~~~~~~~~~ ~~~~~~ ~~~~~~~~~// invia(x, y, true, false, true) ← illeggibile al punto di chiamata// Quando compaiono, la strada di solito è tornare indietro: separare in// funzioni con un nome, e accettare la duplicazione che ne deriva.// "Duplication is far cheaper than the wrong abstraction" — Sandi MetzIl parametro booleano che seleziona il comportamento è il sintomo classico: dentro quella funzione ci sono due funzioni diverse, tenute insieme dal fatto che un giorno si somigliavano. Al punto di chiamata, invia(x, y, true, false, true) non è leggibile da nessuno.
Una sola fonte di verità#
// Una sola fonte di verità, in pratica: il valore derivato non si// memorizza, si calcola. Quello che non esiste non può divergere.// ✗ Due copie della stessa informazione: prima o poi non coincidono// (un ordine annullato che non decrementa il totale, un'operazione// fallita a metà, una modifica fatta a mano sul database)const ordine = { righe: [...], totale: 1250, numeroRighe: 4 };// ✓ Una sola, e le altre derivanoconst ordine = { righe: [...] };const totale = ordine.righe.reduce((s, r) => s + r.prezzo * r.qta, 0);// L'eccezione legittima è la denormalizzazione per prestazioni: si// memorizza il derivato perché ricalcolarlo costa troppo. In quel caso// va dichiarato esplicitamente e va garantito che l'aggiornamento// avvenga nella STESSA transazione — non "da qualche parte, dopo".È DRY applicato ai dati invece che al codice, ed è il principio che previene la categoria di bug più difficile da diagnosticare: quelli in cui due parti del sistema hanno ragione entrambe, perché guardano due copie che hanno smesso di coincidere.
L'eccezione legittima è la denormalizzazione per prestazioni — ricalcolare costa troppo, quindi si memorizza. Va però trattata come un'eccezione dichiarata: il valore derivato è documentato come tale, e il suo aggiornamento avviene nella stessa transazione che modifica la fonte. «Lo aggiorniamo dopo, in un job» è il modo in cui si costruiscono divergenze permanenti.
| Segnale nel codice | Cosa indica | Direzione |
|---|---|---|
SELECT, controllo, UPDATE | race condition | condizione dentro la scrittura |
| Endpoint che crea senza chiave | duplicati da doppio invio | chiave di idempotenza + vincolo unico |
| Un file toccato da tre persone per motivi diversi | troppe responsabilità | separare per committente |
| Parametro booleano che sceglie il comportamento | astrazione sbagliata | tornare indietro e separare |
| Lo stesso numero in due tabelle | fonti di verità multiple | calcolare, o aggiornare in transazione |
| La stessa regola in client e server | duplicazione vera | una sola definizione, condivisa |
| Sei file da aprire per capire una funzione | eccesso di astrazione | appiattire |
La sintesi che uso: i principi non sono regole da applicare, sono nomi per problemi ricorrenti. Servono a riconoscere una situazione più in fretta, non a decidere al posto vostro. La decisione richiede di sapere quale cambiamento è probabile che arrivi — e quella è conoscenza del dominio, non del linguaggio.