FastAPI Hexagonal Code source

01. La règle de dépendance

La seule règle qui compte — et comment la rendre vérifiable plutôt que déclarative.

TL;DR — Une seule règle : les import pointent vers l'intérieur, vers le cœur métier. Tout le reste du cours en découle. Elle est vérifiable mécaniquement, en CI, par import-linter.

L'architecture hexagonale — publiée par Alistair Cockburn en 2005 sous le nom ports & adapters — répond au problème du chapitre 00 avec une règle unique.

La règle#

Les dépendances du code pointent toujours vers l'intérieur. Le cœur ne connaît aucune implémentation concrète du monde extérieur.

Attention à une confusion fréquente : « le cœur ne connaît rien de l'extérieur » ne veut pas dire qu'il ignore tout besoin externe. Le domaine déclare ce dont il a besoin (« je dois pouvoir sauvegarder une tâche ») sous forme d'interfaces — les ports — sans jamais savoir qui les réalise. C'est de l'inversion de dépendance : le besoin est possédé par celui qui l'exprime, pas par celui qui le satisfait.

L'analogie de la prise électrique tient bien : ton appartement définit un standard de prise (le port). Il ne sait pas si tu y brancheras une lampe ou un aspirateur (les adaptateurs). Et surtout, ce n'est pas l'aspirateur qui dicte la forme de la prise.

ADAPTATEURS DRIVING · PILOTENT ADAPTATEURS DRIVEN · PILOTÉS API HTTP CLI Worker Application Domaine PostgreSQL Valkey SMTP RabbitMQ FastAPI click consumer RabbitMQ use cases · DTO · ports techniques entités · value objects · ports métier sans framework · aucune I/O implémentent les ports

Les flèches représentent les import. À l’exécution, le use case appelle un adaptateur à travers son port. Dans le code, c’est l’adaptateur de persistance qui dépend de l’interface du cœur ; le serveur PostgreSQL ne dépend évidemment pas de notre code Python.

Les quatre couches de ce projet#

CoucheRôleDépend de
domain/Le cœur : entités, value objects, ports métier, exceptionsrien (aucun framework)
application/Use cases, DTO, ports techniquesdomain/
infrastructure/Adaptateurs driven : SQLAlchemy, Valkey, SMTP, RabbitMQ, configapplication/, domain/
presentation/Adaptateurs driving : API HTTP, CLI, workertout le reste
Nuance utile « Hexagonale » ne prescrit pas quatre couches. Cockburn décrit deux zones (dedans / dehors) et des ports. Le découpage en quatre couches vient plutôt de la Clean Architecture de Robert C. Martin, et il se marie bien avec l'hexagone. Ne confonds pas la règle (une) et le découpage (un choix).

Driving vs driven : les deux côtés de l'hexagone#

Le point clé : un use case ne connaît ni l'adaptateur qui l'appelle, ni celui qui le sert. C'est exactement pour ça que CreateUser est réutilisé tel quel par l'API et par le CLI de ce projet, sans une ligne d'adaptation.

Un même outil peut d'ailleurs apparaître des deux côtés : RabbitMQ est driven quand ShareTask y publie un message, et driving quand le worker consomme ce message pour déclencher NotifyTaskShared.

Comment on la vérifie ici#

Une règle d'architecture qui n'est pas vérifiée automatiquement se dégrade en quelques mois. La vérification naïve est un grep :

grep -rE "fastapi|sqlalchemy|pydantic" src/task_manager/domain/   # → doit être vide

C'est un bon réflexe de contrôle rapide, mais ce n'est pas la source de vérité de ce projet : un grep ne voit pas les imports transitifs (un module du domaine qui importe un module « pur » qui, lui, importe SQLAlchemy) et produit des faux positifs sur des commentaires.

La vraie garantie est import-linter, configuré dans pyproject.toml ([tool.importlinter]), qui analyse le graphe d'imports réel :

make imports          # ou : uv run lint-imports

Deux contrats sont déclarés :

  1. layers — l'empilement presentation → infrastructure → application → domain. Interdit par exemple qu'application/ importe infrastructure/.
  2. forbidden — aucun de fastapi, sqlalchemy, pydantic, pydantic_settings, starlette ne doit être atteignable depuis task_manager.domain, même indirectement.
Deux niveaux de contrainte, à ne pas confondre

Ces contrats disent « domaine sans framework », pas « domaine sans aucune dépendance externe ». Le second est plus strict, et ce projet ne le tient pas : domain/user/ importe email_validator. C'est un choix assumé, pas un oubli — l'exercice 1 ci-dessous porte précisément sur cette frontière.

En cas de violation, l'outil affiche la chaîne d'imports fautive avec le numéro de ligne :

Architecture hexagonale (dépendances vers l'intérieur) KEPT
Domaine sans framework BROKEN

Contracts: 1 kept, 1 broken.

----------------
Broken contracts
----------------

Domaine sans framework
----------------------

task_manager.domain is not allowed to import sqlalchemy:

-   task_manager.domain.task.entities -> sqlalchemy (l.10)

import-linter tourne dans make lint et dans les hooks pre-commit lorsqu’ils sont installés. Le seul workflow GitHub versionné publie le site ; aucune CI backend ne lance encore ces contrôles. Les brancher en CI reste nécessaire pour protéger les contributions qui contournent les hooks locaux.

ConceptCe que dit le principeCe que fait ce projet
Vérification de l'archiLa règle doit être testable, pas seulement documentéeimport-linter (2 contrats) en make lint + hooks pre-commit
Nombre de couchesNon prescrit (dedans / dehors)4 couches, héritées de la Clean Architecture

Ce que ça t'apporte concrètement#

À retenir#

À toi de jouer#

  1. domain/user/value_objects.py importe email_validator, une bibliothèque tierce. Est-ce une violation de la règle de dépendance ? Justifie — et regarde le contrat forbidden de pyproject.toml pour vérifier.
  2. Un collègue place TaskRepository (l'interface) dans infrastructure/ et fait importer ce module par application/. Quel contrat import-linter casse, et quelle capacité concrète perd-on ?

→ Corrigés