FastAPI Hexagonal Code source

02. Où vit une règle ?

Le chapitre le plus utile du cours. Tout le reste est de la machinerie ; ici, c'est la compétence de conception elle-même.

TL;DR — « Le domaine contient les règles, l'application orchestre » est trop grossier pour décider en pratique. Ce chapitre donne l'arbre de décision qui manque : invariant d'objet → value object / entité ; invariant d'agrégat → racine ; règle métier sans propriétaire naturel → service de domaine ; enchaînement → use case ; contrainte technique → infrastructure.

Le vrai problème : « règle métier » ne veut rien dire#

On dit facilement « les règles métier vont dans le domaine ». Mais mets côte à côte ces cinq affirmations, toutes vraies dans ce projet :

  1. Un titre de tâche ne peut pas être vide.
  2. Une tâche déjà terminée ne peut pas être terminée une seconde fois.
  3. On ne crée pas une tâche pour un utilisateur qui n'existe pas.
  4. Une adresse e-mail doit être unique parmi les utilisateurs.
  5. tasks.owner_id doit référencer une ligne existante de users.

Elles ressemblent toutes à « du métier ». Elles vivent pourtant à quatre endroits différents, et pour des raisons précises.

L'arbre de décision#

Une règle à placer Porte-t-elle sur la validité d'une seule valeur ? Porte-t-elle sur l'état interne d'un seul agrégat ? Règle du métier, mais sans objet propriétaire ? Enchaînement, coordination, policy du cas d'usage ? Value object Méthode d'entité Service de domaine Use case Infrastructure validation à la construction ou racine d'agrégat sans état, sans I/O couche application contrainte technique oui oui oui oui non non non non

Reprenons nos cinq règles.

1. « Un titre ne peut pas être vide » → value object#

La règle porte sur une valeur isolée. Elle ne dépend d'aucun contexte, aucun autre objet, aucune requête. Elle appartient au type lui-même.

# domain/task/value_objects.py
@dataclass(frozen=True, slots=True)
class TaskTitle:
    value: str

    def __post_init__(self) -> None:
        cleaned = self.value.strip()
        if not cleaned:
            raise EmptyTitle()

Conséquence : tout appelant est couvert. L'API, le CLI, le worker, un test, un futur import CSV. La règle n'a plus besoin d'être répétée nulle part.

2. « Pas de double complétion » → méthode de l'entité#

La règle porte sur une transition d'état d'un seul objet, en fonction de son état courant. Seule l'entité connaît cet état, donc seule elle peut la garantir.

# domain/task/entities.py
def complete(self) -> None:
    if self.status is TaskStatus.DONE:
        raise TaskAlreadyCompleted()
    self.status = TaskStatus.DONE
    self.completed_at = _now()

C'est ce qu'on appelle un invariant : une propriété qui doit rester vraie à tout moment de la vie de l'objet.

Bel exemple inverse dans le même fichier : rename() autorise le renommage d'une tâche terminée, et le docstring l'explique. Ce n'est pas un oubli, c'est une décision métier — corriger un libellé fautif reste légitime après coup. Une règle absente doit être aussi explicite qu'une règle présente.

3. « Le propriétaire doit exister » → use case#

Ici on quitte le domaine. Cette vérification implique deux agrégats (Task et User), et aucun des deux ne peut la porter : Task ne connaît pas le référentiel des utilisateurs, et User n'a pas à connaître les tâches. Il faut aussi interroger un port, donc faire une I/O — ce que les entités ne font jamais.

# application/task/use_cases/create_task.py
owner_id = UserId.from_string(command.owner_id)
if not await self._users.exists(owner_id):
    raise UserNotFound(str(owner_id))
task = Task.create(owner_id=owner_id, title=TaskTitle(command.title))

Le use case coordonne, il ne décide pas de ce qu'est une tâche valide.

4. « E-mail unique » → règle partagée entre application et infrastructure#

Celle-ci est instructive parce qu'elle n'a pas de réponse unique. L'unicité est une propriété d'un ensemble d'utilisateurs, pas d'un utilisateur : aucune entité ne peut la garantir seule.

En pratique, elle se joue à plusieurs niveaux :

Mais une garantie qu'on n'entend pas ne sert à rien, et c'est le troisième niveau — celui qu'on oublie. Une violation de contrainte remonte en IntegrityError : une erreur technique, qu'aucun gestionnaire ne sait convertir en statut HTTP. Pire, elle ne survient qu'au flush — donc au commit si personne ne le force, c'est-à-dire dans la fermeture de la dépendance de session, après que la réponse a été construite.

Mesuré sur ce projet avant correction : le client recevait un 201 complet, avec l'identifiant du user dans le corps, pour une transaction annulée. Pas un 500 — un succès mensonger, qu'aucun appelant ne peut détecter.

D'où le rôle de l'adaptateur : SqlAlchemyUserRepository.save force le flush et traduit la violation en EmailAlreadyUsed.

NiveauCe qu'il apporte
Use case (find_by_email)le message, dans le cas courant
Contrainte en basela garantie, y compris en course
Adaptateur (flush + traduction)rend la garantie audible en métier
La formulation à retenir Vérifier en amont sans contrainte en base, c'est un bug de concurrence en attente. Poser la contrainte sans la traduire, c'est le même bug, mais silencieux.

5. « FK vers users » → infrastructure#

Purement technique. La ForeignKey("users.id", ondelete="CASCADE") traduit dans PostgreSQL une relation que le domaine exprime déjà par un UserId. Le domaine ignore jusqu'à l'existence du mot « clé étrangère ».

Le tableau des trois niveaux ci-dessus s'applique pourtant tel quel. CreateTask vérifie le propriétaire avant d'écrire — c'est le message. La FK tranche même en course, quand le user est supprimé entre la vérification et l'INSERT — c'est la garantie. Et SqlAlchemyTaskRepository.save force le flush pour traduire tasks_owner_id_fkey en UserNotFound (404) — c'est ce qui la rend audible. Ce qui est purement technique, c'est le mécanisme, pas ce qu'on en fait entendre : une contrainte posée mais non traduite produit le même succès mensonger, quelle que soit la contrainte.

Et le service de domaine ?#

C'est le cas de figure que ce projet n'a pas, et qu'il faut pourtant connaître.

Un service de domaine héberge une règle qui est incontestablement métier, mais qui n'appartient naturellement à aucune entité. Le signal classique : la règle a besoin de plusieurs objets à parts égales, et la placer dans l'un d'eux serait arbitraire.

# domain/pricing/services.py  (exemple illustratif, hors de ce projet)
class PricingService:
    """Le prix dépend du client ET de la commande. Ni l'un ni l'autre n'en est
    le propriétaire naturel : la règle vit dans un service du domaine."""

    def calculate(self, customer: Customer, order: Order) -> Money:
        base = order.subtotal()
        if customer.is_premium and base > Money(100, "EUR"):
            return base * Decimal("0.9")
        return base

Trois garde-fous :

Et les « policies » applicatives ?#

La couche application peut fixer des limites propres au traitement : taille d’un lot, durée maximale d’un export ou pagination. Un exemple illustratif :

if not 1 <= command.page_size <= 100:
    raise InvalidPageSize()

En revanche, un plafond journalier de virement ou un quota commercial reste une règle métier, même si sa valeur est configurable ou si plusieurs agrégats sont concernés. L’application charge les données nécessaires ; une entité, une policy métier ou un service de domaine prend la décision. Comparer un seul montant au plafond ne suffit pas à contrôler un cumul journalier.

Le critère est la signification de la règle, pas le nombre d’objets consultés ni l’existence d’un paramètre de configuration.

Le tableau de synthèse#

Type de décisionDomicileExemple dans ce projet
Validité d'une valeurValue objectTaskTitle, Email, UserName
Invariant d'un objetMéthode d'entitéTask.complete()
Invariant d'un agrégatRacine d'agrégatvoir ch. 04
Règle métier sans propriétaireService de domaine— (exemple PricingService)
Coordination inter-agrégatsUse caseCreateTask vérifie l'owner
Policy du cas d'usageUse casepagination bornée, TTL de cache
Optimisation de lectureUse case + port techniquecache-aside de GetUser
Intégrité, unicité, transportInfrastructureFK, UNIQUE, index

Le test décisif : « qui d'autre pourrait l'appeler ? »#

Quand tu hésites, pose cette question :

Si demain cette opération est déclenchée par un import CSV, un cron ou un message de broker — la règle doit-elle encore s'appliquer ?

Ce projet rend le test très concret, parce que trois adaptateurs entrants existent réellement : ce qui doit valoir pour l'API, le CLI et le worker à la fois n'a rien à faire dans le router.

À retenir#

À toi de jouer#

  1. Nouvelle règle : « un utilisateur ne peut pas avoir plus de 50 tâches en cours ». Réponds aux quatre questions, dans cet ordre : a. où places-tu la règle ? b. pourquoi pas dans User ? c. que se passe-t-il si deux requêtes créent une tâche au même instant alors qu'il en reste une de disponible ? d. quelle contrainte ou quel mécanisme ajouterais-tu côté base pour que la limite tienne vraiment ?
  2. Nouvelle règle : « le titre d'une tâche ne peut pas dépasser 200 caractères ». Elle est déjà exprimée à deux endroits dans ce projet. Lesquels, et est-ce une duplication fautive ?
  3. Nouvelle règle : « la liste des tâches renvoyée par l'API est plafonnée à 100 éléments ». Domaine, application ou présentation ?

→ Corrigés