04 mai 2025·7 min de lecture

Pattern de dépôt CRUD générique en Go pour une couche de données propre

Apprenez un pattern CRUD pratique avec les génériques Go pour réutiliser la logique list/get/create/update/delete avec des contraintes lisibles, sans réflexion, et un code clair.

Pattern de dépôt CRUD générique en Go pour une couche de données propre

Pourquoi les dépôts CRUD deviennent désordonnés dans Go

Les dépôts CRUD commencent simples. Vous écrivez GetUser, puis ListUsers, puis la même chose pour Orders, puis Invoices. Après quelques entités, la couche données se transforme en une pile de quasi-copies où de petites différences sont faciles à manquer.

Ce qui se répète le plus souvent n'est pas le SQL lui‑même. C'est le flux autour : exécuter la requête, scanner les lignes, gérer le « not found », traduire les erreurs DB, appliquer des valeurs par défaut de pagination, et convertir les entrées aux bons types.

Les points chauds habituels sont familiers : code Scan dupliqué, motifs répétés avec context.Context et les transactions, boilerplate de LIMIT/OFFSET (parfois avec un total), la même vérification « 0 lignes signifie introuvable », et des variations copiées-collées de INSERT ... RETURNING id.

Quand la répétition devient trop coûteuse, beaucoup d'équipes se tournent vers la réflexion. Elle promet « écrire une fois » le CRUD : prendre n'importe quelle struct et la remplir depuis des colonnes au runtime. Le coût arrive plus tard. Le code lourd en réflexion est plus difficile à lire, l'aide de l'IDE se dégrade, et les échecs passent du temps de compilation au runtime. De petits changements, comme renommer un champ ou ajouter une colonne nullable, deviennent des surprises qui n'apparaissent qu'en test ou en production.

La réutilisation avec sécurité de type signifie partager le flux CRUD sans renoncer aux conforts quotidiens de Go : signatures claires, types vérifiés au compilateur et autocomplétion utile. Avec les génériques, on peut réutiliser des opérations comme Get[T] et List[T] tout en exigeant de chaque entité les éléments qu'on ne peut pas deviner, par exemple comment scanner une ligne en T.

Ce pattern concerne volontairement la couche d'accès aux données. Il garde le SQL et le mapping consistants et ennuyeux. Il ne cherche pas à modéliser votre domaine, appliquer des règles métier ou remplacer la logique de service.

Objectifs de conception (et ce que cela ne résoudra pas)

Un bon pattern de dépôt rend l'accès courant à la base prévisible. Vous devriez pouvoir lire un dépôt et voir rapidement ce qu'il fait, quel SQL il exécute et quelles erreurs il peut renvoyer.

Les objectifs sont simples :

  • Sécurité de type de bout en bout (IDs, entités et résultats ne sont pas any)
  • Contraintes qui expliquent l'intention sans gymnastique de types
  • Moins de boilerplate sans cacher un comportement important
  • Comportement cohérent pour List/Get/Create/Update/Delete

Les non-objectifs sont tout aussi importants. Ceci n'est pas un ORM. Il ne doit pas deviner les mappings de champs, joindre automatiquement des tables ou modifier silencieusement les requêtes. Le « mapping magique » vous ramène à la réflexion, aux tags et aux cas limites.

Supposez un workflow SQL normal : SQL explicite (ou un petit query builder), frontières de transaction claires, et des erreurs sur lesquelles on peut raisonner. Quand quelque chose échoue, l'erreur doit indiquer « not found », « conflict/violation de contrainte » ou « DB inaccessible », pas un vague « repository error ».

La décision clé est ce qui devient générique versus ce qui reste spécifique à l'entité.

  • Générique : le flux (exécuter la requête, scanner, retourner des valeurs typées, traduire les erreurs communes).
  • Spécifique par entité : le sens (noms de tables, colonnes sélectionnées, jointures et chaînes SQL).

Forcer toutes les entités dans un système universel de filtres rend généralement le code plus difficile à lire que d'écrire deux requêtes claires.

Choisir la contrainte d'entité et d'ID

La plupart du code CRUD se répète parce que chaque table a les mêmes mouvements de base, mais chaque entité a ses propres champs. Avec les génériques, l'astuce est de partager une petite forme et de garder tout le reste libre.

Commencez par décider ce que le dépôt doit vraiment connaître d'une entité. Pour beaucoup d'équipes, la seule pièce universelle est l'ID. Les timestamps peuvent être utiles, mais ils ne sont pas universels, et les forcer dans chaque type rend souvent le modèle artificiel.

Choisir un type d'ID avec lequel vous pouvez vivre

Le type d'ID doit correspondre à la façon dont vous identifiez les lignes en base. Certains projets utilisent int64, d'autres des UUID sous forme de string. Si vous voulez une approche qui marche partout, rendez l'ID générique. Si tout votre codebase utilise un seul type d'ID, le garder fixe peut raccourcir les signatures.

Une bonne contrainte par défaut pour les IDs est comparable, puisque vous comparez des IDs, les utilisez comme clés de map et les passez partout.

type ID interface {
	comparable
}

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

Garder les contraintes d'entité minimales

Évitez d'exiger des champs via de l'embedded struct ou des astuces de jeux de types comme ~struct{...}. Elles paraissent puissantes, mais couplent vos types métier au pattern de dépôt.

Exigez seulement ce dont le flux CRUD partagé a besoin :

  • Obtenir et définir l'ID (pour que Create puisse le retourner, et Update/Delete le cibler)

Si vous ajoutez plus tard des fonctionnalités comme le soft delete ou le verrouillage optimiste, ajoutez de petites interfaces opt-in (par exemple GetVersion/SetVersion) et utilisez-les seulement où nécessaire. Les petites interfaces ont tendance à bien vieillir.

Une interface de dépôt générique qui reste lisible

Une interface de dépôt doit décrire ce dont votre app a besoin, pas ce que la base fait. Si l'interface ressemble trop au SQL, elle fuit les détails partout.

Gardez l'ensemble de méthodes petit et prévisible. Mettez context.Context en premier, puis l'entrée principale (ID ou données), puis des options groupées dans une 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
}

Pour List, évitez d'imposer un type de filtre universel. Les filtres sont là où les entités diffèrent le plus. Une approche pratique est d'avoir des types de requête par entité plus une petite forme de pagination partagée que vous pouvez imbriquer.

type Page struct {
	Limit  int
	Offset int
}

La gestion des erreurs est souvent le point bruyant des dépôts. Décidez d'avance sur quelles erreurs les appelants peuvent faire des branches. Un petit ensemble suffit généralement :

  • ErrNotFound quand un ID n'existe pas
  • ErrConflict pour violation d'unicité ou conflit de version
  • ErrValidation quand l'entrée est invalide (si le dépôt valide)

Tout le reste peut être une erreur basse-niveau enveloppée (DB/réseau). Avec ce contrat, le code de service peut traiter « not found » ou « conflict » sans se soucier du stockage sous-jacent.

Comment éviter la réflexion tout en réutilisant le flux

Rendre la logique explicite
Placez les règles métier dans le Business Process Editor plutôt que disperser des vérifications dans les dépôts.
Construire la logique

La réflexion arrive souvent quand vous voulez qu'un morceau de code « remplisse n'importe quelle struct ». Ça masque les erreurs jusqu'au runtime et rend les règles obscures.

Une approche plus propre est de ne réutiliser que les parties ennuyeuses : exécuter des requêtes, parcourir des rows, vérifier le nombre de lignes affectées et envelopper les erreurs de façon cohérente. Gardez le mapping vers/depuis les structs explicite.

Séparer les responsabilités : SQL, mapping, flux partagé

Une séparation pratique ressemble à ceci :

  • Par entité : conserver les chaînes SQL et l'ordre des paramètres en un seul endroit
  • Par entité : écrire de petites fonctions de mapping qui scannent une ligne dans la struct concrète
  • Générique : fournir le flux partagé qui exécute une requête et appelle le mapper

Ainsi, les génériques réduisent la répétition sans cacher ce que fait la base.

Voici une petite abstraction qui vous laisse passer soit *sql.DB soit *sql.Tx sans que le reste du code ne s'en soucie :

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
}

Ce que les génériques doivent (et ne doivent pas) faire

La couche générique ne doit pas « comprendre » votre struct. Elle doit accepter des fonctions explicites que vous fournissez, comme :

  • un binder qui transforme les inputs en arguments de requête
  • un scanner qui lit les colonnes dans une entité

Par exemple, un repository Customer peut stocker le SQL en constantes (selectByID, insert, update) et implémenter scanCustomer(rows) une fois. Un List générique peut gérer la boucle, le contexte et l'enveloppement d'erreurs, tandis que scanCustomer garde le mapping sûr et évident.

Si vous ajoutez une colonne, mettez à jour le SQL et le scanner. Le compilateur vous aide à trouver ce qui a cassé.

Pas à pas : implémenter le pattern

L'objectif est un flux réutilisable pour List/Get/Create/Update/Delete tout en gardant chaque dépôt honnête sur son SQL et son mapping de lignes.

1) Définir les types de base

Commencez avec le moins de contraintes possible. Choisissez un type d'ID adapté et une interface de dépôt prévisible.

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) Ajouter un exécuteur pour DB et transactions

Ne liez pas le code générique directement à *sql.DB ou *sql.Tx. Dépendre d'une petite interface d'exécuteur qui correspond aux méthodes appelées permet aux services de passer une DB ou une transaction sans changer le code du dépôt.

3) Construire une base générique avec le flux partagé

Créez un baseRepo[E,K] qui stocke l'exécuteur et quelques champs fonctionnels. La base gère les parties ennuyeuses : appeler la requête, mapper le « not found », vérifier les lignes affectées et renvoyer des erreurs cohérentes.

4) Implémenter les pièces spécifiques à l'entité

Chaque repository d'entité fournit ce qui ne peut pas être générique :

  • le SQL pour list/get/create/update/delete
  • une fonction scan(row) qui convertit une ligne en E
  • une fonction bind(...) qui retourne les args de la requête

5) Câbler les repos concrets et les utiliser depuis les services

Construisez NewCustomerRepo(exec Executor) *CustomerRepo qui embed ou wrap baseRepo. La couche service dépend de l'interface Repo[E,K] et décide quand démarrer une transaction ; le dépôt utilise simplement l'exécuteur fourni.

Gérer List/Get/Create/Update/Delete sans surprises

Réduire le boilerplate en toute sécurité
Construisez des backends typés et des écrans UI tout en gardant le code facile à étendre.
Commencer

Un dépôt générique n'aide que si chaque méthode se comporte de la même façon partout. La plupart des problèmes viennent de petites incohérences : un dépôt trie par created_at, un autre par id; l'un renvoie nil, nil pour une ligne manquante, l'autre renvoie une erreur.

List : pagination et ordre qui ne bougent pas

Choisissez un style de pagination et appliquez-le. La pagination par offset (limit/offset) est simple et convient aux écrans d'admin. La pagination par curseur est meilleure pour l'infinite scroll, mais elle nécessite une clé de tri stable.

Quelle que soit la méthode, rendez l'ordre explicite et stable. Trier par une colonne unique (souvent la clé primaire) empêche les éléments de sauter entre les pages quand de nouvelles lignes apparaissent.

Get : un signal « introuvable » clair

Get(ctx, id) doit renvoyer une entité typée et un signal clair d'enregistrement manquant, généralement une erreur sentinel partagée comme ErrNotFound. Évitez de retourner une entité valeur zéro avec une erreur nil. Les appelants ne peuvent pas distinguer “manquant” de “champs vides”.

Faites de l'habitude : le type contient les données, l'erreur contient l'état.

Avant d'implémenter des méthodes, prenez quelques décisions et gardez-les :

  • Create : acceptez-vous un type d'entrée (sans ID, sans timestamps) ou une entité complète ? Beaucoup préfèrent Create(ctx, in CreateX) pour empêcher le réglage de champs serveur.
  • Update : remplacement complet ou patch ? Si c'est un patch, n'utilisez pas de structs simples où les zéros sont ambigus. Utilisez des pointeurs, des types nullables ou un field mask explicite.
  • Delete : hard delete ou soft delete ? Si soft, Get cache-t-il les supprimés par défaut ?

Décidez aussi ce que les méthodes d'écriture renvoient. Des options peu surprises : renvoyer l'entité mise à jour (après les valeurs par défaut DB) ou renvoyer seulement l'ID plus ErrNotFound si rien n'a changé.

Stratégie de tests pour les parties génériques et spécifiques

Du schéma à l'application complète
Concevez des données PostgreSQL, puis générez des applications web et mobiles par-dessus.
Commencer

Cette approche ne paye que si elle est fiable. Séparez les tests comme le code : testez les helpers partagés une fois, puis testez le SQL et le scanning de chaque entité séparément.

Traitez les pièces partagées comme des fonctions pures quand c'est possible : validation de pagination, mapping des clés de tri vers des colonnes autorisées, construction de fragments WHERE. Celles-ci peuvent être couvertes par des tests unitaires rapides.

Pour les requêtes list, les tests table-driven marchent bien parce que les cas limites sont tout le problème. Couvrez : filtres vides, clé de tri inconnue, limit 0, limite au‑delà du max, offset négatif, et frontières de page où vous récupérez une ligne en plus.

Concentrez les tests par entité sur le spécifique : le SQL attendu et comment les lignes se scannent dans le type. Utilisez un mock SQL ou une petite base de test et assurez-vous que le scan gère les nulls, les colonnes optionnelles et les conversions de type.

Si votre pattern gère les transactions, testez commit/rollback avec un faux exécuteur léger qui enregistre les appels et simule des erreurs :

  • Begin retourne un exécuteur scoped tx
  • en cas d'erreur, rollback est appelé exactement une fois
  • en cas de succès, commit est appelé exactement une fois
  • si commit échoue, l'erreur est renvoyée telle quelle

Vous pouvez aussi ajouter de petits “contract tests” que chaque dépôt doit passer : create puis get renvoie les mêmes données, update modifie les champs attendus, delete fait que get renvoie not found, et list renvoie un ordre stable avec les mêmes inputs.

Erreurs communes et pièges

Les génériques donnent envie de tout généraliser. L'accès aux données est rempli de petites différences, et ces différences comptent.

Quelques pièges fréquents :

  • Trop généraliser jusqu'à ce que chaque méthode prenne un énorme sac d'options (joins, recherche, permissions, soft deletes, cache). À ce stade, vous avez créé un second ORM.
  • Contraintes trop astucieuses. Si le lecteur doit décoder des jeux de types pour comprendre ce qu'une entité doit implémenter, l'abstraction coûte plus qu'elle ne rapporte.
  • Traiter les types d'entrée comme le modèle DB. Quand Create et Update prennent la même struct que vous scannez depuis les rows, les détails DB fuient vers les handlers et tests, et les changements de schéma se répercutent dans tout l'app.
  • Comportement silencieux dans List : tri instable, defaults incohérents ou règles de pagination qui varient par entité.
  • Gestion de l'introuvable qui force les appelants à parser des chaînes d'erreur au lieu d'utiliser errors.Is.

Un exemple concret : ListCustomers renvoie les clients dans un ordre différent à chaque fois parce que le dépôt n'a pas mis d'ORDER BY. La pagination duplique ou saute alors des enregistrements entre requêtes. Rendre l'ordre explicite (même par clé primaire) et garder des defaults consistants.

Checklist rapide avant adoption

Maintenir la cohérence des entrées
Séparez clairement les inputs Create et Update pour que les handlers restent prévisibles à l'évolution des schémas.
Essayer maintenant

Avant de généraliser les dépôts dans tous les packages, assurez-vous que cela supprime la répétition sans cacher un comportement important de la base.

Commencez par la cohérence. Si un dépôt prend context.Context et qu'un autre non, ou si l'un renvoie (T, error) tandis qu'un autre renvoie (*T, error), la douleur arrive partout : services, tests et mocks.

Assurez-vous que chaque entité a toujours un endroit évident pour son SQL. Les génériques doivent réutiliser le flux (scan, validation, mapping d'erreurs), pas disperser des fragments de requêtes.

Une série de vérifications préventives :

  • Une convention de signature unique pour List/Get/Create/Update/Delete
  • Une règle unique et prévisible pour l'introuvable utilisée par tous les dépôts
  • Un ordre stable documenté et testé pour les listes
  • Une façon propre d'exécuter le même code sur *sql.DB et *sql.Tx (via une interface executor)
  • Une frontière claire entre le code générique et les règles d'entité (validation et règles métier restent hors de la couche générique)

Si vous construisez des outils internes rapidement avec AppMaster puis exportez ou étendez le code Go généré, ces vérifications aident à garder la couche données prévisible et testable.

Un exemple réaliste : construire un dépôt Customer

Voici une petite forme de Customer repo qui reste typée sans être trop astucieuse.

Commencez par le modèle stocké. Gardez l'ID fortement typé pour ne pas le confondre avec d'autres IDs :

type CustomerID int64

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

Séparez ce que l'API accepte de ce que vous stockez. C'est là que Create et Update doivent différer.

type CreateCustomerInput struct {
	Name   string
	Status string
}

type UpdateCustomerInput struct {
	Name   *string
	Status *string
}

Votre base générique peut gérer le flux partagé (exécuter le SQL, scanner, mapper les erreurs), tandis que le Customer repo possède le SQL et le mapping Customer-spécifique. Depuis la couche service, l'interface reste propre :

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)
}

Pour List, traitez filtres et pagination comme un objet de requête à part entière. Cela rend les appels lisibles et réduit le risque d'oublier des limites.

type CustomerListQuery struct {
	Status *string // filtre
	Search *string // nom contient
	Limit  int
	Offset int
}

À partir de là, le pattern s'adapte bien : copiez la structure pour l'entité suivante, gardez les inputs séparés des modèles stockés, et gardez le scan explicite pour que les changements restent visibles et aidés par le compilateur.

FAQ

Quel problème les dépôts CRUD génériques en Go résolvent-ils vraiment ?

Utilisez les génériques pour réutiliser le flux (exécution de requête, boucle de scan, gestion du "not found", valeurs par défaut de pagination, mappage d'erreurs), mais gardez le SQL et le mapping des lignes explicites par entité. Cela réduit la répétition sans transformer la couche données en une “magie” d'exécution qui casse silencieusement.

Pourquoi éviter les helpers CRUD basés sur la réflexion pour « scanner n'importe quelle struct » ?

La réflexion masque les règles de mapping et déplace les erreurs au runtime. Vous perdez les vérifications du compilateur, l'aide de l'IDE devient moins efficace, et de petits changements de schéma deviennent des surprises. Avec les génériques + des fonctions de scan explicites, vous conservez la sécurité de type tout en partageant les parties répétitives.

Quelle contrainte sensée choisir pour le type d'ID ?

Un bon défaut est comparable, parce que les IDs sont comparés, utilisés comme clés de map et passés partout. Si votre système utilise plusieurs styles d'ID (par ex. int64 et UUID strings), rendre le type d'ID générique évite d'imposer un choix à tous les dépôts.

Que devrait inclure la contrainte d'entité (et que ne devrait-elle pas inclure) ?

Gardez-la minimale : habituellement juste ce dont le flux CRUD partagé a besoin, comme GetID() et SetID(). Évitez d'imposer des champs communs via de l'embedded struct ou des jeux de types sophistiqués, car cela couple vos types métier au pattern de dépôt et rend les refactors difficiles.

Comment prendre en charge proprement à la fois *sql.DB et *sql.Tx ?

Utilisez une petite interface d'exécuteur (souvent appelée DBTX) qui contient uniquement les méthodes que vous appelez, comme QueryContext, QueryRowContext et ExecContext. Ainsi votre code de dépôt peut fonctionner contre *sql.DB ou *sql.Tx sans duplication ni branchement.

Quelle est la meilleure façon de signaler « introuvable » depuis Get ?

Retournez une erreur partagée comme ErrNotFound. Retourner une valeur zéro avec une erreur nil oblige l'appelant à deviner si l'entité est absente ou juste vide. Un sentinel partagé permet d'utiliser errors.Is pour gérer correctement l'absence.

Create/Update devraient-ils prendre la struct complète de l'entité ?

Séparez les inputs des modèles stockés. Préférez Create(ctx, CreateInput) et Update(ctx, id, UpdateInput) pour empêcher les appelants de fixer des champs contrôlés par le serveur (ID, timestamps). Pour des patchs, utilisez des pointeurs (ou types nullable) pour distinguer “non fourni” de “mis à zéro”.

Comment éviter que la pagination de List ne retourne des résultats inconsistants ?

Fixez un ORDER BY stable et explicite à chaque fois, idéalement sur une colonne unique comme la clé primaire. Sans ordre garanti, la pagination peut sauter ou dupliquer des enregistrements entre deux requêtes quand de nouvelles lignes apparaissent.

Quel contrat d'erreurs les dépôts doivent-ils fournir aux services ?

Exposez un petit ensemble d'erreurs sur lesquelles les appelants peuvent faire des branches, comme ErrNotFound et ErrConflict, et enveloppez tout le reste avec le contexte de l'erreur basse-niveau. Ne forcez pas les appelants à parser des chaînes ; visez des checks errors.Is plus un message utile pour les logs.

Comment tester un pattern de dépôt générique sans le sur-tester ?

Testez les helpers partagés une fois (normalisation de pagination, mapping du not-found, vérification des lignes affectées), puis testez séparément le SQL et le scan de chaque entité. Ajoutez de petits tests « contrat » par dépôt : create-then-get renvoie les mêmes données, update modifie les champs attendus, delete fait que get retourne ErrNotFound, et list respecte un ordre stable.

Facile à démarrer
Créer quelque chose d'incroyable

Expérimentez avec AppMaster avec un plan gratuit.
Lorsque vous serez prêt, vous pourrez choisir l'abonnement approprié.

Démarrer