04 mag 2025·6 min di lettura

Pattern repository CRUD generici in Go per uno strato dati pulito

Impara un pattern pratico di repository CRUD con generici in Go per riusare logica di list/get/create/update/delete con vincoli leggibili, senza reflection e con codice chiaro.

Pattern repository CRUD generici in Go per uno strato dati pulito

Perché i repository CRUD diventano disordinati in Go

I repository CRUD partono semplici. Scrivi GetUser, poi ListUsers, poi lo stesso per Orders, poi Invoices. Dopo poche entità lo strato dati si trasforma in un mucchio di copie quasi identiche dove piccole differenze sono facili da perdere.

Quello che si ripete più spesso non è tanto la SQL. È il flusso circostante: eseguire la query, fare lo scan delle righe, gestire il “non trovato”, mappare gli errori del DB, applicare valori predefiniti di paginazione e convertire gli input nei tipi giusti.

I punti caldi abituali sono familiari: codice Scan duplicato, pattern ripetuti di context.Context e transazioni, boilerplate per LIMIT/OFFSET (a volte con total count), lo stesso controllo “0 righe significa non trovato” e varianti copiate di INSERT ... RETURNING id.

Quando la ripetizione diventa fastidiosa, molte squadre ricorrono alla reflection. Promette “scrivi una volta”: prendi qualunque struct e riempila dalle colonne a runtime. Il conto però arriva dopo. Il codice basato sulla reflection è più difficile da leggere, il supporto IDE peggiora e i fallimenti si spostano dal compile time al runtime. Piccole modifiche, come rinominare un campo o aggiungere una colonna nullable, diventano sorprese che si vedono solo nei test o in produzione.

Il riuso type-safe significa condividere il flusso CRUD senza rinunciare ai comfort quotidiani di Go: firme chiare, tipi controllati dal compilatore e autocomplete che funziona davvero. Con i generici puoi riusare operazioni come Get[T] e List[T] richiedendo al tempo stesso a ogni entità di fornire le parti che non si possono indovinare, ad esempio come scansionare una riga in T.

Questo pattern riguarda deliberatamente il livello di accesso ai dati. Mantiene SQL e mapping coerenti e noiosi. Non cerca di modellare il dominio, imporre regole di business o sostituire la logica a livello di servizio.

Obiettivi di design (e cosa non cerca di risolvere)

Un buon pattern repository rende l'accesso al database quotidiano prevedibile. Dovresti poter leggere un repository e capire rapidamente cosa fa, quale SQL esegue e quali errori può restituire.

Gli obiettivi sono semplici:

  • Sicurezza di tipo end-to-end (ID, entità e risultati non sono any)
  • Vincoli che spiegano l'intento senza ginnastica sui tipi
  • Meno boilerplate senza nascondere comportamenti importanti
  • Comportamento coerente per List/Get/Create/Update/Delete

I non-goals sono altrettanto importanti. Questo non è un ORM. Non dovrebbe indovinare i mapping dei campi, fare join automatici di tabelle o modificare le query in modo silenzioso. Il “mapping magico” ti riporterebbe alla reflection, ai tag e ai casi limite.

Assumi un workflow SQL normale: SQL esplicito (o un sottile query builder), confini di transazione chiari ed errori su cui puoi ragionare. Quando qualcosa fallisce, l'errore dovrebbe dirti “not found”, “conflict/violazione di vincolo” o “DB non disponibile”, non un vago “repository error”.

La decisione chiave è cosa diventa generico e cosa rimane per entità.

  • Generico: il flusso (eseguire query, fare scan, restituire valori tipati, tradurre errori comuni).
  • Per entità: il significato (nomi delle tabelle, colonne selezionate, join e stringhe SQL).

Forzare tutte le entità in un unico sistema universale di filtri di solito rende il codice più difficile da leggere che scrivere due query chiare.

Scegliere i vincoli per entità e ID

La maggior parte del codice CRUD si ripete perché ogni tabella fa le stesse mosse di base, ma ogni entità ha i suoi campi. Con i generici, il trucco è condividere una piccola forma e lasciare libero il resto.

Inizia decidendo cosa il repository deve davvero conoscere di un'entità. Per molte squadre l'unico elemento universale è l'ID. I timestamp possono essere utili, ma non sono universali, e imporli in ogni tipo spesso fa sembrare il modello finto.

Scegli un tipo ID con cui puoi convivere

Il tipo ID dovrebbe corrispondere a come identifichi le righe nel DB. Alcuni progetti usano int64, altri stringhe UUID. Se vuoi un approccio che funzioni ovunque, rendi generico l'ID. Se tutto il codicebase usa un solo tipo di ID, tenerlo fisso può accorciare le firme.

Un buon vincolo di default per gli ID è comparable, dato che confronterai gli ID, li userai come chiavi di mappe e li passerai in giro.

type ID interface {
	comparable
}

type Entity[IDT ID] interface {
	GetID() IDT
	SetID(IDT)
}

Mantieni i vincoli dell'entità minimi

Evita di richiedere campi tramite embedding di struct o trucchi con insiemi di tipi come ~struct{...}. Sembrano potenti, ma accoppiano i tuoi tipi di dominio al pattern del repository.

Invece, richiedi solo ciò che il flusso CRUD condiviso necessita:

  • Recuperare e impostare l'ID (così Create può restituirlo e Update/Delete possono usarlo come target)

Se poi aggiungi funzionalità come soft delete o optimistic locking, aggiungi piccole interfacce opt-in (per esempio GetVersion/SetVersion) e usale solo dove servono. Le interfacce piccole tendono a invecchiare bene.

Un'interfaccia di repository generica che resta leggibile

Un'interfaccia repository dovrebbe descrivere ciò di cui la tua app ha bisogno, non ciò che il database fa. Se l'interfaccia sembra SQL, perde dettagli ovunque.

Mantieni il set di metodi piccolo e prevedibile. Metti context.Context per primo, poi l'input principale (ID o dati), poi i knob opzionali raggruppati in una struct.

type Repository[T any, ID comparable, CreateIn any, UpdateIn any, ListQ any] interface {
	Get(ctx context.Context, id ID) (T, error)
	List(ctx context.Context, q ListQ) ([]T, error)
	Create(ctx context.Context, in CreateIn) (T, error)
	Update(ctx context.Context, id ID, in UpdateIn) (T, error)
	Delete(ctx context.Context, id ID) error
}

Per List, evita di imporre un tipo filtro universale. I filtri sono dove le entità differiscono di più. Un approccio pratico è usare tipi di query per entità più una piccola shape di paginazione condivisa che puoi includere.

type Page struct {
	Limit  int
	Offset int
}

La gestione degli errori è dove i repository spesso diventano rumorosi. Decidi fin da subito su quali errori i chiamanti possono fare branching. Un set semplice solitamente funziona:

  • ErrNotFound quando un ID non esiste
  • ErrConflict per violazioni di unicità o clash di versione
  • ErrValidation quando l'input è invalido (solo se il repo valida)

Tutto il resto può essere un errore low-level wrappato (DB/rete). Con quel contratto, il codice di servizio può gestire “not found” o “conflict” senza curarsi se oggi lo storage è PostgreSQL o altro domani.

Come evitare la reflection continuando a riusare il flusso

Standardizza il comportamento CRUD
Crea endpoint list e get coerenti con comportamento stabile tra le entità.
Genera API

La reflection entra in scena quando vuoi che un pezzo di codice "riempia qualunque struct". Questo nasconde gli errori fino al runtime e rende le regole poco chiare.

Un approccio più pulito è riusare solo le parti noiose: eseguire query, loopare le righe, controllare le righe interessate e wrappare gli errori in modo coerente. Mantieni il mapping verso e da struct esplicito.

Dividi responsabilità: SQL, mapping, flusso condiviso

Una separazione pratica è la seguente:

  • Per entità: conserva le stringhe SQL e l'ordine dei parametri in un posto
  • Per entità: scrivi piccole funzioni di mapping che fanno lo scan delle righe nella struct concreta
  • Generico: fornisci il flusso condiviso che esegue la query e chiama il mapper

Così i generici riducono la ripetizione senza nascondere cosa fa il database.

Ecco una piccola astrazione che ti permette di passare sia *sql.DB sia *sql.Tx senza che il resto del codice se ne accorga:

type DBTX interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

Cosa dovrebbero (e non dovrebbero) fare i generici

Lo strato generico non dovrebbe cercare di “capire” la tua struct. Invece dovrebbe accettare funzioni esplicite che fornisci, come:

  • un binder che trasforma gli input in argomenti della query
  • uno scanner che legge le colonne in un'entità

Per esempio, un repository Customer può conservare SQL come costanti (selectByID, insert, update) e implementare scanCustomer(rows) una sola volta. Un List generico può gestire il loop, il contesto e il wrapping degli errori, mentre scanCustomer mantiene il mapping type-safe e ovvio.

Se aggiungi una colonna, aggiorni SQL e lo scanner. Il compilatore ti aiuterà a trovare cosa si è rotto.

Passo dopo passo: implementare il pattern

L'obiettivo è un flusso riusabile per List/Get/Create/Update/Delete mantenendo ogni repository onesto riguardo a SQL e mapping delle righe.

1) Definire i tipi core

Inizia con il minor numero di vincoli possibile. Scegli un tipo ID che funzioni per il tuo codice e un'interfaccia repository prevedibile.

type ID interface{ ~int64 | ~string }

type Repo[E any, K ID] interface {
	Get(ctx context.Context, id K) (E, error)
	List(ctx context.Context, limit, offset int) ([]E, error)
	Create(ctx context.Context, e *E) error
	Update(ctx context.Context, e *E) error
	Delete(ctx context.Context, id K) error
}

2) Aggiungere un executor per DB e transazioni

Non legare il codice generico direttamente a *sql.DB o *sql.Tx. Dipendi da una piccola interfaccia executor che corrisponde ai metodi che chiami (QueryContext, ExecContext, QueryRowContext). I servizi possono passare un DB o una transazione senza cambiare il codice del repository.

3) Costruire una base generica con il flusso condiviso

Crea un baseRepo[E,K] che conserva l'executor e alcuni campi funzione. La base gestisce le parti noiose: chiamare la query, mappare il “not found”, controllare le righe interessate e restituire errori coerenti.

4) Implementare i pezzi specifici di entità

Ogni repository di entità fornisce ciò che non può essere generico:

  • SQL per list/get/create/update/delete
  • una funzione scan(row) che converte una riga in E
  • una funzione bind(...) che restituisce gli argomenti della query

5) Collegare i repo concreti e usarli dai servizi

Costruisci NewCustomerRepo(exec Executor) *CustomerRepo che incorpora o avvolge baseRepo. Il livello di servizio dipende dall'interfaccia Repo[E,K] e decide quando iniziare una transazione; il repository usa semplicemente l'executor che gli è stato fornito.

Gestire List/Get/Create/Update/Delete senza sorprese

Dallo schema all'app completa
Progetta dati PostgreSQL, poi genera app web e mobile sopra di essi.
Inizia a costruire

Un repository generico aiuta solo se ogni metodo si comporta allo stesso modo ovunque. Il dolore maggiore viene da piccole incoerenze: un repo ordina per created_at, un altro per id; uno restituisce nil, nil per righe mancanti, un altro restituisce un errore.

List: paginazione e ordinamento che non oscillano

Scegli uno stile di paginazione e applicalo in modo coerente. La paginazione offset (limit/offset) è semplice e funziona bene per schermate amministrative. La paginazione cursore è migliore per lo scrolling infinito, ma richiede una chiave di ordinamento stabile.

Qualunque scelta, rendi esplicito e stabile l'ordinamento. Ordinare per una colonna unica (spesso la primary key) evita che gli elementi saltino tra le pagine quando compaiono nuove righe.

Get: un segnale “not found” chiaro

Get(ctx, id) dovrebbe restituire un'entità tipata e un chiaro segnale di record mancante, solitamente un errore sentinella condiviso come ErrNotFound. Evita di restituire una entità a valore zero con errore nil. I chiamanti non saprebbero distinguere “mancante” da “campi vuoti”.

Abitua a questo: il tipo è per i dati, l'errore è per lo stato.

Prima di implementare i metodi, prendi alcune decisioni e mantienile coerenti:

  • Create: accetti un tipo input (senza ID, senza timestamp) o un'entità completa? Molte squadre preferiscono Create(ctx, in CreateX) per evitare che i chiamanti impostino campi gestiti dal server.
  • Update: è un replace completo o una patch? Se è una patch, non usare struct plain dove i valori zero sono ambigui. Usa puntatori, tipi nullable o una field mask esplicita.
  • Delete: hard delete o soft delete? Se è soft delete, decidi se Get nasconde di default le righe cancellate.

Decidi anche cosa restituiscono i metodi di scrittura. Opzioni a bassa sorpresa sono restituire l'entità aggiornata (dopo i valori di default DB) o restituire solo l'ID più ErrNotFound quando nulla è stato cambiato.

Strategia di test per parti generiche e specifiche

Rendi la logica esplicita
Metti le regole di business nell'Editor di Processi invece di spargerle nei repository.
Crea Logica

Questo approccio paga solo se è facile fidarsi del codice. Divide i test lungo le stesse linee del codice: testa i helper condivisi una volta, poi testa separatamente SQL e scanning per ogni entità.

Tratta i pezzi condivisi come piccole funzioni pure quando puoi, come la validazione della paginazione, la mappatura delle chiavi di ordinamento alle colonne ammesse o la costruzione di frammenti WHERE. Questi si coprono con unit test veloci.

Per le query list, i test tabellari funzionano bene perché i casi limite sono l'intero problema. Copri casi come filtri vuoti, chiavi di ordinamento sconosciute, limit 0, limit oltre il massimo, offset negativo e i confini di “pagina successiva” dove prendi una riga in più.

Mantieni i test per entità focalizzati su ciò che è davvero specifico dell'entità: la SQL che ti aspetti venga eseguita e come le righe si scansionano nel tipo entità. Usa un mock SQL o un DB di test leggero e assicurati che la logica di scan gestisca null, colonne opzionali e conversioni di tipo.

Se il tuo pattern supporta le transazioni, testa commit/rollback con un piccolo fake executor che registra le chiamate e simula errori:

  • Begin restituisce un executor scoped alla tx
  • in caso di errore, viene chiamato rollback esattamente una volta
  • in caso di successo, viene chiamato commit esattamente una volta
  • se il commit fallisce, l'errore viene restituito senza alterazioni

Puoi anche aggiungere piccoli “contract tests” che ogni repository deve passare: create poi get restituisce gli stessi dati, update modifica i campi intenzionati, delete fa ritornare not found e list ritorna ordine stabile con gli stessi input.

Errori comuni e trappole

I generici invitano alla tentazione di costruire un repository unico per tutto. L'accesso ai dati è pieno di piccole differenze e quelle differenze contano.

Alcune trappole comuni:

  • Generalizzare troppo finché ogni metodo prende una grossa borsa di opzioni (join, ricerca, permessi, soft delete, caching). A quel punto hai costruito un secondo ORM.
  • Vincoli troppo ingegnosi. Se i lettori devono decodificare insiemi di tipi per capire cosa un'entità deve implementare, l'astrazione costa più di quanto risparmia.
  • Trattare i tipi di input come il modello DB. Quando Create e Update prendono la stessa struct che scansionate dalle righe, i dettagli DB filtrano nei handler e nei test, e i cambi di schema si propagano nell'app.
  • Comportamento silenzioso in List: ordinamento instabile, default incoerenti o regole di paging che variano per entità.
  • Gestione del not-found che costringe i chiamanti a parsare stringhe di errore invece di usare errors.Is.

Un esempio concreto: ListCustomers restituisce i clienti in ordine diverso ogni volta perché il repository non imposta un ORDER BY. La paginazione allora duplica o salta record tra richieste. Rendi esplicito l'ordinamento (anche se è solo per primary key) e mantieni i default coerenti.

Checklist rapida prima di adottare questo

Possiedi il tuo output Go
Genera codice Go e spostalo nel tuo repo quando hai bisogno del controllo completo.
Esporta Codice

Prima di applicare un repository generico in ogni package, assicurati che rimuoverà ripetizioni senza nascondere comportamenti importanti del database.

Inizia con la coerenza. Se un repo prende context.Context e un altro no, o uno restituisce (T, error) mentre un altro ( *T, error), il dolore appare ovunque: servizi, test e mock.

Assicurati che ogni entità abbia ancora un posto ovvio per la sua SQL. I generici dovrebbero riusare il flusso (scan, validare, mappare errori), non spargere query in frammenti di stringa.

Un rapido insieme di controlli che previene la maggior parte delle sorprese:

  • Una convenzione di firma per List/Get/Create/Update/Delete
  • Una regola prevedibile di not-found usata da ogni repo
  • Ordinamento stabile della list documentato e testato
  • Un modo pulito per eseguire lo stesso codice su *sql.DB e *sql.Tx (via un'interfaccia executor)
  • Un confine chiaro tra codice generico e regole di entità (validazione e controlli business restano fuori dallo strato generico)

Se stai costruendo strumenti interni rapidamente in AppMaster e poi esportando o estendendo il codice Go generato, questi controlli aiutano a mantenere lo strato dati prevedibile e facile da testare.

Un esempio realistico: costruire un repository Customer

Ecco una piccola forma di Customer repository che resta type-safe senza diventare cervellotica.

Inizia con un modello salvato. Mantieni l'ID fortemente tipizzato così non lo confondi con altri ID per errore:

type CustomerID int64

type Customer struct {
	ID     CustomerID
	Name   string
	Status string // "active", "blocked", "trial"...
}

Ora separa “cosa accetta l'API” da “cosa memorizzi”. Qui Create e Update dovrebbero differire.

type CreateCustomerInput struct {
	Name   string
	Status string
}

type UpdateCustomerInput struct {
	Name   *string
	Status *string
}

La tua base generica può gestire il flusso condiviso (eseguire SQL, scan, mappare errori), mentre il Customer repo possiede la SQL e il mapping specifici. Dal punto di vista del layer di servizio, l'interfaccia rimane pulita:

type CustomerRepo interface {
	Create(ctx context.Context, in CreateCustomerInput) (Customer, error)
	Update(ctx context.Context, id CustomerID, in UpdateCustomerInput) (Customer, error)
	Get(ctx context.Context, id CustomerID) (Customer, error)
	Delete(ctx context.Context, id CustomerID) error
	List(ctx context.Context, q CustomerListQuery) ([]Customer, int, error)
}

Per List, tratta filtri e paginazione come un oggetto di richiesta di prima classe. Mantiene i call site leggibili e rende più difficile dimenticare i limiti.

type CustomerListQuery struct {
	Status *string // filter
	Search *string // name contains
	Limit  int
	Offset int
}

Da qui, il pattern scala bene: copia la struttura per l'entità successiva, mantieni gli input separati dai modelli salvati e tieni lo scanning esplicito così i cambiamenti rimangono ovvi e il compilatore ti aiuta.

FAQ

Quale problema risolvono realmente i repository CRUD generici in Go?

Usa i generici per riutilizzare il flusso (query, loop di scan, gestione del "not found", valori predefiniti di paginazione, mappatura degli errori), ma mantieni SQL e mapping delle righe espliciti per entità. Questo riduce le ripetizioni senza trasformare il livello dati in una “magia” a runtime che si rompe silenziosamente.

Perché evitare helper CRUD basati su reflection che “scansionano qualsiasi struct”?

La reflection nasconde le regole di mapping e sposta i fallimenti al runtime. Si perdono i controlli del compilatore, il supporto dell'IDE peggiora e piccole modifiche allo schema diventano sorprese. Con i generici più funzioni scanner esplicite, mantieni la sicurezza di tipo pur condividendo le parti ripetitive.

Qual è un vincolo sensato per il tipo ID?

Un buon default è comparable, perché gli ID vengono confrontati, usati come chiavi di mappe e passati in giro ovunque. Se nel sistema usi stili diversi di ID (ad esempio int64 e stringhe UUID), rendere l'ID un tipo generico evita di imporre una scelta unica su tutti i repo.

Cosa dovrebbe includere il vincolo per l'entità (e cosa no)?

Tienilo minimale: di solito solo ciò che il flusso CRUD condiviso richiede, come GetID() e SetID(). Evita di forzare campi comuni via embedding o insiemi di tipi complessi, perché questo accoppia i tipi del dominio al pattern del repository e rende dolorose le refactorizzazioni.

Come supportare pulitamente sia *sql.DB che *sql.Tx?

Usa una piccola interfaccia executor (spesso chiamata DBTX) che include solo i metodi che chiami, ad esempio QueryContext, QueryRowContext e ExecContext. In questo modo il codice del repository può girare sia su *sql.DB sia su *sql.Tx senza branch o duplicazioni.

Qual è il modo migliore per segnalare “not found” da Get?

Restituisci un valore zero più un errore ErrNotFound (o, detto diversamente, segnala la mancanza tramite l'errore). Restituire un valore zero con nil obbliga i chiamanti a indovinare se la risorsa manca o ha campi vuoti. Un sentinel ErrNotFound consente a errors.Is di funzionare correttamente.

Create/Update dovrebbe prendere la struct completa dell'entità?

Separa gli input dai modelli persistiti. Preferisci Create(ctx, CreateInput) e Update(ctx, id, UpdateInput) così i chiamanti non possono impostare campi gestiti dal server come ID o timestamp. Per update parziali, usa puntatori (o tipi nullable) così puoi distinguere “non impostato” da “impostato a zero”.

Come evitare che la paginazione in List ritorni risultati incoerenti?

Imposta sempre un ORDER BY stabile ed esplicito, idealmente su una colonna unica come la primary key. Senza di esso, la paginazione può saltare o duplicare elementi tra richieste man mano che appaiono nuove righe o il piano cambia.

Quale contratto di errori dovrebbero fornire i repository ai servizi?

Esporre un piccolo set di errori su cui i caller possono fare branching, come ErrNotFound e ErrConflict, e wrappare tutto il resto con il contesto dell'errore DB sottostante. Non far parsare stringhe: punta a errors.Is più messaggi utili per i log.

Come testare un pattern di repository generico senza sovraccaricarlo di test?

Testa gli helper condivisi una sola volta (normalizzazione paginazione, mapping del not-found, controlli sulle righe interessate), poi testa separatamente SQL e scanning per ogni entità. Aggiungi piccoli “test di contratto” per ogni repository: create-then-get corrisponde, update modifica i campi previsti, delete fa ritornare ErrNotFound, e l'ordinamento della list è stabile.

Facile da avviare
Creare qualcosa di straordinario

Sperimenta con AppMaster con un piano gratuito.
Quando sarai pronto potrai scegliere l'abbonamento appropriato.

Iniziare