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

Maîtrisez les modèles d’Injection de Dépendances pour un code Go testable

Sommaire

L’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 soyez en train de construire des APIs REST, de mettre en œuvre des [modèles de bases de données multi-locataires](https://www.glukhov.org/fr/app-architecture/multitenancy/multi-tenant-database-patterns/ “Modèles de bases de données multi-locataires avec exemples en Go) ou de travailler avec des bibliothèques ORM, la compréhension de 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 de sources externes plutôt que de les créer en interne. 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 grâce à la philosophie de conception basée sur les interfaces du langage. La satisfaction implicite d’interfaces de Go signifie que vous pouvez facilement remplacer les implémentations sans modifier le code existant.

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

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

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

Couplage lâche : 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 méthode Go

La manière la plus courante et idiomatique d’implémenter l’injection de dépendances en Go est via des fonctions constructeurs. 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 d’exécution dues à des dépendances manquantes.

Dépendances multiples

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 doivent pas dépendre de modules de bas niveau ; les deux doivent dépendre d’abstractions.

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

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

// Mauvais : Grande interface 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 Segmentation 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 du monde réel : Abstraction de base de données

Lors du travail avec des bases de données dans les applications Go, vous aurez souvent besoin d’abstraire les opérations de base de données. Voici comment l’injection de dépendances 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()
    
    // ... analyser les lignes
}

Ce modèle est particulièrement utile lors de l’implémentation de modèles de bases de données multi-locataires, où vous pourriez avoir besoin de passer d’une implémentation de base de données à une autre ou de stratégies de connexion.

Le modèle Composition Root

Le Composition Root (ou Racine de Composition) est l’endroit 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 de 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 leurs 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 clarifie la structure de votre application et l’origine des dépendances. Elle est particulièrement précieuse lors de la construction d’APIs REST en Go, où vous devez coordonner plusieurs couches de dépendances.

Frameworks 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 possède plusieurs frameworks DI qui peuvent aider :

Google Wire (DI à la compilation)

Wire est un outil d’injection de dépendances à la compilation qui génère du code. Il est sûr au niveau des types et n’a pas de surcharge à 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 à la compilation, garantissant la sécurité des types et éliminant la surcharge de réflexion à l’exécution.

Uber Dig (DI à l’exécution)

Dig est un framework d’injection de dépendances à l’exécution qui utilise la réflexion. Il est plus flexible mais a un certain coût à 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)
    
    // Invoker la fonction qui a besoin de dépendances
    err := container.Invoke(func(handler *UserHandler) {
        // Utiliser le handler
    })
    if err != nil {
        log.Fatal(err)
    }
}

Quand utiliser des frameworks

Utilisez un framework 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 en fonction de la configuration
  • Vous voulez une résolution automatique des dépendances
  • Vous construisez une grande application où le câblage manuel devient sujet aux erreurs

Restez avec l’injection manuelle lorsque :

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

Tester avec l’injection de dépendances

L’un des principaux avantages de l’injection de dépendances est l’amélioration de la testabilité. Voici comment le DI facilite les tests :

Exemple de test unitaire

// Implémentation mock pour les tests
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 utilisant 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, ne nécessite pas de base de données et teste votre logique métier de manière isolée. Lors du travail avec des bibliothèques ORM en Go, vous pouvez injecter des repositories mock pour tester la logique des services sans configuration de base de données.

Modèles courants et bonnes pratiques

1. Utiliser la segmentation des interfaces

Gardez les interfaces petites et ciblées sur ce dont le client a réellement 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("le repository utilisateur ne peut pas être nil")
    }
    return &UserService{repo: repo}, nil
}

3. Utiliser le contexte pour les dépendances liées à la requête

Pour les dépendances spécifiques à une requête (comme les transactions de base de données), passez-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

N’injectez pas de dépendances qui sont de véritables détails d’implémentation internes. Si un composant crée et gère ses propres objets utilitaires, c’est acceptable :

// Bon : L'assistant 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 toutes contraintes :

// UserService gère la logique métier relative 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 s'il est 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 :

Sautez l’injection de dépendances pour :

  • Les objets valeur simples ou structures de données
  • Les fonctions utilitaires internes ou utilitaires
  • Les scripts ponctuels ou petits utilitaires
  • Lorsque l’instanciation directe est plus claire et plus simple

Exemple de quand NE PAS utiliser l’injection de dépendances :

// Structure simple - pas besoin d'injection de dépendances
type Point struct {
    X, Y float64
}

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

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

Intégration avec l’écosystème Go

L’injection de dépendances fonctionne parfaitement avec d’autres modèles et outils Go. Lors de la construction d’applications utilisant 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 mocks lors des tests.

Conclusion

L’injection de dépendances est un pilier pour écrire du 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 le modèle Composition Root — vous créerez des applications plus faciles à comprendre, tester et modifier.

Commencez par l’injection de dépendances manuelle via les constructeurs pour les applications de petite à moyenne taille, et envisagez des frameworks comme Wire ou Dig à mesure que votre graphe de dépendances se complexifie. N’oubliez pas que l’objectif est la clarté et la testabilité, pas la complexité pour elle-même. Si vous structurez votre application autour de gestionnaires de commandes et de requêtes, Implémentation de CQRS en Go montre comment la même approche d’injection par constructeur câble proprement et sans cérémonies les structures Application, Commands et Queries.

Pour plus de ressources de développement Go, consultez notre Aide-mémoire Go pour une référence rapide sur la syntaxe Go et les modèles courants.

Liens utiles

Ressources externes

S'abonner

Recevez de nouveaux articles sur les systèmes, l'infrastructure et l'ingénierie IA.