Conteneurs DI magiques¶
Anti-pattern. Un conteneur DI résout les dépendances à l'exécution via introspection / annotations / configuration externe. Plus personne ne sait qui a besoin de quoi.
Symptômes¶
# ❌ Conteneur DI typique
from dependency_injector import containers, providers
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
db = providers.Singleton(SqlDataStorage, url=config.db.url)
user_service = providers.Factory(UserService, session=db.provided.session)
auth_service = providers.Factory(AuthService, user_service=user_service)
# ... 50 lignes plus tard ...
# Quelque part dans le code :
@inject
def handler(user_service: UserService = Provide[Container.user_service]):
...
Pourquoi c'est tentant¶
- "Inversion de contrôle" sonne professionnel.
- On peut changer d'implémentation sans toucher au code consommateur.
- Le code consommateur devient "plus pur".
Pourquoi c'est mauvais¶
Le câblage est invisible¶
Si je lis def handler(user_service: UserService = Provide[...]), je
vois quel type est attendu, mais où est-il configuré ? Dans le
conteneur. Mais aussi peut-être surchargé ailleurs. Bonne chance.
Erreurs à l'exécution, pas à la vérification de types¶
# Pas d'erreur à l'écriture
user_service: UserService = Provide[Container.usr_service] # typo
# Boom à la première requête en prod
mypy ne peut rien faire — Provide[...] est résolu dynamiquement.
Les tests demandent un conteneur¶
Pour tester handler isolément, il faut un mini-conteneur ou un
monkeypatch des Provide. Convention de tests cassée par convention de
tests.
Cycle de vie obscur¶
Singleton ? Factory ? Scoped per-request ? Cycle de vie ? Vous découvrez un beau jour qu'une session SQLAlchemy fuit entre deux requêtes parce que le conteneur l'a marquée Singleton.
Le bon motif : factory + closures¶
# ✅ MIC : tout est explicite
def build_mail_v1_routes(
*,
enqueue_mail_controller: EnqueueMailController,
) -> tuple[HttpRouteSpec, ...]:
def _enqueue(*, payload: EnqueueMailPayload) -> HttpResponseSpec:
response = handle_enqueue_mail(payload, controller=enqueue_mail_controller)
return HttpResponseSpec(status_code=201, body=wrap_http_success(response))
return (HttpRouteSpec(..., handler=_enqueue),)
# Câblage : session → repository → controller → routes
db = SqlDataStorage(url=settings.database_url)
db.prepare()
mail_repo = MailRepository(session_factory=db.session)
routes = build_mail_v1_routes(
enqueue_mail_controller=EnqueueMailController(repository=mail_repo),
)
- Pas de conteneur. L'assemblage se lit de haut en bas dans
bootstrap/startup/. - Pas de magie à l'exécution. mypy peut tout vérifier.
- Tests triviaux. Chaque couche prend ses dépendances en constructeur, les tests injectent ce qu'ils veulent.
"Mais avec 200 endpoints, c'est verbeux !"¶
Trois objections, trois réponses :
- Verbeux, oui. Mais lisible, oui. Et un peu plus de typage explicite vaut mieux qu'un conteneur qui craque en prod.
- Composez via factories.
mail_routes(),audit_routes(),search_routes()... chacun gère sa boutique.all_routes()agrège. - Si vous avez vraiment 200 endpoints, vous avez probablement un monolithe qui devrait être 5 services. Le DI ne résoudra pas ça.
Pour aller plus loin¶
Voir DI explicite.