FastAPI Hexagonal Code source

12. Organisation & tests

Comment ranger le code — et pourquoi la facilité à le tester est le meilleur juge de l'architecture.

TL;DR — Couche et module métier sont deux dimensions orthogonales : ce projet tranche verticalement à chaque couche. Trois niveaux de tests, pas deux : domaine pur, use cases avec doublures, intégration sur les moteurs réels (testcontainers).

Deux dimensions, pas une#

Beaucoup de discussions « par couche ou par feature ? » sont mal posées : ce ne sont pas deux options concurrentes, ce sont deux axes d'un même plan.

taskusertransverse
presentationv1/routers/tasks.pyv1/routers/users.pyapp.py, error_handlers.py, dependencies/session.py
infrastructurepersistence/task/persistence/user/persistence/database.py, config.py, cache/, mail/
applicationapplication/task/application/user/application/shared/ (ports techniques, erreurs)
domaindomain/task/domain/user/domain/shared/ (bases d'exceptions)

Les couches répondent à « quel type de responsabilité ? ». Les contextes répondent à « quel morceau de métier ? ». La question réelle est : dans quel sens découpe-t-on les dossiers en premier ?

Ce projet découpe par couche d’abord, puis par module métier (task, user). Ces deux modules ne démontrent pas deux bounded contexts : voir le chapitre 03. D’autres organisations de dossiers peuvent également faire vérifier leurs dépendances par import-linter.

Le découpage vertical, concrètement#

domain/task/          domain/user/
application/task/     application/user/
persistence/task/     persistence/user/
v1/schemas/task.py    v1/schemas/user.py
v1/routers/tasks.py   v1/routers/users.py
dependencies/task.py  dependencies/user.py

Le bénéfice : on travaille par fonctionnalité. Ajouter un module crée surtout de nouveaux fichiers, mais demande aussi le câblage des routes, des dépendances et des modèles. On lit un contexte de bout en bout sans naviguer dans dix fichiers fourre-tout.

Le coût, à connaître : un peu de duplication structurelle entre contextes (chaque contexte a son mapper, son repository, ses schémas) et la tentation de partager trop vite. La bonne réponse à « ces deux contextes se ressemblent » est le plus souvent rien du tout : deux contextes qui évoluent séparément ont le droit de se ressembler aujourd'hui et de diverger demain. Le vrai transverse (domain/shared/, application/shared/, persistence/database.py) est ce qui n'appartient à aucun métier.

La bonne dose de centralisation#

Nuance apprise dans ce projet : on ne découpe pas tout mécaniquement.

Le découpage sert la lisibilité ; ce n'est pas un dogme à appliquer partout. Une façade ajoutée « pour la symétrie » est du couplage gratuit.

Le compromis de la façade#

C'est un choix, pas une recommandation générale — et il a un coût qu'il faut nommer plutôt que passer sous silence.

Avec façade (ce projet)Sans façade
Import dans un routerune ligne, une seule origineun import par module de dépendances
Ajouter un use caseun provider et une entrée dans __all__un provider, c'est tout
Travail à plusieurstout le monde édite le même fichier → conflits Gitchacun touche son contexte
Coût à l'importcharger la façade charge tous les providerson ne charge que ce qu'on utilise

Deux précisions, parce que la critique habituelle de ce motif vise souvent à côté :

Le seuil de bascule est simple à énoncer : la façade paie tant qu'une seule personne touche ce fichier. À plusieurs, ou passé quelques dizaines de providers, la maintenance de __all__ coûte plus qu'elle ne rapporte — et des dependencies/ par contexte, importés directement par chaque router, deviennent le meilleur choix.

ConceptCe que dit le principeCe que fait ce projet
Composition rootLa décision d'association port ↔ adaptateur reste en un point uniqueFaçade __init__.py : un import, au prix d'un fichier partagé

Les trois niveaux de tests#

Il y en a bien trois, et le niveau intermédiaire est celui qu'on oublie le plus souvent.

Intégration Use cases Domaine Intégration Use cases Domaine HTTP → use case → domaine → PostgreSQL + Valkey réels secondes use case + ports en mémoire (_fakes.py) millisecondes entités, value objects, invariants · zéro I/O microsecondes

1. Le domaine : pur et instantané#

Aucune base, aucun HTTP, aucun mock. Juste des objets.

# tests/unit/domain/test_task_entity.py
def test_complete_twice_is_rejected() -> None:
    task = Task.create(owner_id=UserId.generate(), title=TaskTitle("x"))
    task.complete()
    with pytest.raises(TaskAlreadyCompleted):
        task.complete()

C'est possible et facile précisément parce que le domaine ne dépend d'aucun framework (ch. 01). Si tes règles métier sont dures à tester, c'est le signe qu'elles sont mélangées à de la technique — le juge de paix le plus fiable de toute cette architecture.

2. Les use cases : ports doublés en mémoire#

Le niveau intermédiaire teste l'orchestration : les branches, les cas d'erreur, les interactions entre ports — sans I/O.

# tests/unit/application/_fakes.py
class FakeTaskRepository(TaskRepository):
    def __init__(self, tasks: list[Task] | None = None) -> None:
        self._by_id: dict[TaskId, Task] = {t.id: t for t in (tasks or [])}
        self.saved: list[Task] = []

    async def save(self, task: Task) -> None:
        self._by_id[task.id] = task
        self.saved.append(task)          # on enregistre pour pouvoir l'asserter

    async def get(self, task_id: TaskId) -> Task:
        try:
            return self._by_id[task_id]
        except KeyError:
            raise TaskNotFound(str(task_id)) from None
# tests/unit/application/test_create_task.py (esprit)
async def test_create_task_rejects_unknown_owner() -> None:
    use_case = CreateTask(FakeTaskRepository(), FakeUserRepository())
    with pytest.raises(UserNotFound):
        await use_case.execute(CreateTaskCommand(owner_id=str(UserId.generate()), title="x"))

Ce niveau est le grand bénéficiaire de l'architecture : il n'existe que parce que les use cases ne dépendent que de ports. Les doublures du projet couvrent aussi FakeCache, RecordingPublisher et FakeSMTP — ce qui permet de tester un hit de cache, une publication d'événement ou un échec d'envoi partiel sans Valkey, sans RabbitMQ, sans serveur mail.

Les fakes rendent le contrat visible. Hériter du port détecte des méthodes abstraites manquantes à l’instanciation. Un contrôle de types peut aussi détecter des signatures incompatibles, à condition d’analyser les tests : la configuration mypy actuelle cible seulement task_manager. Un fake ne prouve pas non plus les garanties transactionnelles de l’adaptateur SQL.

3. L'intégration : les moteurs réels#

Les tests d'intégration exercent toute la chaîne — HTTP → use case → domaine → base — via un client HTTP asynchrone, sur un PostgreSQL et un Valkey jetables démarrés par testcontainers (tests/integration/conftest.py).

# tests/integration/conftest.py (extrait)
@pytest.fixture(scope="session")
def database_url() -> Iterator[str]:
    with PostgresContainer("postgres:17-alpine", driver="asyncpg") as postgres:
        yield postgres.get_connection_url()

@pytest.fixture(scope="session")
def redis_url() -> Iterator[str]:
    with RedisContainer("valkey/valkey:8-alpine") as valkey:
        ...

Pourquoi les moteurs réels et pas un substitut in-memory ? Parce qu'un test qui passe sur un dialecte que la production n'utilise pas ne prouve pas grand-chose. Ici on exerce vraiment le type uuid natif, timestamptz, les violations d'unicité, le ON DELETE CASCADE, et le protocole Redis. Le prix : quelques secondes au démarrage de la suite, et un démon Docker obligatoire.

Les conteneurs rendent aussi la suite hermétique. Sans eux, un Redis qui écoute sur le port par défaut de la machine — la stack docker compose du projet, typiquement — serait utilisé par les tests, qui liraient alors des entrées laissées par un run précédent.

Ce que la fixture client remplace, exactement. Elle monte la vraie application (routers, DI, handlers d'erreurs authentiques) et surcharge deux choses seulement :

Les routers, providers et handlers restent réels, mais ASGITransport n’exécute pas automatiquement le lifespan. La fixture configure les ressources de test et remplace le publisher. Cette suite ne valide donc ni le démarrage réel du broker, ni PgBouncer, ni Alembic, ni le worker complet. Ce sont des tests d’intégration ciblés, pas une validation de tout le déploiement.

Deux autres choix de la conftest valent d'être notés :

# tests/integration/api/test_users_api.py
async def test_list_user_tasks_returns_only_owned_tasks(client):
    alice = await _create_user(client, email="alice@example.com")
    await client.post(f"/api/v1/users/{alice}/tasks", json={"title": "T1"})
    tasks = await client.get(f"/api/v1/users/{alice}/tasks")
    assert all(t["owner_id"] == alice for t in tasks.json())

La règle de répartition#

Ce que tu testesNiveau
Un invariant, une validation, une transition d'étatDomaine
Une orchestration, une branche d'erreur, un hit/miss de cacheUse case (fakes)
Un parcours complet, un code HTTP, une contrainte SQL, un comportement du moteurIntégration

On évite de répéter le même scénario au même niveau, mais un invariant et sa traduction HTTP sont deux propriétés différentes. Un test unitaire vérifie que le titre vide est refusé ; un test d’intégration peut utilement vérifier le statut 422, le corps de réponse et l’absence d’écriture. Le coût se juge par l’information apportée, pas par l’interdiction de tester deux fois un même chemin.

Les effectifs évoluent : utiliser uv run pytest --collect-only -q depuis backend/ pour les retrouver. La review du 14 septembre 2026 a exécuté 107 tests unitaires avec succès. Ce nombre n’est pas une cible pédagogique ; le choix des risques couverts importe davantage.

Les garde-fous automatisés#

make lint     # ruff + import-linter + mypy strict
make test     # les trois niveaux
make check    # = lint + test, contrôles locaux complets

make imports exécute les deux contrats d’architecture. Les hooks pre-commit les lancent s’ils sont installés ; le workflow GitHub actuel publie uniquement le site. La CI backend reste à ajouter : make check n’est pas lancé automatiquement par GitHub.

À retenir#

À toi de jouer#

  1. Tu ajoutes la règle « un titre ne peut pas contenir d'URL ». À quel niveau écris-tu le test, et combien de tests au total ?
  2. La fixture client surcharge get_session par une dépendance qui commit et rollback à l'identique. Que testerait-on en moins si elle committait systématiquement, sans try/except ?
  3. Pourquoi les fixtures de seed passent-elles par CreateUser plutôt que par un INSERT SQL ? Donne un bug qu'un seed SQL pourrait masquer.

→ Corrigés