Aller au contenu

3. DI explicite

Règle. Pas de conteneur DI. Les dépendances sont passées en constructeur ou via une factory de closures. Si un objet X a besoin d'un objet Y, c'est écrit noir sur blanc.

Pourquoi

Les conteneurs DI (Dependency Injector, Punq, etc.) cachent le graphe d'objets dans une configuration externe. Symptômes :

  • Difficulté à savoir qui a besoin de quoi sans dérouler le conteneur.
  • Erreurs de câblage à l'exécution, pas à la vérification de types.
  • Tests qui doivent monter un mini-conteneur, ou pire, monkeypatcher.

MIC choisit une approche plus simple : factory + closures.

Le motif : factory qui retourne un tuple de routes

def build_mail_v1_routes(
    *,
    enqueue_mail_controller: EnqueueMailController,   # (1)
) -> tuple[HttpRouteSpec, ...]:

    def _enqueue(*, payload: EnqueueMailPayload) -> HttpResponseSpec:
        response = handle_enqueue_mail(                # (2)
            payload, controller=enqueue_mail_controller,
        )
        return HttpResponseSpec(status_code=201, body=wrap_http_success(response))

    return (HttpRouteSpec(..., handler=_enqueue),)
  1. La factory prend ses dépendances en argument explicite (ici les controllers déjà construits).
  2. Le handler capture la dépendance via la closure et l'utilise au moment voulu.

Le wiring final

db = SqlDataStorage(url=settings.database_url)
db.prepare()

mail_repo = MailRepository(session_factory=db.session)   # repository ← session
routes = (
    *build_mail_v1_routes(
        enqueue_mail_controller=EnqueueMailController(repository=mail_repo),  # controller ← repository
    ),
    *build_audit_v1_routes(...),
)

app = FastAPI()
app.include_router(build_fastapi_router(routes=routes))

L'assemblage se lit de haut en bas : session → repository → controller → routes. Pas de magie.

Les cas d'usage — même règle

class EnqueueMailController:
    def __init__(self, *, repository: MailRepository) -> None:
        self._repository = repository

    def execute(self, *, recipient: str, ...) -> EnqueueMailResult:
        ...  # validation, puis self._repository.enqueue_mail(...)


class MailRepository:
    def __init__(
        self,
        *,
        session_factory: Callable[[], AbstractContextManager[Session]],
    ) -> None:
        self._session_factory = session_factory

Pas de @inject, pas de Depends(). Chaque constructeur prend ses dépendances. Les tests injectent ce qu'ils veulent.

Les tests deviennent triviaux

def test_enqueue_creates_pending_job(database):
    controller = EnqueueMailController(
        repository=MailRepository(session_factory=database.session),
    )
    result = controller.execute(recipient="a@b.c", subject="s", body="b")
    assert result.status == "pending"

Aucune fixture magique, aucun monkeypatch. La fixture database est définie en 5 lignes dans conftest.py.