Depuis des années, les développeurs Go se vantent de la simplicité et du code explicite. Cependant, à mesure que les applications deviennent plus complexes, le code répétitif (boilerplate) pour les opérations de base de données encombre souvent les bases de code. Avant Go 1.18, l'implémentation d'une couche de référentiel générique nécessitait soit des gymnastiques d'interface fastidieuses, soit la copie et le collage de logique entre différentes entités. Avec l'introduction des génériques, Go est devenu un langage capable de supporter des modèles de conception robustes sans sacrifier sa philosophie fondamentale de simplicité.
Dans cet article, nous explorerons comment implémenter un modèle de référentiel générique en Go moderne. Nous créerons une abstraction réutilisable qui vous permet d'effectuer des opérations CRUD (Create, Read, Update, Delete) courantes sur n'importe quelle entité, réduisant drastiquement le code répétitif tout en maintenant la sécurité des types.
Pourquoi utiliser un référentiel générique ?
Le modèle Repository agit comme un médiateur entre les couches de domaine et de mappage des données. Son objectif principal est de séparer la logique métier de la logique d'accès aux données. Dans une application Go typique, vous pourriez vous retrouver à écrire des structures de requête presque identiques pour les modèles User, Post et Comment.
Sans génériques, atteindre cette réutilisabilité implique souvent de passer des interfaces ou d'utiliser la réflexion (reflection), ce qui peut entraîner des erreurs à l'exécution ou une surcharge de performance. Les génériques nous permettent de définir une seule interface ou structure qui fonctionne avec n'importe quel type T, garantissant que la couche de base de données sait exactement quels champs existent au moment de la compilation.
Définir l'interface générique
Commençons par définir une interface générique qui décrit les opérations de base. Cette interface sera paramétrée par un type T. Pour cet exemple, nous supposerons que nous utilisons un ORM hypothétique ou un pilote SQL, mais le modèle reste cohérent quelle que soit la bibliothèque de données sous-jacente.
package repository
import (
"context"
"errors"
)
// Entity représente une entité de base de données générique avec un champ ID.
// Dans une application réelle, vous pourriez utiliser une interface plus complexe ou exiger
// des balises spécifiques pour le mappage ORM.
type Entity interface {
GetID() any
}
// ErrNotFound est renvoyé lorsqu'une entité n'est pas trouvée.
var ErrNotFound = errors.New("entity not found")
// GenericRepository définit les opérations CRUD standard pour tout type d'entité T.
type GenericRepository[T Entity] interface {
Create(ctx context.Context, entity T) error
GetByID(ctx context.Context, id any) (T, error)
Update(ctx context.Context, entity T) error
Delete(ctx context.Context, id any) error
List(ctx context.Context) ([]T, error)
}
Remarquez que T doit satisfaire la contrainte Entity. Cela garantit que tout type passé à notre référentiel possède une méthode GetID(), ce qui est crucial pour les opérations de mise à jour et de suppression.
Implémentation du référentiel concret
Maintenant, implémentons un type concret qui satisfait cette interface. Bien que nous n'écrivions pas un ORM complet ici, nous pouvons démontrer la structure de l'implémentation. Nous utiliserons une carte (map) comme magasin de données fictif pour garder l'exemple centré sur le modèle plutôt que sur la connectivité à la base de données.
package repository
import (
"context"
"fmt"
"sync"
)
// MockRepository est une implémentation générique à des fins de démonstration.
type MockRepository[T Entity] struct {
mu sync.Mutex
Store map[any]T
}
// NewMockRepository crée une nouvelle instance du référentiel fictif.
func NewMockRepository[T Entity]() *MockRepository[T] {
return &MockRepository[T]{
Store: make(map[any]T),
}
}
// Create ajoute une nouvelle entité au magasin.
func (r *MockRepository[T]) Create(ctx context.Context, entity T) error {
r.mu.Lock()
defer r.mu.Unlock()
id := entity.GetID()
if _, exists := r.Store[id]; exists {
return fmt.Errorf("entity with ID %v already exists", id)
}
r.Store[id] = entity
return nil
}
// GetByID récupère une entité par son ID.
func (r *MockRepository[T]) GetByID(ctx context.Context, id any) (T, error) {
r.mu.Lock()
defer r.mu.Unlock()
entity, exists := r.Store[id]
if !exists {
var zero T
return zero, ErrNotFound
}
return entity, nil
}
// List retourne toutes les entités du magasin.
func (r *MockRepository[T]) List(ctx context.Context) ([]T, error) {
r.mu.Lock()
defer r.mu.Unlock()
entities := make([]T, 0, len(r.Store))
for _, v := range r.Store {
entities = append(entities, v)
}
return entities, nil
}
En utilisant MockRepository[T Entity], nous garantissons que le compilateur impose que seuls les types implémentant Entity puissent être utilisés avec ce référentiel. Si vous essayez de créer un référentiel pour une structure qui manque d'une méthode GetID(), le code ne compilera pas.
Exemple d'utilisation pratique
Voyons comment utiliser ce modèle dans une couche de service. Définissons une structure utilisateur simple :
type User struct {
ID int64
Name string
}
func (u User) GetID() any {
return u.ID
}
Vous pouvez maintenant instancier un référentiel spécifiquement pour les utilisateurs :
userRepo := repository.NewMockRepository[User]()
err := userRepo.Create(context.Background(), User{ID: 1, Name: "Alice"})
if err != nil {
log.Fatal(err)
}
user, err := userRepo.GetByID(context.Background(), 1)
if err == repository.ErrNotFound {
log.Println("User not found")
} else if err != nil {
log.Fatal(err)
}
fmt.Printf("Retrieved user: %+v\n", user)
Conclusion
L'implémentation de modèles de référentiel génériques en Go offre un moyen puissant d'abstraire la logique de la base de données tout en tirant parti de la sécurité des types que les développeurs Go apprécient. Cela élimine le code répétitif, facilite les tests en permettant des implémentations fictives simples et fournit un contrat clair pour l'accès aux données.
Bien que cet exemple utilise un magasin fictif, les mêmes principes s'appliquent lors de l'intégration avec des bibliothèques comme GORM, sqlx ou ent. En adoptant les génériques, vous pouvez écrire des applications Go plus expressives, maintenables et évolutives.