Aller au contenu

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 :

  1. Verbeux, oui. Mais lisible, oui. Et un peu plus de typage explicite vaut mieux qu'un conteneur qui craque en prod.
  2. Composez via factories. mail_routes(), audit_routes(), search_routes()... chacun gère sa boutique. all_routes() agrège.
  3. 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.