L'injection de dépendances en Go : modèles et bonnes pratiques

Maîtrisez les modèles DI pour un code Go testable

Sommaire

Injection de dépendances (DI) est un modèle de conception fondamental qui favorise un code propre, testable et maintenable dans les applications Go.

Que vous construissiez des API REST, que vous implémentiez des modèles de base de données multi-locataires, ou que vous travailliez avec des bibliothèques ORM, comprendre l’injection de dépendances améliorera considérablement la qualité de votre code.

go-dependency-injection

Qu’est-ce que l’injection de dépendances ?

L’injection de dépendances est un modèle de conception où les composants reçoivent leurs dépendances depuis des sources externes plutôt que de les créer internement. Cette approche découple les composants, rendant votre code plus modulaire, testable et maintenable.

En Go, l’injection de dépendances est particulièrement puissante en raison de la philosophie de conception basée sur les interfaces du langage. La satisfaction implicite des interfaces en Go signifie que vous pouvez facilement remplacer les implémentations sans modifier le code existant.

Pourquoi utiliser l’injection de dépendances en Go ?

Meilleure testabilité : En injectant les dépendances, vous pouvez facilement remplacer les implémentations réelles par des doubles ou des mocks. Cela vous permet d’écrire des tests unitaires rapides, isolés et qui ne nécessitent pas de services externes comme les bases de données ou les API.

Meilleure maintenabilité : Les dépendances deviennent explicites dans votre code. Lorsque vous regardez une fonction de constructeur, vous voyez immédiatement ce dont un composant a besoin. Cela rend le codebase plus facile à comprendre et à modifier.

Couplage faible : Les composants dépendent d’abstractions (interfaces) plutôt que d’implémentations concrètes. Cela signifie que vous pouvez changer les implémentations sans affecter le code dépendant.

Flexibilité : Vous pouvez configurer différentes implémentations pour différents environnements (développement, test, production) sans modifier votre logique métier.

Injection par constructeur : La manière Go

La façon la plus courante et idiomatique d’implémenter l’injection de dépendances en Go est via les fonctions de constructeur. Ces fonctions sont généralement nommées NewXxx et acceptent les dépendances en tant que paramètres.

Exemple basique

Voici un exemple simple démontrant l’injection par constructeur :

// Définir une interface pour le repository
type UserRepository interface {
    FindByID(id int) (*User, error)
    Save(user *User) error
}

// Le service dépend de l'interface du repository
type UserService struct {
    repo UserRepository
}

// Le constructeur injecte la dépendance
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

// Les méthodes utilisent la dépendance injectée
func (s *UserService) GetUser(id int) (*User, error) {
    return s.repo.FindByID(id)
}

Ce modèle rend clair que UserService nécessite un UserRepository. Vous ne pouvez pas créer un UserService sans fournir un repository, ce qui empêche les erreurs de runtime dues à des dépendances manquantes.

Plusieurs dépendances

Lorsqu’un composant a plusieurs dépendances, ajoutez-les simplement comme paramètres du constructeur :

type EmailService interface {
    Send(to, subject, body string) error
}

type Logger interface {
    Info(msg string)
    Error(msg string, err error)
}

type OrderService struct {
    repo        OrderRepository
    emailSvc    EmailService
    logger      Logger
    paymentSvc  PaymentService
}

func NewOrderService(
    repo OrderRepository,
    emailSvc EmailService,
    logger Logger,
    paymentSvc PaymentService,
) *OrderService {
    return &OrderService{
        repo:       repo,
        emailSvc:   emailSvc,
        logger:     logger,
        paymentSvc: paymentSvc,
    }
}

Conception d’interfaces pour l’injection de dépendances

L’un des principes clés lors de l’implémentation de l’injection de dépendances est le Principe d’inversion des dépendances (DIP) : les modules de haut niveau ne devraient pas dépendre des modules de bas niveau ; tous devraient dépendre d’abstractions.

En Go, cela signifie définir des interfaces petites et ciblées qui représentent uniquement ce dont votre composant a besoin :

// Bon : Petite interface ciblée
type PaymentProcessor interface {
    ProcessPayment(amount float64) error
}

// Mauvais : Interface grande avec des méthodes inutiles
type PaymentService interface {
    ProcessPayment(amount float64) error
    RefundPayment(id string) error
    GetPaymentHistory(userID int) ([]Payment, error)
    UpdatePaymentMethod(userID int, method PaymentMethod) error
    // ... beaucoup plus de méthodes
}

L’interface plus petite suit le Principe de ségrégation des interfaces — les clients ne devraient pas dépendre de méthodes qu’ils n’utilisent pas. Cela rend votre code plus flexible et plus facile à tester.

Exemple concret : Abstraction de base de données

Lorsque vous travaillez avec des bases de données dans des applications Go, vous aurez souvent besoin d’abstraire les opérations de base de données. Voici comment l’injection de dépendances vous aide :

// Interface de base de données - abstraction de haut niveau
type DB interface {
    Query(ctx context.Context, query string, args ...interface{}) (Rows, error)
    Exec(ctx context.Context, query string, args ...interface{}) (Result, error)
    BeginTx(ctx context.Context) (Tx, error)
}

// Le repository dépend de l'abstraction
type UserRepository struct {
    db DB
}

func NewUserRepository(db DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
    query := "SELECT id, name, email FROM users WHERE id = $1"
    rows, err := r.db.Query(ctx, query, id)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    // ... parser les lignes
}

Ce modèle est particulièrement utile lors de l’implémentation des modèles de base de données multi-locataires, où vous pourriez avoir besoin de basculer entre différentes implémentations de base de données ou stratégies de connexion.

Le patron de la racine de composition

Le patron de la racine de composition est le lieu où vous assemblez toutes vos dépendances au point d’entrée de l’application (généralement main). Cela centralise la configuration des dépendances et rend le graphe des dépendances explicite.

func main() {
    // Initialiser les dépendances d'infrastructure
    db := initDatabase()
    logger := initLogger()
    
    // Initialiser les repositories
    userRepo := NewUserRepository(db)
    orderRepo := NewOrderRepository(db)
    
    // Initialiser les services avec les dépendances
    emailSvc := NewEmailService(logger)
    paymentSvc := NewPaymentService(logger)
    userSvc := NewUserService(userRepo, logger)
    orderSvc := NewOrderService(orderRepo, emailSvc, logger, paymentSvc)
    
    // Initialiser les gestionnaires HTTP
    userHandler := NewUserHandler(userSvc)
    orderHandler := NewOrderHandler(orderSvc)
    
    // Configurer les routes
    router := setupRouter(userHandler, orderHandler)
    
    // Démarrer le serveur
    log.Fatal(http.ListenAndServe(":8080", router))
}

Cette approche rend clair comment votre application est structurée et d’où proviennent les dépendances. Elle est particulièrement précieuse lors de la création de API REST en Go, où vous devez coordonner plusieurs couches de dépendances.

Cadres d’injection de dépendances

Pour les applications plus grandes avec des graphes de dépendances complexes, la gestion manuelle des dépendances peut devenir fastidieuse. Go dispose de plusieurs cadres de DI qui peuvent aider :

Google Wire (DI au moment de la compilation)

Wire est un outil de DI au moment de la compilation qui génère du code. Il est type-safe et n’a aucun surcoût au moment de l’exécution.

Installation :

go install github.com/google/wire/cmd/wire@latest

Exemple :

// wire.go
//go:build wireinject
// +build wireinject

package main

import "github.com/google/wire"

func InitializeApp() (*App, error) {
    wire.Build(
        NewDB,
        NewUserRepository,
        NewUserService,
        NewUserHandler,
        NewApp,
    )
    return &App{}, nil
}

Wire génère le code d’injection de dépendances au moment de la compilation, garantissant la sécurité des types et éliminant le surcoût d’exécution lié à la réflexion.

Uber Dig (DI au moment de l’exécution)

Dig est un cadre de DI au moment de l’exécution qui utilise la réflexion. Il est plus flexible mais a un certain coût au moment de l’exécution.

Exemple :

import "go.uber.org/dig"

func main() {
    container := dig.New()
    
    // Enregistrer les fournisseurs
    container.Provide(NewDB)
    container.Provide(NewUserRepository)
    container.Provide(NewUserService)
    container.Provide(NewUserHandler)
    
    // Appeler une fonction qui nécessite des dépendances
    err := container.Invoke(func(handler *UserHandler) {
        // Utiliser le gestionnaire
    })
    if err != nil {
        log.Fatal(err)
    }
}

Quand utiliser des cadres

Utilisez un cadre lorsque :

  • Votre graphe de dépendances est complexe avec de nombreux composants interdépendants
  • Vous avez plusieurs implémentations de la même interface qui doivent être sélectionnées selon la configuration
  • Vous souhaitez une résolution automatique des dépendances
  • Vous construisez une grande application où le câblage manuel devient source d’erreurs

Restez avec l’injection manuelle lorsque :

  • Votre application est petite à moyenne
  • Le graphe de dépendances est simple et facile à suivre
  • Vous souhaitez garder les dépendances minimales et explicites
  • Vous préférez le code explicite au code généré

Test avec l’injection de dépendances

L’un des principaux avantages de l’injection de dépendances est une meilleure testabilité. Voici comment la DI rend le test plus facile :

Exemple de test unitaire

// Implémentation de test
type mockUserRepository struct {
    users map[int]*User
    err   error
}

func (m *mockUserRepository) FindByID(id int) (*User, error) {
    if m.err != nil {
        return nil, m.err
    }
    return m.users[id], nil
}

func (m *mockUserRepository) Save(user *User) error {
    if m.err != nil {
        return m.err
    }
    m.users[user.ID] = user
    return nil
}

// Test avec le mock
func TestUserService_GetUser(t *testing.T) {
    mockRepo := &mockUserRepository{
        users: map[int]*User{
            1: {ID: 1, Name: "John", Email: "john@example.com"},
        },
    }
    
    service := NewUserService(mockRepo)
    
    user, err := service.GetUser(1)
    assert.NoError(t, err)
    assert.Equal(t, "John", user.Name)
}

Ce test s’exécute rapidement, n’a pas besoin d’une base de données et teste votre logique métier en isolation. Lorsque vous travaillez avec des bibliothèques ORM en Go, vous pouvez injecter des repositories de test pour tester la logique du service sans configuration de base de données.

Modèles courants et bonnes pratiques

1. Utiliser la ségrégation des interfaces

Gardez les interfaces petites et ciblées sur ce dont le client a vraiment besoin :

// Bon : Le client n'a besoin que de lire les utilisateurs
type UserReader interface {
    FindByID(id int) (*User, error)
    FindByEmail(email string) (*User, error)
}

// Interface séparée pour l'écriture
type UserWriter interface {
    Save(user *User) error
    Delete(id int) error
}

2. Retourner des erreurs depuis les constructeurs

Les constructeurs doivent valider les dépendances et retourner des erreurs si l’initialisation échoue :

func NewUserService(repo UserRepository) (*UserService, error) {
    if repo == nil {
        return nil, errors.New("user repository cannot be nil")
    }
    return &UserService{repo: repo}, nil
}

3. Utiliser le contexte pour les dépendances spécifiques à la requête

Pour les dépendances spécifiques à la requête (comme les transactions de base de données), transmettez-les via le contexte :

type ctxKey string

const dbKey ctxKey = "db"

func WithDB(ctx context.Context, db DB) context.Context {
    return context.WithValue(ctx, dbKey, db)
}

func DBFromContext(ctx context.Context) (DB, bool) {
    db, ok := ctx.Value(dbKey).(DB)
    return db, ok
}

4. Éviter l’injection excessive

Ne pas injecter des dépendances qui sont véritablement des détails d’implémentation internes. Si un composant crée et gère ses propres objets d’aide, cela est acceptable :

// Bon : L'helper interne n'a pas besoin d'injection
type UserService struct {
    repo UserRepository
    // Cache interne - n'a pas besoin d'injection
    cache map[int]*User
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{
        repo:  repo,
        cache: make(map[int]*User),
    }
}

5. Documenter les dépendances

Utilisez des commentaires pour documenter pourquoi les dépendances sont nécessaires et toute contrainte :

// UserService gère la logique métier liée aux utilisateurs.
// Il nécessite un UserRepository pour l'accès aux données et un Logger pour
// le suivi des erreurs. Le repository doit être thread-safe si utilisé
// dans des contextes concurrents.
func NewUserService(repo UserRepository, logger Logger) *UserService {
    // ...
}

Quand NE PAS utiliser l’injection de dépendances

L’injection de dépendances est un outil puissant, mais elle n’est pas toujours nécessaire :

Passer l’ID pour :

  • Des objets simples ou des structures de données
  • Des fonctions d’aide internes ou des utilitaires
  • Des scripts à usage unique ou des utilitaires petits
  • Lorsque l’instanciation directe est plus claire et plus simple

Exemple de cas où NE PAS utiliser l’ID :

// Structure simple - pas besoin d'ID
type Point struct {
    X, Y float64
}

func NewPoint(x, y float64) Point {
    return Point{X: x, Y: y}
}

// Utilitaire simple - pas besoin d'ID
func FormatCurrency(amount float64) string {
    return fmt.Sprintf("$%.2f", amount)
}

Intégration avec l’écosystème Go

L’injection de dépendances fonctionne de manière fluide avec d’autres modèles et outils Go. Lors de la création d’applications qui utilisent la bibliothèque standard Go pour le web scraping ou la génération de rapports PDF, vous pouvez injecter ces services dans votre logique métier :

type PDFGenerator interface {
    GenerateReport(data ReportData) ([]byte, error)
}

type ReportService struct {
    pdfGen PDFGenerator
    repo   ReportRepository
}

func NewReportService(pdfGen PDFGenerator, repo ReportRepository) *ReportService {
    return &ReportService{
        pdfGen: pdfGen,
        repo:   repo,
    }
}

Cela vous permet de remplacer les implémentations de génération de PDF ou d’utiliser des doubles lors des tests.

Conclusion

L’injection de dépendances est un pilier de l’écriture de code Go maintenable et testable. En suivant les modèles décrits dans cet article — injection par constructeur, conception basée sur les interfaces, et patron de la racine de composition — vous créerez des applications plus faciles à comprendre, à tester et à modifier.

Commencez avec l’injection manuelle par constructeur pour les applications petites à moyennes, et envisagez des cadres comme Wire ou Dig lorsque votre graphe de dépendances s’élargit. N’oubliez pas que l’objectif est la clarté et la testabilité, pas la complexité pour elle-même.

Pour plus de ressources sur le développement Go, consultez notre Feuille de calcul Go.

Liens utiles

Ressources externes