L'injection de dépendances : une méthode Python

Modèles de conception DI Python pour un code propre et testable

Sommaire

L’injection de dépendances (DI) est un motif de conception fondamental qui favorise un code propre, testable et maintenable dans les applications Python.

Que vous construisiez des API REST avec FastAPI, que vous implémentiez des tests unitaires, ou que vous travailliez avec des fonctions AWS Lambda, comprendre l’injection de dépendances améliorera significativement la qualité de votre code.

python packages

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

L’injection de dépendances est un motif 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 Python, l’injection de dépendances est particulièrement puissante grâce à la nature dynamique du langage et son support des protocoles, des classes de base abstraites et du typage canard. La flexibilité de Python signifie que vous pouvez implémenter des motifs DI sans cadres lourds, bien que des cadres soient disponibles lorsque nécessaire.

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

Amélioration de la testabilité : 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 qui sont rapides, isolés et n’ont pas besoin de services externes comme des bases de données ou des API. Lors de la rédaction de tests unitaires complets, l’injection de dépendances rend trivial le remplacement des dépendances réelles par des doubles de test.

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

Couplage faible : Les composants dépendent d’abstractions (protocoles ou ABC) 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. Cela est particulièrement utile lors du déploiement d’applications Python sur différentes plateformes, qu’il s’agisse de AWS Lambda ou de serveurs traditionnels.

Injection de constructeur : la manière Python

La manière la plus courante et idiomatique de mettre en œuvre l’injection de dépendances en Python est par l’injection de constructeur - accepter les dépendances comme paramètres dans la méthode __init__.

Exemple de base

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

from typing import Protocol
from abc import ABC, abstractmethod

# Définir un protocole pour le dépôt
class UserRepository(Protocol):
    def find_by_id(self, user_id: int) -> 'User | None':
        ...
    
    def save(self, user: 'User') -> 'User':
        ...

# Le service dépend du protocole du dépôt
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
    
    def get_user(self, user_id: int) -> 'User | None':
        return self.repo.find_by_id(user_id)

Ce motif rend clair que UserService nécessite un UserRepository. Vous ne pouvez pas créer un UserService sans fournir un dépôt, ce qui empêche les erreurs d’exécution dues à des dépendances manquantes.

Multiples dépendances

Lorsque qu’un composant a plusieurs dépendances, ajoutez-les simplement comme paramètres de constructeur :

class EmailService(Protocol):
    def send(self, to: str, subject: str, body: str) -> None:
        ...

class Logger(Protocol):
    def info(self, msg: str) -> None:
        ...
    
    def error(self, msg: str, err: Exception) -> None:
        ...

class OrderService:
    def __init__(
        self,
        repo: OrderRepository,
        email_svc: EmailService,
        logger: Logger,
        payment_svc: PaymentService,
    ):
        self.repo = repo
        self.email_svc = email_svc
        self.logger = logger
        self.payment_svc = payment_svc

Utilisation des protocoles et des classes de base abstraites

L’un des principes clés lors de la mise en œuvre 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 des modules de bas niveau ; les deux doivent dépendre des abstractions.

En Python, vous pouvez définir des abstractions en utilisant soit les Protocoles (typage structurel) soit les Classes de Base Abstraites (ABC) (typage nominal).

Protocoles (Python 3.8+)

Les protocoles utilisent le typage structurel - si un objet a les méthodes requises, il satisfait le protocole :

from typing import Protocol

class PaymentProcessor(Protocol):
    def process_payment(self, amount: float) -> bool:
        ...

# Toute classe avec la méthode process_payment satisfait ce protocole
class CreditCardProcessor:
    def process_payment(self, amount: float) -> bool:
        # Logique de carte de crédit
        return True

class PayPalProcessor:
    def process_payment(self, amount: float) -> bool:
        # Logique PayPal
        return True

# Le service accepte tout PaymentProcessor
class OrderService:
    def __init__(self, payment_processor: PaymentProcessor):
        self.payment_processor = payment_processor

Classes de base abstraites

Les ABC utilisent le typage nominal - les classes doivent explicitement hériter de l’ABC :

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> bool:
        pass

class CreditCardProcessor(PaymentProcessor):
    def process_payment(self, amount: float) -> bool:
        return True

Quand utiliser les Protocoles vs les ABC : Utilisez les Protocoles lorsque vous voulez un typage structurel et une flexibilité. Utilisez les ABC lorsque vous avez besoin d’imposer des hiérarchies d’héritage ou de fournir des implémentations par défaut.

Exemple du monde réel : Abstraction de base de données

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

from typing import Protocol, Optional
from contextlib import contextmanager

class Database(Protocol):
    @contextmanager
    def transaction(self):
        ...
    
    def execute(self, query: str, params: dict) -> None:
        ...
    
    def fetch_one(self, query: str, params: dict) -> Optional[dict]:
        ...

# Le dépôt dépend de l'abstraction
class UserRepository:
    def __init__(self, db: Database):
        self.db = db
    
    def find_by_id(self, user_id: int) -> Optional['User']:
        result = self.db.fetch_one(
            "SELECT * FROM users WHERE id = :id",
            {"id": user_id}
        )
        if result:
            return User(**result)
        return None

Ce motif vous permet de changer les implémentations de base de données (PostgreSQL, SQLite, MongoDB) sans modifier votre code de dépôt.

Le motif de la racine de composition

La 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.py ou votre usine d’application). Cela centralise la configuration des dépendances et rend le graphe de dépendances explicite.

def create_app() -> FastAPI:
    app = FastAPI()
    
    # Initialiser les dépendances d'infrastructure
    db = init_database()
    logger = init_logger()
    
    # Initialiser les dépôts
    user_repo = UserRepository(db)
    order_repo = OrderRepository(db)
    
    # Initialiser les services avec les dépendances
    email_svc = EmailService(logger)
    payment_svc = PaymentService(logger)
    user_svc = UserService(user_repo, logger)
    order_svc = OrderService(order_repo, email_svc, logger, payment_svc)
    
    # Initialiser les gestionnaires HTTP
    user_handler = UserHandler(user_svc)
    order_handler = OrderHandler(order_svc)
    
    # Connecter les routes
    app.include_router(user_handler.router)
    app.include_router(order_handler.router)
    
    return app

Cette approche rend clair comment votre application est structurée et d’où viennent les dépendances. Elle est particulièrement précieuse lorsque vous construisez des applications suivant les principes de l’architecture propre, 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. Python dispose de plusieurs cadres DI qui peuvent aider :

Dependency Injector

Dependency Injector est un cadre populaire qui fournit une approche basée sur un conteneur pour l’injection de dépendances.

Installation :

pip install dependency-injector

Exemple :

from dependency_injector import containers, providers
from dependency_injector.wiring import inject, Provide

class Container(containers.DeclarativeContainer):
    # Configuration
    config = providers.Configuration()
    
    # Base de données
    db = providers.Singleton(
        Database,
        connection_string=config.database.url
    )
    
    # Dépôts
    user_repository = providers.Factory(
        UserRepository,
        db=db
    )
    
    # Services
    user_service = providers.Factory(
        UserService,
        repo=user_repository
    )

# Utilisation
container = Container()
container.config.database.url.from_env("DATABASE_URL")
user_service = container.user_service()

Injector

Injector est une bibliothèque légère inspirée de Google’s Guice, axée sur la simplicité.

Installation :

pip install injector

Exemple :

from injector import Injector, inject, Module, provider

class DatabaseModule(Module):
    @provider
    def provide_db(self) -> Database:
        return Database(connection_string="...")

class UserModule(Module):
    @inject
    def __init__(self, repo: UserRepository):
        self.repo = repo

injector = Injector([DatabaseModule()])
user_service = injector.get(UserService)

Quand utiliser les 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 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 source d’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 à la magie du cadre

Test 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 l’ID facilite les tests :

Exemple de test unitaire

from unittest.mock import Mock
import pytest

# Implémentation mock pour les tests
class MockUserRepository:
    def __init__(self):
        self.users = {}
        self.error = None

    def find_by_id(self, user_id: int) -> Optional['User']:
        if self.error:
            raise self.error
        return self.users.get(user_id)

    def save(self, user: 'User') -> 'User':
        if self.error:
            raise self.error
        self.users[user.id] = user
        return user

# Test utilisant le mock
def test_user_service_get_user():
    mock_repo = MockUserRepository()
    mock_repo.users[1] = User(id=1, name="John", email="john@example.com")

    service = UserService(mock_repo)

    user = service.get_user(1)
    assert user is not None
    assert user.name == "John"

Ce test s’exécute rapidement, ne nécessite pas de base de données et teste votre logique métier en isolation. Lors du travail avec les tests unitaires en Python, l’injection de dépendances facilite la création de doubles de test et la vérification des interactions.

Utilisation des fixtures pytest

Les fixtures pytest fonctionnent parfaitement avec l’injection de dépendances :

@pytest.fixture
def mock_user_repository():
    return MockUserRepository()

@pytest.fixture
def user_service(mock_user_repository):
    return UserService(mock_user_repository)

def test_user_service_get_user(user_service, mock_user_repository):
    user = User(id=1, name="John", email="john@example.com")
    mock_user_repository.users[1] = user

    result = user_service.get_user(1)
    assert result.name == "John"

Modèles courants et bonnes pratiques

1. Utiliser la ségrégation d’interface

Gardez les protocoles et interfaces petits et concentrés sur ce dont le client a réellement besoin :

# Bon : Le client n'a besoin que de lire les utilisateurs
class UserReader(Protocol):
    def find_by_id(self, user_id: int) -> Optional['User']:
        ...
    
    def find_by_email(self, email: str) -> Optional['User']:
        ...

# Interface séparée pour l'écriture
class UserWriter(Protocol):
    def save(self, user: 'User') -> 'User':
        ...
    
    def delete(self, user_id: int) -> bool:
        ...

2. Valider les dépendances dans les constructeurs

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

class UserService:
    def __init__(self, repo: UserRepository):
        if repo is None:
            raise ValueError("le dépôt utilisateur ne peut pas être None")
        self.repo = repo

3. Utiliser les indications de type

Les indications de type rendent les dépendances explicites et aident avec le support de l’IDE et la vérification de type statique :

from typing import Protocol, Optional

class UserService:
    def __init__(
        self,
        repo: UserRepository,
        logger: Logger,
        email_service: EmailService,
    ) -> None:
        self.repo = repo
        self.logger = logger
        self.email_service = email_service

4. Éviter la sur-injection

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

# Bon : L'auxiliaire interne n'a pas besoin d'injection
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
        # Cache interne - n'a pas besoin d'injection
        self._cache: dict[int, User] = {}

5. Documenter les dépendances

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

class UserService:
    """UserService gère la logique métier liée aux utilisateurs.

    Args:
        repo: UserRepository pour l'accès aux données. Doit être thread-safe
            s'il est utilisé dans des contextes concurrents.
        logger: Logger pour le suivi des erreurs et le débogage.
    """
    def __init__(self, repo: UserRepository, logger: Logger):
        self.repo = repo
        self.logger = logger

Injection de dépendances avec FastAPI

FastAPI a un support intégré pour l’injection de dépendances via son mécanisme Depends :

from fastapi import FastAPI, Depends
from typing import Annotated

app = FastAPI()

def get_user_repository() -> UserRepository:
    db = get_database()
    return UserRepository(db)

def get_user_service(
    repo: Annotated[UserRepository, Depends(get_user_repository)]
) -> UserService:
    return UserService(repo)

@app.get("/users/{user_id}")
def get_user(
    user_id: int,
    service: Annotated[UserService, Depends(get_user_service)]
):
    user = service.get_user(user_id)
    if not user:
        raise HTTPException(status_code=404)
    return user

Le système d’injection de dépendances de FastAPI gère automatiquement le graphe de dépendances, facilitant la création d’API propres et maintenables.

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 :

Sauter l’ID pour :

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

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

# Classe de données simple - pas besoin d'ID
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

# Utilitaire simple - pas besoin d'ID
def format_currency(amount: float) -> str:
    return f"${amount:.2f}"

Intégration avec l’écosystème Python

L’injection de dépendances fonctionne de manière transparente avec d’autres modèles et outils Python. Lors de la construction d’applications utilisant les packages Python ou les frameworks de tests unitaires, vous pouvez injecter ces services dans votre logique métier :

class ReportService:
    def __init__(
        self,
        pdf_generator: PDFGenerator,
        repo: ReportRepository,
        logger: Logger,
    ):
        self.pdf_generator = pdf_generator
        self.repo = repo
        self.logger = logger

    def generate_report(self, report_id: int) -> bytes:
        report_data = self.repo.get_by_id(report_id)
        pdf = self.pdf_generator.generate(report_data)
        self.logger.info(f"Generated report {report_id}")
        return pdf

Cela permet de remplacer les implémentations ou d’utiliser des mocks lors des tests.

Injection de dépendances asynchrone

La syntaxe async/await de Python fonctionne bien avec l’injection de dépendances :

from typing import Protocol
import asyncio

class AsyncUserRepository(Protocol):
    async def find_by_id(self, user_id: int) -> Optional['User']:
        ...
    
    async def save(self, user: 'User') -> 'User':
        ...

class AsyncUserService:
    def __init__(self, repo: AsyncUserRepository):
        self.repo = repo
    
    async def get_user(self, user_id: int) -> Optional['User']:
        return await self.repo.find_by_id(user_id)
    
    async def get_users_batch(self, user_ids: list[int]) -> list['User']:
        tasks = [self.repo.find_by_id(uid) for uid in user_ids]
        results = await asyncio.gather(*tasks)
        return [u for u in results if u is not None]

Conclusion

L’injection de dépendances est un pilier pour écrire du code Python maintenable et testable. En suivant les modèles décrits dans cet article - injection de constructeur, conception basée sur les protocoles et le modèle de racine de composition - vous créerez des applications plus faciles à comprendre, tester et modifier.

Commencez par l’injection de constructeur manuelle pour les petites à moyennes applications, et envisagez des frameworks comme dependency-injector ou injector lorsque votre graphe de dépendances grandit. Rappelez-vous que l’objectif est la clarté et la testabilité, pas la complexité pour elle-même.

Pour plus de ressources de développement Python, consultez notre Python Cheatsheet pour une référence rapide sur la syntaxe Python et les modèles courants.

Liens utiles

Ressources externes