La pianificazione dello schema Postgres ti aiuta a definire entità, vincoli, indici e migrazioni prima della generazione del codice, riducendo le riscritture successive.

Se costruisci endpoint e modelli prima che la forma del database sia chiara, di solito finisci per riscrivere le stesse funzionalità due volte. L’app funziona per una demo, poi arrivano dati reali e casi limite e tutto comincia a sembrare fragile.
La maggior parte delle riscritture deriva da tre problemi prevedibili:
Ognuno di questi costringe a cambiamenti che si propagano nel codice, nei test e nelle app client.
Pianificare il tuo schema Postgres significa decidere prima il contratto dei dati, poi generare codice che lo rispetti. In pratica, questo significa scrivere entità, relazioni e le poche query importanti, quindi scegliere vincoli, indici e una strategia di migrazione prima che qualsiasi strumento generi tabelle e CRUD.
Questo è ancora più importante quando usi una piattaforma di generazione veloce come Koder.ai, dove puoi creare molto codice rapidamente. La generazione rapida è ottima, ma è molto più affidabile quando lo schema è definito. I modelli e gli endpoint generati richiederanno meno modifiche dopo.
Ecco cosa succede tipicamente quando salti la pianificazione:
Un buon piano di schema è semplice: una descrizione in linguaggio comune delle tue entità, una bozza di tabelle e colonne, i vincoli e gli indici principali, e una strategia di migrazione che ti permetta di cambiare le cose in sicurezza man mano che il prodotto cresce.
La pianificazione dello schema funziona meglio quando parti da ciò che l’app deve ricordare e da cosa le persone devono poter fare con quei dati. Scrivi l’obiettivo in 2–3 frasi in linguaggio semplice. Se non riesci a spiegarlo in modo chiaro, probabilmente creerai tabelle extra di cui non hai bisogno.
Poi concentrati sulle azioni che creano o modificano i dati. Queste azioni sono la vera fonte delle righe e rivelano cosa deve essere validato. Pensa in verbi, non in nomi.
Per esempio, un’app di prenotazioni potrebbe dover creare una prenotazione, riprogrammarla, cancellarla, rimborsarla e inviare messaggi al cliente. Questi verbi suggeriscono rapidamente cosa deve essere memorizzato (fasce orarie, cambi di stato, importi) prima ancora di dare un nome alla tabella.
Annota anche i percorsi di lettura, perché le letture influenzano la struttura e l’indicizzazione. Elenca le schermate o i report che le persone useranno e come vogliono filtrare/ordinare i dati: “Le mie prenotazioni” ordinate per data e filtrate per stato, ricerca admin per nome cliente o riferimento prenotazione, ricavo giornaliero per sede, e una vista di audit su chi ha cambiato cosa e quando.
Infine, nota i requisiti non funzionali che influenzano le scelte di schema, come cronologia di audit, soft deletes, separazione multi-tenant o regole di privacy (es. limitare chi può vedere i dettagli di contatto).
Se prevedi di generare codice dopo, queste note diventano ottimi prompt. Definiscono cosa è richiesto, cosa può cambiare e cosa deve essere ricercabile. Se usi Koder.ai, scrivere tutto questo prima di generare rende Planning Mode molto più efficace perché la piattaforma lavora su requisiti reali invece che su ipotesi.
Prima di toccare le tabelle, scrivi una descrizione in termini semplici di ciò che la tua app memorizza. Inizia elencando i nomi che ripeti: user, project, message, invoice, subscription, file, comment. Ogni sostantivo è una possibile entità.
Poi aggiungi una frase per entità che risponda: cos’è e perché esiste? Per esempio: “Un Project è uno spazio di lavoro che un utente crea per raggruppare lavoro e invitare altri.” Questo evita tabelle vaghe come data, items o misc.
La proprietà è la decisione successiva e influisce quasi su ogni query. Per ogni entità, decidi:
Ora decidi come identificherai i record. Gli UUID sono ottimi quando le righe possono essere create da molti posti (web, mobile, job) o quando non vuoi ID prevedibili. I bigint sono più piccoli e veloci. Se ti serve un identificatore leggibile, tienilo separato (es. un breve project_code unico all’interno di un account) invece di usarlo come chiave primaria.
Infine, scrivi le relazioni a parole prima di diagrammare: un utente ha molti progetti, un progetto ha molti messaggi, e gli utenti possono appartenere a molti progetti. Segna ogni collegamento come obbligatorio o opzionale, per esempio “un messaggio deve appartenere a un progetto” vs “una fattura può appartenere a un progetto”. Queste frasi diventeranno la fonte di verità per la generazione del codice in seguito.
Una volta che le entità sono chiare in linguaggio naturale, converti ciascuna in una tabella con colonne che rappresentano fatti reali da memorizzare.
Inizia con nomi e tipi su cui puoi essere coerente. Scegli pattern consistenti: nomi di colonna in snake_case, lo stesso tipo per la stessa idea e chiavi primarie prevedibili. Per i timestamp, preferisci timestamptz così i fusi orari non ti sorprendono più avanti. Per il denaro, usa numeric(12,2) (o salva i centesimi come intero) invece dei float.
Per i campi di stato, usa o un enum di Postgres o una colonna text con un CHECK constraint in modo che i valori consentiti siano controllati.
Decidi cosa è obbligatorio vs opzionale traducendo le regole in NOT NULL. Se un valore deve esistere perché la riga abbia senso, rendilo obbligatorio. Se è davvero sconosciuto o non applicabile, permetti i null.
Un set pratico di colonne di default da pianificare:
id (uuid o bigint, scegli un approccio e mantienilo coerente)created_at e updated_atdeleted_at solo se hai davvero bisogno di soft deletes e restorecreated_by quando servono tracce chiare di chi ha fatto cosaLe relazioni molti-a-molti dovrebbero quasi sempre diventare tabelle di join. Per esempio, se più utenti possono collaborare su un app, crea app_members con app_id e user_id, poi applica un vincolo di unicità sulla coppia per evitare duplicati.
Pensa alla cronologia fin da subito. Se sai che ti servirà versioning, pianifica una tabella immutabile come app_snapshots, dove ogni riga è una versione salvata collegata a apps tramite app_id e timbrata con created_at.
I vincoli sono le protezioni del tuo schema. Decidi quali regole devono essere vere a prescindere da quale servizio, script o tool admin tocchi il database.
Inizia con identità e relazioni. Ogni tabella ha bisogno di una primary key, e ogni campo “belongs to” dovrebbe essere una vera foreign key, non solo un intero che speri corrisponda.
Poi aggiungi unicità dove i duplicati causerebbero danni reali, come due account con la stessa email o due line item con lo stesso (order_id, product_id).
Vincoli ad alto valore da pianificare presto:
amount >= 0, status IN ('draft','paid','canceled') o rating BETWEEN 1 AND 5.Il comportamento di cascade è dove la pianificazione ti salva dopo. Chiediti cosa si aspetta la gente. Se un cliente viene eliminato, i suoi ordini di solito non dovrebbero sparire: questo punta a RESTRICT/NO ACTION e a mantenere la cronologia. Per dati dipendenti come i line item di un ordine, il CASCADE può avere senso perché gli item non hanno significato senza il genitore.
Quando poi genererai modelli e endpoint, questi vincoli diventeranno requisiti chiari: quali errori gestire, quali campi sono obbligatori e quali casi limite sono impossibili per progetto.
Gli indici devono rispondere a una domanda: cosa deve essere veloce per gli utenti reali.
Inizia dalle schermate e dalle chiamate API che prevedi di rilasciare subito. Una pagina di elenco che filtra per stato e ordina per nuovo ha esigenze diverse di una pagina di dettaglio che carica record correlati.
Scrivi 5–10 pattern di query in linguaggio naturale prima di scegliere un indice. Per esempio: “Mostra le mie fatture per gli ultimi 30 giorni, filtra per pagate/non pagate, ordina per created_at”, o “Apri un progetto e lista i suoi task per due_date.” Questo mantiene le scelte di indice radicate nell’uso reale.
Un primo insieme utile di indici spesso include le colonne foreign key usate per join, colonne usate come filtro comune (status, user_id, created_at) e uno o due indici composti per query multi-filtro stabili, come (account_id, created_at) quando filtri sempre per account_id e poi ordini per tempo.
L’ordine degli indici composti conta. Metti per primo la colonna su cui filtri più spesso (e che è più selettiva). Se filtri per tenant_id a ogni richiesta, spesso va in testa a molti indici.
Evita di indicizzare tutto “giusto per sicurezza”. Ogni indice aggiunge lavoro a INSERT e UPDATE e questo può danneggiare più di una query rara leggermente più lenta.
Pianifica la ricerca testuale separatamente. Se ti basta un semplice “contiene”, ILIKE può essere sufficiente all’inizio. Se la ricerca è centrale, valuta il full-text (tsvector) presto così non dovrai riprogettare dopo.
Uno schema non è “finito” quando crei le prime tabelle. Cambia ogni volta che aggiungi una funzionalità, correggi un errore o impari di più sui dati. Se decidi la strategia di migrazione fin dall’inizio, eviti riscritture dolorose dopo la generazione del codice.
Tieni una regola semplice: cambia il database a piccoli passi, una funzionalità alla volta. Ogni migrazione dovrebbe essere facile da revisionare e sicura da eseguire in ogni ambiente.
La maggior parte dei problemi proviene dal rinominare o rimuovere colonne, o dal cambiare tipi. Invece di fare tutto in un colpo solo, pianifica un percorso sicuro:
Questo richiede più passaggi, ma nella pratica è più veloce perché riduce outage e patch d’emergenza.
I dati seed fanno parte delle migrazioni. Decidi quali tabelle di riferimento sono “sempre lì” (ruoli, stati, paesi, tipi di piano) e rendile prevedibili. Metti insert e update per queste tabelle in migrazioni dedicate così ogni sviluppatore e ogni deploy ottiene gli stessi risultati.
Stabilisci le aspettative presto:
I rollback non sono sempre una perfetta “down migration”. A volte il miglior rollback è il ripristino da backup. Se usi Koder.ai, vale anche la pena decidere quando affidarsi a snapshot e rollback per recuperi rapidi, specialmente prima di cambi rischiosi.
Immagina una piccola SaaS dove le persone si uniscono in team, creano progetti e tracciano task.
Inizia elencando le entità e solo i campi necessari per il giorno uno:
Le relazioni sono semplici: un team ha molti progetti, un progetto ha molti task, e gli utenti si uniscono ai team tramite team_members. I task appartengono a un progetto e possono essere assegnati a un utente.
Ora aggiungi alcuni vincoli che prevengono bug che trovi di solito troppo tardi:
Gli indici dovrebbero corrispondere alle schermate reali. Per esempio, se la lista dei task filtra per project e state e ordina per newest, pianifica un indice come tasks (project_id, state, created_at DESC). Se “My tasks” è una vista chiave, un indice come tasks (assignee_user_id, state, due_date) può aiutare.
Per le migrazioni, mantieni il primo insieme sicuro e semplice: crea tabelle, chiavi primarie, foreign key e i vincoli unici core. Un buon cambiamento successivo è qualcosa da aggiungere dopo che l’uso lo dimostra, come introdurre soft delete (deleted_at) sui task e adattare gli indici “active tasks” per ignorare le righe cancellate.
La maggior parte delle riscritture avviene perché il primo schema manca di regole e dettagli sull’uso reale. Un buon pass di pianificazione non serve a creare diagrammi perfetti, ma a individuare le trappole presto.
Un errore comune è mantenere regole importanti solo nel codice applicativo. Se un valore deve essere unico, presente o entro un range, il database dovrebbe farlo rispettare. Altrimenti uno script, un nuovo endpoint o un import manuale possono aggirare la logica.
Un altro errore frequente è considerare gli indici un problema successivo. Aggiungerli dopo il lancio spesso diventa un lavoro di indovinare e puoi finire per indicizzare la cosa sbagliata mentre la query lenta reale è una join o un filtro su uno status.
Le tabelle molti-a-molti sono anche fonte di bug silenziosi. Se la tabella di join non evita duplicati, puoi memorizzare la stessa relazione due volte e passare ore a capire “perché questo utente ha due ruoli?”.
È anche facile creare tabelle prima e poi rendersi conto di aver bisogno di log di audit, soft deletes o cronologia eventi. Quegli aggiustamenti si ripercuotono su endpoint e report.
Infine, le colonne JSON sono allettanti per dati “flessibili”, ma tolgono controlli e rendono l’indicizzazione più difficile. JSON va bene per payload davvero variabili, non per campi core di business.
Prima di generare codice, esegui questa lista di controllo rapida:
Fermati e assicurati che il piano sia sufficientemente completo da generare codice senza correre dietro alle sorprese. L’obiettivo non è la perfezione. È intercettare i vuoti che causano riscritture: relazioni mancanti, regole poco chiare e indici che non corrispondono all’uso reale.
Usa questo come controllo pre-volo rapido:
amount >= 0 o stati consentiti).Un rapido test di sanità: immagina che un collega si unisca domani. Potrebbe costruire i primi endpoint senza chiedere “questo può essere null?” o “cosa succede alla delete?” ogni ora?
Quando il piano è leggibile e i flussi principali hanno senso su carta, trasformalo in qualcosa eseguibile: uno schema reale più migrazioni.
Inizia con una migrazione iniziale che crea tabelle, tipi (se usi enum) e i vincoli indispensabili. Mantieni la prima versione piccola ma corretta. Carica qualche dato seed e esegui le query che l’app userà davvero. Se un flusso è scomodo, correggi lo schema mentre la storia delle migrazioni è ancora corta.
Genera modelli e endpoint solo dopo che puoi testare alcune azioni end-to-end con lo schema in piedi (create, update, list, delete, più un’azione di business reale). La generazione del codice è più veloce quando tabelle, chiavi e naming sono abbastanza stabili da non dover rinominare tutto il giorno dopo.
Un loop pratico che mantiene le riscritture basse:
Decidi presto cosa validare nel database vs a livello API. Metti regole permanenti nel database (foreign key, unique, check). Mantieni regole morbide nell’API (feature flag, limiti temporanei e logica cross-table complessa che cambia spesso).
Se usi Koder.ai, un approccio sensato è mettersi d’accordo su entità e migrazioni in Planning Mode prima, poi generare il backend Go + PostgreSQL. Quando una modifica va storta, snapshot e rollback possono aiutarti a tornare rapidamente a una versione funzionante mentre rivedi il piano dello schema.
Pianifica prima lo schema. Fissa un contratto dati stabile (tabelle, chiavi, vincoli) così i modelli e gli endpoint generati non richiederanno continui rinomini e riscritture.
In pratica: scrivi le entità, le relazioni e le query principali, poi conferma vincoli, indici e migrazioni prima di generare il codice.
Scrivi 2–3 frasi che descrivono cosa l’app deve ricordare e cosa gli utenti devono poter fare.
Poi elenca:
Questo ti dà abbastanza chiarezza per progettare le tabelle senza sovraccaricare lo schema.
Inizia elencando i sostantivi che ripeti spesso (user, project, invoice, task). Per ognuno scrivi una frase: cos’è e perché esiste.
Se non riesci a descriverlo chiaramente, finirai probabilmente con tabelle vaghe come items o misc e te ne pentirai dopo.
Scegli una strategia ID coerente per tutto lo schema.
UUID: ottimo quando le righe possono essere create da molti posti (web/mobile/job) o non vuoi ID prevedibilibigint: più piccoli e un po’ più veloci, semplici quando tutto è creato dal serverSe ti serve un identificatore leggibile, aggiungi una colonna unica separata (es. project_code) invece di usarla come chiave primaria.
Decidilo per ogni relazione in base alle aspettative degli utenti e alla conservazione dei dati.
Default comuni:
RESTRICT/NO ACTION quando cancellare il genitore eliminerebbe record importanti (cliente → ordini)CASCADE quando le righe figlie non hanno senso senza il genitore (ordine → line item)Prendi questa decisione presto perché influisce sul comportamento delle API e sui casi limite.
Metti le regole permanenti nel database così tutti i writer (API, script, import, tool admin) sono forzati a rispettarle.
Priorità del giorno zero:
Parti dai pattern di query reali, non dai “magari dopo”.
Scrivi 5–10 query in linguaggio naturale (filtri + ordinamento), poi aggiungi indici per quelle:
status, user_id, created_atCrea una tabella di join con due foreign key e un vincolo UNIQUE composto.
Esempio:
team_members(team_id, user_id, role, joined_at)UNIQUE (team_id, user_id) per evitare duplicatiQuesto previene bug silenziosi come “perché questo utente appare due volte?” e mantiene le query pulite.
Di default:
timestamptz per i timestamp (meno sorprese sui fusi orari)numeric(12,2) o centesimi interi per denaro (evita float)CHECK constraintsMantieni i tipi coerenti tra le tabelle (stesso tipo per lo stesso concetto) così join e validazioni restano prevedibili.
Usa migrazioni piccole e revisionabili e evita cambiamenti distruttivi in un solo passaggio.
Percorso sicuro:
Decidi anche come gestire seed/reference data in modo che ogni ambiente sia coerente.
PRIMARY KEY su ogni tabellaFOREIGN KEY per ogni colonna “belongs to”UNIQUE dove i duplicati causano problemi reali (email, (team_id, user_id) nelle join)CHECK per regole semplici (importi non negativi, stati consentiti)NOT NULL per campi necessari al senso della riga(account_id, created_at))Evita di indicizzare tutto; ogni indice rallenta INSERT e UPDATE.