Aller au contenu

FastAPI + Injector : une approche simplifiée de l’injection de dépendances en Python

L'injection de dépendances ne devrait pas être une souffrance ! Je vous présente une alternative efficace pour réussir l'inversion de contrôle dans FastAPI, en utilisant la bibliothèque injector.

Photo by Diana Polekhina / Unsplash

Python est un langage dynamiquement typé, ce qui signifie que la vérification des types se fait à l'exécution—contrairement aux langages statiquement typés comme C# ou Java. Cette flexibilité permet des fonctionnalités puissantes telles que le monkey patching, le hot swapping et la création dynamique de classes. Pour cette raison, l’injection de dépendances (dependency injection ou DI en anglais) n’est pas strictement nécessaire dans les applications Python.

Cependant, la DI apporte une structure formelle à votre base de code, en particulier lorsque vous utilisez des modèles architecturaux comme le Domain-Driven Design (DDD) ou l’Architecture Hexagonale. Elle favorise la flexibilité en permettant de remplacer ou de modifier de grandes parties du code sans impacter le reste du système. Elle réduit également le couplage, rendant le système moins dépendant d’implémentations spécifiques. Cela simplifie les tests, car les modules peuvent être facilement simulés (mockés).

Pour toutes ces raisons, de nombreux développeurs, moi y compris, choisissent d’utiliser la DI dans les projets Python, notamment dans les API web.

Dans cet article, je vais partager la méthode que j’utilise pour implémenter l’injection de dépendances dans une API web typique construite avec FastAPI. Bien que FastAPI propose un système de DI intégré (que vous pouvez consulter ici), je trouve sa syntaxe quelque peu lourde. De plus, injecter des dépendances dans les couches internes d’une architecture DDD (comme la couche domaine) n’est pas aussi simple que dans la couche API. La définition d’objets singleton peut également être complexe.

C’est pourquoi j’ai développé une technique que je trouve plus facile à suivre, à implémenter et à maintenir, et je suis ravi de la partager avec vous.

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

L’injection de dépendances est un patron de conception utilisé pour mettre en œuvre le principe d’Inversion de Contrôle (inversion of control ou IoC) Ce principe est le dernier des principes SOLID définis par Robert C. Martin (aussi connu comme « Uncle Bob »), qui servent de guide pour écrire du code évolutif, maintenable et faiblement couplé (jetez un coup d'œil aux articles Késaco : Les principes SOLID? et Les principes SOLID, pour quoi faire ? de mon collègue Thibaut Rety pour connaître plus en profondeur ces principes).

La DI repose sur deux règles fondamentales :

  1. « Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les deux doivent dépendre d’abstractions. »
  2. « Les abstractions ne doivent pas dépendre des détails. Les détails doivent dépendre des abstractions. »

Décomposons ces règles :

Règle 1 : Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau

Un module de bas niveau est un composant qui effectue des opérations de base et qui a peu ou pas de dépendances. Ces modules sont généralement réutilisables et autonomes. À l’inverse, les modules de haut niveau encapsulent la logique métier et dépendent souvent d’autres composants (comme des dépôts, des services ou des API externes) pour fonctionner.

Voici un exemple de code qui viole cette règle :

class MyDatabase:
    ...
    def connect(self):
        print("Connexion à la base de données")
    ...

class UserService:
    def __init__(self):
        self.db = MyDatabase()  # <-- dépendance

    def get_user(self):
        self.db.connect()
        print("Récupération des données utilisateur")
    ...

def main():
    service = UserService()  # <-- dépendance
    service.get_user()
    ...

if __name__ == "__main__":
    main()

Dans cet exemple, MyDatabase est un module de bas niveau, et UserService est un module de haut niveau. La dépendance est créée directement dans UserService, ce qui signifie que si nous devons changer l’implémentation de MyDatabase, nous devrons probablement modifier toutes les classes qui l’utilisent—à commencer par UserService. Ce couplage fort rend le système difficile à maintenir et à faire évoluer.

De plus, il devient difficile de créer ou de simuler (mock) des instances de UserService sans s’assurer que MyDatabase fonctionne correctement. Cette chaîne de dépendances complique les tests et réduit la flexibilité.

Une meilleure approche consiste à dépendre d’une abstraction, comme une interface :

from abc import ABC, abstractmethod

# Abstraction de haut niveau
class IDatabase(ABC):

    @abstractmethod
    def connect(self):
        raise NotImplementedError

# Implémentation de bas niveau
class MyDatabase(IDatabase):
    ...
    def connect(self):
        print("Connexion à la base MySQL")
    ...

# Module de haut niveau dépendant de l’abstraction
class UserService:
    def __init__(self, db: IDatabase):  # <-- dépendance de l'abstraction
        self.db = db

    def get_user(self):
        self.db.connect()
        print("Récupération des données utilisateur")
    ...

def main():
    db = MyDatabase()  # <-- dépendance
    service = UserService(db)  # <-- dépendance
    service.get_user()
    ...

if __name__ == "__main__":
    main()

Dans ce second exemple, le couplage fort est résolu. Une interface IDatabase est introduite, permettant à UserService d’être complètement détaché de l’implémentation concrète de MyDatabase. Désormais, toutes les dépendances sont injectées au niveau le plus élevé—généralement dans la fonction main ou lors de la configuration de l’application—tandis que les modules eux-mêmes ne dépendent que d’abstractions. Cette structure garantit que les modules sont faiblement couplés, respectant ainsi la première règle de l’injection de dépendances.

Règle 2 : Les abstractions ne doivent pas dépendre des détails

Cette règle souligne qu’une abstraction doit définir ce que fait un composant, et non comment il le fait. Les détails d’implémentation doivent être encapsulés dans des classes concrètes, et non exposés ou gérés par l’abstraction elle-même.

Si une interface ou une classe abstraite commence à inclure de la logique ou des hypothèses sur des implémentations spécifiques, elle devient fortement couplée à ces détails. Cela viole le principe et risque de provoquer des changements en cascade dans tout le système dès qu’une seule implémentation change.

Voici un exemple pour illustrer cela :

from abc import ABC, abstractmethod

class ITransportationMode(ABC):

    @abstractmethod
    def get_max_speed(self):
        raise NotImplementedError

    @abstractmethod
    def get_wheels_quantity(self):
        raise NotImplementedError

class Car(ITransportationMode):
    def get_max_speed(self):
        return 200

    def get_wheels_quantity(self):  # <-- fonctionne ici
        return 4

class Horse(ITransportationMode): 
    
    def get_max_speed(self):
        return 70

    def get_wheels_quantity(self):  # <-- ne fonctionne plus ici
        pass

Cet exemple met en évidence une erreur courante : la méthode get_wheels_quantity (obtenir la quantité de roues) est définie dans l’interface ITransportationMode, mais elle n’a de sens que pour certaines implémentations comme Car (voiture). Pour d’autres, comme Horse (cheval), elle est inutile ou non définie, ce qui conduit à des implémentations maladroites ou vides.

Cela viole la deuxième règle de l’injection de dépendances : les abstractions ne doivent pas dépendre des détails. L’interface ne doit définir que des comportements communs et pertinents pour toutes ses implémentations. Inclure des méthodes qui ne s’appliquent qu’à certaines sous-classes force un couplage inutile et casse l’abstraction.

Pour résoudre ce problème, nous pouvons diviser l’abstraction en interfaces plus spécifiques :

from abc import ABC, abstractmethod

class ITransportationMode(ABC):
	@abstractmethod
	def get_max_speed(self):
		raise NotImplementedError
		
class IVehicleWithWheels(ITransportationMode):
	@abstractmethod
	def get_wheels_quantity(self):
		raise NotImplementedError

class Car(IVehicleWithWheels):
	def get_max_speed(self):
		return 200

	def get_wheels_quantity(self):
		return 4

class Horse(ITransportationMode): 
	def get_max_speed(self):
		return 70

Le nouveau code offre beaucoup plus de flexibilité, car lorsqu’une modification survient, elle n’affecte que les classes concernées. Cela améliore la modularisation et réduit le couplage. De plus, les responsabilités de chaque module sont clairement définies.

Injection de dépendances en Python

Je vais maintenant vous présenter une API web simple pour illustrer ma méthodologie d’implémentation du principe d’Inversion de Contrôle (IoC) en Python. Vous pouvez consulter le projet complet sur GitHub ici.

Pour cela, j’utilise deux bibliothèques principales pour l’injection de dépendances, une pour la gestion des paramètres de l’application, et bien sûr, FastAPI :

Type Bibliothèque Version utilisée
DI FastAPI Injector 0.8.0
DI Injector 0.22.0
Configuration Pydantic Settings 2.10.1
API FastAPI 0.116.1

L’application est une API simple qui retourne les propriétés de différents polygones. Elle est structurée selon les principes du Domain-Driven Design (DDD) :

fastapi-dependency-injection
│
├── polygons/
│	│
│	├── api/
│	│   ├── controllers/
|	│   │   └── polygons.py
|	│   ├── app.py
|	│   └── dependency_injection.py
|	│   
|	├── domain/
|	│   ├── interfaces/
|	│   │   ├── polygon_interface.py
|	│   │   └── settings_interface.py
|	│   ├── models/
|	│   │   ├── square.py
|	│   │   └── triangle.py
|	│   └── settings/
|	|
|	├── __init__.py
|	└── __main__.py
|
├── .env
└── requirements.txt

Injection de la configuration

D’après mon expérience, l’un des principaux avantages de l’injection de dépendances est la possibilité d’injecter les paramètres de l’application. En général, une classe de configuration est utilisée dans plusieurs modules. Sans DI, il faudrait l’instancier à chaque fois qu’elle est nécessaire, ce qui conduit à un code répétitif et moins maintenable.

De plus, les valeurs de configuration—comme les paramètres d’environnement—doivent rester constantes tout au long du cycle de vie de l’application. Cela en fait des candidats idéaux pour le patron singleton.

Heureusement, cela est facile à mettre en œuvre grâce à pydantic et injector. Commençons par créer un fichier settings.py qui lit la longueur des côtés du polygone à partir d’un fichier .env.

SIDE_LENGTH=5

.env

from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")
    side_length: float

settings.py

Voici maintenant la partie intéressante. Pour appliquer l'inversion de contrôle, nous avons besoin d'une interface qui agit comme un contrat entre les modules nécessitant des paramètres et l'implémentation des paramètres elle-même. Cette interface garantit que toute classe qui en hérite doit implémenter la méthode get_side_length . Ainsi, tout objet utilisant ISettings peut compter sur la présence de cette méthode.

from abc import ABC, abstractmethod

class ISettings(ABC):

    @abstractmethod
    def get_side_length(self) -> float:
        raise NotImplementedError

settings_interface.py

Il s'agit de la déclaration finale de la classe Settings. Elle hérite de ISettings et implémente la méthode get_side_length. Notez l'utilisation du décorateur @singleton de la bibliothèque injector : cela garantit qu'une seule instance de la classe Settings est créée et partagée dans toute l'application, conformément au modèle singleton.

from injector import singleton
from pydantic_settings import BaseSettings, SettingsConfigDict

from polygons.domain.interfaces.settings_interface import ISettings
  
@singleton
class Settings(BaseSettings, ISettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")
    side_length: float

    def get_side_length(self) -> float:
        return self.side_length

settings.py

Polymorphisme

Ensuite, définissons l’interface pour les polygones. Cette interface inclut trois méthodes qui décrivent le comportement attendu de toute implémentation de polygone.

from abc import ABC, abstractmethod

class IPolygon(ABC):

    @abstractmethod
    def get_area(self, side_length: float) -> float:
        raise NotImplementedError

    @abstractmethod
    def get_perimeter(self, side_length: float) -> float:
        raise NotImplementedError

    @abstractmethod
    def get_number_of_sides(self) -> int:
        raise NotImplementedError

polygons_interface.py

Implémentons maintenant une classe qui hérite de IPolygon. La classe Square fournit des implémentations concrètes pour les trois méthodes. Elle nécessite un accès au paramètre side_length, qui est injecté via le constructeur en utilisant l’interface ISettings. Sans injection de dépendances, nous serions obligés d’instancier manuellement la classe de configuration à chaque fois qu’elle est nécessaire, ce qui rend le code répétitif et complique la gestion des modifications de configuration à travers toute l’application. Par exemple, imaginez que nous décidions de changer la valeur de side_length de 5 à 10 ; il faudrait alors mettre à jour chaque endroit où Settings est instancié manuellement. En injectant ISettings, nous déléguons la responsabilité de fournir la dépendance à l’injecteur.

Il est nécessaire d'utiliser le décorateur @inject pour indiquer à la bibliothèque injector que la classe doit être injectée.
from injector import inject

from polygons.domain.interfaces.polygon_interface import IPolygon
from polygons.domain.interfaces.settings_interface import ISettings

class Square(IPolygon):

    @inject
    def __init__(self, settings: ISettings):
		# Si les paramètres n'étaient pas injectés, je devrais créer une nouvelle instance
		# self.settings = Settings(side_length=5)    
        self.settings = settings  

    def get_area(self) -> float:
        return self.settings.get_side_length() ** 2

    def get_perimeter(self) -> float:
        return 4 * self.settings.get_side_length()

    def get_number_of_sides(self) -> int:
        return 4

square.py

Une implémentation similaire est fournie pour la classe Triangle :

from injector import inject

from polygons.domain.interfaces.polygon_interface import IPolygon
from polygons.domain.interfaces.settings_interface import ISettings

class Triangle(IPolygon):

    @inject
    def __init__(self, settings: ISettings):
        self.settings = settings

    def get_area(self) -> float:
        return 0.5 * self.settings.get_side_length() ** 2

    def get_perimeter(self) -> float:
        return 3 * self.settings.get_side_length()

    def get_number_of_sides(self) -> int:
        return 3

triangle.py

Liaison des classes

Pour finaliser la configuration, nous devons définir comment les interfaces sont liées à leurs implémentations. Cela se fait dans le fichier dependency_injection.py, situé à la racine de l’application. Grâce à la bibliothèque injector, le processus de liaison est simple :

  1. Créer une instance injector = Injector().
  2. Lier les interfaces à leurs classes concrètes.
  3. Définir éventuellement une portée, comme singleton, soit via des décorateurs, soit directement dans le binder.
import random  

from injector import Injector

from polygons.domain.interfaces.polygon_interface import IPolygon
from polygons.domain.interfaces.settings_interface import ISettings
from polygons.domain.models.square import Square
from polygons.domain.models.triangle import Triangle
from polygons.domain.settings.settings import Settings

injector = Injector()
injector.binder.bind(IPolygon, random.choice([Square, Triangle]))
injector.binder.bind(ISettings, Settings)

dependency_injection.py

Cette configuration lie ISettings à la classe Settings, garantissant que toute classe nécessitant ISettings reçoit l’instance unique (singleton). À des fins de démonstration, IPolygon est lié aléatoirement à Square ou Triangle, illustrant à quel point l’injection de dépendances peut être flexible et dynamique—même si ce type de liaison aléatoire n’est pas courant en environnement de production.

Injection avec FastAPI

J’ai créé une application FastAPI simple avec trois points de terminaison : /random-polygon/square et /triangle.

import uvicorn
  
def main():
    uvicorn.run("polygons.api.app:app")
  
if __name__ == "__main__":
    main()

__main__.py

L’une des étapes clés consiste à intégrer l’injecteur à l’objet app de FastAPI. À première vue, le fichier app.py ressemble à une configuration FastAPI classique. Cependant, la dernière ligne est cruciale : la fonction attach_injector de la bibliothèque fastapi_injector permet de lier l’injecteur à l’application FastAPI.

from fastapi import FastAPI
from fastapi_injector import attach_injector
  
from polygons.api.controllers import polygons
from polygons.api.dependency_injection import injector

app = FastAPI(
    title="Polygons DI example",
    version="EXAMPLE",
    description="API conceived to show how to implement dependency injection (DI) in Python using FastAPI.",
    swagger_ui_parameters={"defaultModelsExpandDepth": -1},
)
  
app.include_router(polygons.router)
attach_injector(app, injector)

app.py

Pour le fichier du contrôleur, je me concentre désormais uniquement sur l’injection des services nécessaires dans les points de terminaison. Nous ne nous soucions plus de l’implémentation des autres dépendances, car elles sont toutes gérées par l’injecteur (c’est la beauté du principe d’inversion de dépendance ).

Avec fastapi_injector, la syntaxe pour injecter des dépendances change légèrement par rapport à l’approche traditionnelle de FastAPI.

Injection traditionnelle avec FastAPI :

...
from typing import Annotated
from fastapi import Depends

@router.get("/example")
def my_endpoint(
	polygon_service: Annotated[IPolygon, Depends(Square)]
):
	...

Dans cette approche, vous devez définir explicitement la liaison pour chaque interface à chaque point de terminaison.

Avec fastapi_injector :

...
from fastapi_injector import Injected

@router.get("/example")
def my_endpoint(
	polygon_service: IPolygon = Injected(IPolygon)
):

Ici, la liaison est automatiquement résolue en fonction de la configuration définie dans dependency_injection.py. Cependant, vous pouvez toujours remplacer la liaison par défaut en spécifiant une classe concrète, comme illustré dans l’implémentation du contrôleur.

Implémentation du contrôleur

from fastapi import APIRouter
from fastapi_injector import Injected
  
from polygons.domain.interfaces.polygon_interface import IPolygon
from polygons.domain.models.square import Square
from polygons.domain.models.triangle import Triangle

router = APIRouter(tags=["Polygons"])

@router.get("/random-polygon")
def get_random_polygon_information(polygon_service: IPolygon = Injected(IPolygon)):
    return {
        "sides": polygon_service.get_number_of_sides(),
        "area": polygon_service.get_area(),
        "perimeter": polygon_service.get_perimeter(),
    }
  
@router.get("/square")
def get_square_information(polygon_service: IPolygon = Injected(Square)):
    return {
        "sides": polygon_service.get_number_of_sides(),
        "area": polygon_service.get_area(),
        "perimeter": polygon_service.get_perimeter(),
    }
  
@router.get("/triangle")
def get_triangle_information(polygon_service: IPolygon = Injected(Triangle)):
    return {
        "sides": polygon_service.get_number_of_sides(),
        "area": polygon_service.get_area(),
        "perimeter": polygon_service.get_perimeter(),
    }

controllers/polygons.py

Pour le point de terminaison /random-polygon, l’implémentation utilisée sera sélectionnée aléatoirement entre Square et Triangle, selon la liaison définie dans dependency_injection.py :

injector.binder.bind(IPolygon, random.choice([Square, Triangle]))

Pour les points de terminaison /square et /triangle, nous spécifions explicitement quelle implémentation injecter.

Et voilà ! Nous avons construit une application FastAPI avec une injection de dépendances propre et maintenable, en utilisant injector et fastapi_injector.

Conclusion

Le principe d’inversion de dépendance (dependency inversion principle ou DIP en anglais) offre des avantages considérables en matière de modularisation et de réduction du couplage. Bien que Python soit un langage dynamiquement typé, appliquer le DIP peut grandement améliorer la structure et la maintenabilité des applications complexes. Il favorise l’évolutivité et simplifie les tests en permettant de remplacer ou de simuler facilement les composants.

Dans cet article, j’ai présenté une méthodologie simple pour implémenter l’injection de dépendances dans une application FastAPI en utilisant les bibliothèques injector et fastapi_injector. Cette approche est particulièrement adaptée aux projets plus grands et complexes, et constitue une base solide pour un développement Python propre et évolutif.

Je vous encourage à mettre en pratique les principes SOLID—en particulier le DIP—afin d’améliorer la qualité, la flexibilité et la testabilité de votre code.

Dernier