FastAPI Hexagonal Code source

05. Relations entre agrégats

Le chapitre précédent a posé les frontières. Celui-ci traite ce qui les traverse.

TL;DR — Entre agrégats : référence par identité, et « les enfants d'un parent » devient une requête. Dans un agrégat : les objets se référencent normalement. La jointure SQL est un détail d'infrastructure ; en async, charge les relations explicitement.

La règle et sa portée exacte#

Un agrégat en référence un autre par son identité, jamais par l'objet.

Formulée ainsi, elle est souvent mal comprise. La version complète :

On ne traverse pas les frontières entre agrégats par navigation d'objets. À l'intérieur d'un agrégat, les objets se référencent directement et forment des collections quand c'est naturel.

# domain/task/entities.py
class Task:
    def __init__(self, *, id: TaskId, owner_id: UserId, ...):
        self.owner_id = owner_id     # ✅ une IDENTITÉ, pas un objet User

Et symétriquement, User ne porte pas la collection de ses tâches.

Les trois raisons, par ordre d'importance :

  1. Frontière de cohérence. C'est la vraie raison (ch. 04) : User et Task n'ont pas à être cohérents dans la même transaction.
  2. Découplage. Le contexte task n'a besoin ni du nom ni de l'e-mail du propriétaire pour appliquer ses règles — seulement de savoir à qui la tâche appartient.
  3. Volume. Un user peut avoir 10 000 tâches. Une collection en mémoire n'a aucun sens.

« Les tâches d'un user » est une requête#

Puisque User ne porte pas ses tâches, on les obtient par le port du contexte task :

# domain/task/repository.py
async def list_by_owner(self, owner_id: UserId, *, limit=100, offset=0) -> list[Task]: ...

La navigation « user → ses tâches » devient un appel explicite, avec sa pagination. C'est plus verbeux qu'un attribut, et c'est le but : le coût de la requête redevient visible dans le code qui la déclenche.

✗ Navigation par objets ✓ Référence par identité + requête User Task Task Task.owner_id User … × 10 000 tasks.list_by_owner(user_id, limit=100) UserId

Où vit la vraie jointure ? Dans l'infrastructure#

La relation relationnelle (clé étrangère, jointure SQL) est un détail d'infrastructure. Elle n'apparaît qu'au niveau ORM :

# infrastructure/persistence/task/models.py
owner_id: Mapped[uuid.UUID] = mapped_column(
    ForeignKey("users.id", ondelete="CASCADE"), index=True
)

Le domaine ne connaît que UserId. L’index facilite les recherches par propriétaire, sans imposer un plan : PostgreSQL peut choisir un parcours séquentiel pour une petite table ou une requête peu sélective.

Qui vérifie que le user existe ?#

La FK garantit l'intégrité en base, mais provoquerait une erreur technique illisible. On veut une erreur métier claire, donc le use case vérifie (ch. 02) :

if not await self._users.exists(owner_id):
    raise UserNotFound(str(owner_id))

Comme pour l'unicité d'e-mail : le use case donne le message, la base donne la garantie.

Le piège du N+1 (côté lecture)#

Si tu charges un User avec ses tâches pour une vue de lecture, attention : en SQLAlchemy async, le chargement paresseux implicite est interdit. C'est une contrainte bienvenue — elle t'oblige à être explicite :

select(UserModel).options(selectinload(UserModel.tasks))   # 1 requête (+1), pas N

En mode synchrone, un chargement paresseux par utilisateur peut produire le N+1. En async, l’accès implicite peut échouer avec MissingGreenlet ; des mécanismes explicites comme AsyncAttrs permettent aussi de déclencher ces I/O. selectinload groupe le chargement, éventuellement en plusieurs lots : deux SELECT est le cas simple, pas une borne universelle.

Vue jointe en lecture ? Query service#

Pour afficher « la tâche + le nom de son propriétaire », ne reconstruis pas deux agrégats. C'est une lecture : un service de requête dédié fait un SELECT … JOIN et retourne directement un DTO de lecture. Détaillé au chapitre 09.

Les autres cardinalités#

Le principe directeur ne change pas : entre agrégats, référence par identité ; la jointure est un détail d'infra. Seule la forme de la référence change. (Les exemples ci-dessous sont illustratifs ; ce dépôt n'implémente que le 1→n.)

n→1 : déjà fait#

Un n→1 est un 1→n vu de l'autre côté, et c'est exactement ce que le projet implémente : Task.owner_id est le côté « plusieurs ». Rien de nouveau.

En pratique, quand deux agrégats sont en relation 1→n, c'est presque toujours l'enfant qui porte l'identité du parent. Pas par dogme, mais parce que la collection côté parent bute sur les trois raisons du début (cohérence, découplage, volume).

La nuance qui compte Si tu te dis « mais Order porte bien ses lines ! », relis le chapitre 04 : OrderLine est dans l'agrégat Order. Ce n'est pas une relation entre agrégats, donc cette section ne s'y applique pas. La question à trancher d'abord est toujours : une frontière ou deux ?

1↔1 : d'abord, est-ce vraiment deux agrégats ?#

Face à un 1↔1, la première question n'est pas « comment le mapper » mais « devrait-ce être un seul agrégat ? ».

Relation 1↔1 entre A et B B a-t-il un cycle de vie indépendant de A ? Un invariant les lie-t-il dans la même transaction ? B est un value object Un seul agrégat Deux agrégats composé dans A · pas de relation B interne à A un seul côté porte l'ID de l'autre FK UNIQUE en base non oui oui non

Par défaut, penche pour fusionner. Ne sépare que si tu as une vraie raison.

n↔n : deux stratégies#

1. Collection d'identités, si les ensembles sont petits et que la relation ne porte aucune donnée propre :

class Article:
    def __init__(self, ..., tag_ids: set[TagId]) -> None:
        self._tag_ids = tag_ids        # des IDENTITÉS, pas des objets Tag

2. Donner un objet métier à la relation quand elle porte des données ou un comportement. Une inscription Enrollment peut être une entité avec son propre cycle de vie. En faire un agrégat indépendant demande ensuite de justifier sa frontière de cohérence ; des attributs sur une relation ne suffisent pas à le décider.

class Enrollment:                       # la relation DEVIENT un agrégat
    student_id: StudentId
    course_id: CourseId
    enrolled_at: datetime
    grade: Grade | None

    def assign_grade(self, grade: Grade) -> None:
        if self.grade is not None:
            raise GradeAlreadyAssigned()  # un invariant : c'est bien une entité

Le déclencheur de la réification : dès que tu veux « stocker quelque chose sur la relation », ou qu'une règle porte sur la relation elle-même, ce n'est plus une relation — c'est un concept.

Le piège n↔n Ne jamais modéliser ça comme deux collections d'objets qui se pointent mutuellement (user.roles ↔ role.users). C'est ce qui fait exploser le chargement et dissout les frontières d'agrégats. En base, un n↔n devient une table de jointure — un détail d'infrastructure, comme toujours.

Récapitulatif des cardinalités#

RelationModélisation domaineInfra
Objets du même agrégatcollection d'objets réels via la racinetable enfant, cascade
1→n / n→1 (2 agrégats)l'enfant porte parent_id: ParentIdFK indexée
1↔1 (2 agrégats)un seul côté porte l'ID de l'autreFK UNIQUE
1↔1 (en fait 1)value object composé dans l'agrégatcolonnes inline
n↔n (sans données)collection d'Id d'un côtétable de jointure
n↔n (avec données)objet de relation ; agrégat séparé si son autonomie le justifietable-entité

La relation, couche par couche#

Pour le 1→n de ce projet :

CoucheComment la relation apparaît
DomainPar identité : Task.owner_id: UserId. Aucune collection dans User.
ApplicationUse cases dépendant de deux ports (vérification d'existence).
InfrastructureLa vraie jointure : ForeignKey, relationship, selectinload.
PresentationRoutes imbriquées /users/{id}/tasks ; le DTO expose owner_id.

À retenir#

À toi de jouer#

  1. On veut afficher, sur la page d'un user, son nom et le nombre de ses tâches terminées. Trois implémentations sont possibles : list_by_owner puis compte en Python, une méthode count_completed_by_owner sur le port, ou un query service. Laquelle choisis-tu et sur quel critère ?
  2. Un collègue ajoute tasks: list[Task] à User « juste pour les tests ». Cite trois problèmes concrets que ça crée dans ce projet.
  3. Enrollment porte student_id et course_id. Pourquoi n'est-ce pas simplement un value object ?

→ Corrigés