Usa sempre istruzioni parametrizzate, mai la concatenazione di stringhe
Motivazione: concatenare input utente nell'SQL è la causa radice della SQL injection, la vulnerabilità di database più dannosa e comune. Le istruzioni parametrizzate (prepared) separano la struttura della query dai dati così l'input non può mai cambiare il significato dell'istruzione, e permettono al database di riusare il piano. Fai: SELECT * FROM users WHERE email = $1 con un parametro vincolato. Non fare: costruire la stringa come "... WHERE email = '" + input + "'", mai, nemmeno per strumenti admin "fidati".
Definisci i vincoli nello schema, non solo nel codice applicativo
Motivazione: il database è l'ultima linea di difesa per l'integrità dei dati, e più applicazioni, script e migrazioni possono toccare le stesse tabelle. Codifica NOT NULL, UNIQUE, FOREIGN KEY e CHECK così i dati non validi fisicamente non possono essere memorizzati. Fai: aggiungere CHECK (amount >= 0) e una chiave esterna su order.customer_id. Non fare: affidarti solo a un livello di validazione applicativo che un import massivo o un secondo servizio aggirerà.
Indicizza per i tuoi reali pattern di query e verifica con EXPLAIN
Motivazione: gli indici aiutano solo se il pianificatore li usa, e ogni indice tassa scritture e spazio. Crea indici che corrispondono alle colonne e all'ordine usati nelle tue clausole WHERE, JOIN e ORDER BY, poi conferma con EXPLAIN che il piano sia passato da una scansione sequenziale a una scansione su indice. Fai: aggiungere un indice composito (customer_id, created_at) per una query che filtra per cliente e ordina per data. Non fare: aggiungere un indice per colonna "per sicurezza" senza mai controllare se qualcuno è usato.
Mantieni le transazioni brevi e limitate al lavoro che deve essere atomico
Motivazione: le transazioni lunghe trattengono lock e accumulano versioni di riga, aumentando contesa, deadlock e bloat. Apri una transazione subito prima delle scritture che devono riuscire o fallire insieme e fai commit prontamente. Fai: leggere i dati, calcolare, poi BEGIN, scrivere, COMMIT. Non fare: aprire una transazione, chiamare un'API esterna lenta o aspettare l'input dell'utente, e solo allora scrivere, tenendo i lock per secondi.
Normalizza prima, denormalizza solo con prove
Motivazione: uno schema normalizzato memorizza ogni fatto una sola volta, il che mantiene corrette le scritture ed evita le anomalie di aggiornamento. La denormalizzazione compra velocità in lettura ma crea dati ridondanti da mantenere coerenti. Fai: progettare in 3NF, poi denormalizzare uno specifico percorso di lettura caldo dopo che il profiling prova che il costo della join è il collo di bottiglia. Non fare: duplicare preventivamente colonne tra tabelle prima di aver misurato un reale problema di lettura.
Previeni il pattern delle query N+1 recuperando i dati correlati deliberatamente
Motivazione: caricare le associazioni in lazy riga per riga trasforma una singola richiesta logica in centinaia di query, cosa invisibile in sviluppo e devastante sotto carico. Decidi al confine della query esattamente quali dati correlati il caso d'uso richiede e recuperali in un solo round trip. Fai: usare JOIN FETCH, un entity graph o un include eager e ispezionare l'SQL generato. Non fare: iterare una collezione di padri e toccare un campo lazy dentro il ciclo o in un serializzatore.
Usa il giusto livello di isolamento per transazione, partendo dal basso
Motivazione: un isolamento più forte compra consistenza al costo di blocco e retry per fallimento di serializzazione, e la maggior parte delle operazioni non ne ha bisogno. Mantieni il default (di solito Read Committed) e alza a Repeatable Read o Serializable solo per le transazioni specifiche la cui correttezza dipende da esso. Fai: usare Serializable per un trasferimento di saldo che non deve interlacciarsi. Non fare: impostare globalmente Serializable e poi subire un crollo di throughput e retry frequenti ovunque.
Calibra il connection pool sul database, non sull'app
Motivazione: ogni connessione del pool è una sessione concorrente che il database deve servire, e molte meno connessioni dei thread di solito massimizza il throughput perché il collo di bottiglia è CPU e disco, non il numero di connessioni. Dimensiona il pool a partire dalla capacità del database e misura. Fai: partire piccolo (ad esempio un pool intorno al numero di core effettivi più il parallelismo del disco) e regolare con le metriche. Non fare: impostare il pool a centinaia di connessioni per istanza e sovraccaricare il server con context switching e contesa sui lock.
Esegui le modifiche allo schema tramite migrazioni versionate e reversibili
Motivazione: la DDL manuale ad hoc fa divergere gli ambienti e non può essere riprodotta o annullata. Usa uno strumento di migrazione (Flyway, Liquibase, Prisma Migrate, Alembic) così ogni modifica è versionata, ordinata e applicata in modo identico ovunque. Fai: scrivere una migrazione forward e testarla su una copia dei dati di produzione. Non fare: eseguire ALTER TABLE a mano in produzione sperando che lo staging corrisponda.
Aggiungi colonne e indici online per evitare downtime
Motivazione: una DDL ingenua può prendere lock lunghi che bloccano tutto il traffico su una tabella grande. Usa le opzioni online del motore. Fai: in PostgreSQL usare CREATE INDEX CONCURRENTLY e aggiungere colonne nullable (o colonne con default costante nelle versioni moderne) che sono modifiche di soli metadati; riempire i dati a lotti. Non fare: aggiungere una colonna NOT NULL con default calcolato a una tabella enorme in una sola istruzione durante le ore di picco, tenendo un lock esclusivo.
Scrivi predicati sargable così gli indici possano essere usati
Motivazione: applicare una funzione o aritmetica a una colonna indicizzata forza l'ottimizzatore a scansionare perché non può abbinare l'indice. Mantieni la colonna nuda su un lato del confronto. Fai: riscrivere WHERE YEAR(created_at) = 2026 come WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'. Non fare: avvolgere le colonne indicizzate in LOWER(), CAST() o funzioni di data nel predicato a meno di avere un indice su espressione corrispondente.
Memorizza il tempo come UTC in un tipo timezone-aware
Motivazione: i timestamp locali ingenui sono ambigui durante i cambi di ora legale e impossibili da confrontare correttamente tra regioni. Persisti gli istanti in un tipo timezone-aware normalizzato a UTC e converti solo per la visualizzazione. Fai: usare il timestamptz di PostgreSQL e formattare al fuso dell'utente nella UI. Non fare: memorizzare l'orario locale da orologio in un timestamp semplice e perdere l'offset.
Fai backup regolari e prova i ripristini
Motivazione: un backup che non hai mai ripristinato è una speranza, non un piano di recupero. Automatizza i backup logici e/o fisici, conserva il WAL/transaction log per il recupero point-in-time e ripristina periodicamente su un'istanza di prova per verificare l'integrità e misurare il tempo di ripristino. Fai: eseguire pg_dump/base backup schedulati più WAL archiviato e testare un ripristino completo ogni trimestre. Non fare: presumere che i dump notturni funzionino senza mai ripristinarne uno o misurare il tuo obiettivo di tempo di recupero.
Concedi il privilegio minimo e separa gli account applicativi
Motivazione: un servizio compromesso o difettoso non dovrebbe poter eliminare tabelle o leggere dati non correlati. Dai a ogni ruolo applicativo solo i privilegi di cui ha bisogno ed evita di usare un superutente per le connessioni di routine. Fai: concedere SELECT/INSERT/UPDATE/DELETE su tabelle specifiche al ruolo applicativo e mantenere i diritti DDL nel ruolo di migrazione. Non fare: connettere la web app come proprietario del database o superutente per comodità.