FastAPI Hexagonal Code source

03. Le domaine (DDD tactique)

Le cœur : les règles, exprimées avec les mots du métier, sans une ligne de technique.

TL;DR — Le DDD tactique fournit trois briques : value objects (définis par leur valeur), entités (définies par leur identité), agrégats (frontières de cohérence, chapitre 04). Le domaine ne fait aucune I/O et n'importe aucun framework.

DDD stratégique vs DDD tactique#

Le DDD n'est pas une liste de classes. Il a deux moitiés, et ce cours n'en traite vraiment qu'une :

DDD
├── Stratégique  (le plus important, le moins codé)
│   ├── langage ubiquitaire   — parler comme le métier, dans le code
│   ├── bounded contexts      — frontières de sens
│   └── context mapping       — comment les contextes s'intègrent
│
└── Tactique     (les briques de code)
    ├── value objects
    ├── entités
    ├── agrégats
    ├── services de domaine
    ├── repositories (ports)
    └── domain events

Une conséquence pratique du langage ubiquitaire : les noms du code viennent des experts métier, pas de la base de données. TaskTitle, EmptyTitle, TaskAlreadyCompleted sont des phrases du métier. Si tu te surprends à nommer une classe TaskRow, UserData ou TaskManagerHelper, tu as basculé du côté technique.

DDD stratégique en 15 minutes#

Le reste de ce chapitre — et une bonne partie du cours — traite du tactique. C'est un déséquilibre assumé : ce dépôt est un exemple de code, et le stratégique se joue en réunion avec des experts métier, pas dans un fichier Python.

Mais il faut savoir ceci : on peut appliquer parfaitement tous les patterns tactiques et rater complètement le DDD. Des entités impeccables sur des frontières mal découpées produisent un système propre localement et incohérent globalement. Voici donc le minimum pour ne pas réduire le DDD à « entités + value objects + agrégats ».

Bounded context : une frontière de sens#

Un bounded context est une frontière à l'intérieur de laquelle un modèle et son langage sont cohérents, et dont les règles de signification sont maîtrisées. Ce n'est pas d'abord une affaire de vocabulaire : c'est une affaire de modèle.

Le vocabulaire en est le meilleur indice, et le plus facile à observer : si le même terme recouvre des concepts ou des règles différentes selon les interlocuteurs, c'est un signal fort qu'il faut examiner une séparation. Ce n'est pas à lui seul un critère suffisant — deux équipes peuvent parfaitement partager un mot et sa définition tout en ayant besoin de modèles séparés, pour des raisons de cycle de vie, de rythme de changement ou de propriété.

Exemple classique : dans un e-commerce, Product désigne dans catalog un descriptif avec photos, catégories et prix affiché — et dans ordering une référence, un prix figé au moment de la commande et une quantité. Même mot, deux concepts. La bonne réponse n'est pas de fabriquer un Product universel qui satisfait les deux : c'est de reconnaître qu'il y en a deux.

Le réflexe à désapprendre Le dédoublement d'un concept entre deux contextes n'est pas de la duplication à éliminer. Un modèle unique qui sert tout le monde ne sert bien personne — le symptôme est une classe qui grossit avec des champs optionnels qui n'ont de sens que pour la moitié des appelants.

Context map : comment les contextes se parlent#

Une context map décrit les relations entre contextes. Les quelques formes à connaître :

RelationCe que ça veut direQuand
Shared kernelDeux contextes partagent un petit noyau de modèle, modifié d'un commun accordÉquipes proches, noyau vraiment stable
Customer / supplierL'amont s'engage à servir les besoins de l'aval ; négociation possibleDeux équipes, une dépendance assumée
ConformistL'aval adopte tel quel le modèle de l'amont, sans négocierL'amont ne bougera pas (SI historique, API tierce)
Anti-corruption layerL'aval traduit le modèle de l'amont dans le sien, via une couche dédiéeLe modèle amont est inadapté ou instable

L'anti-corruption layer (ACL) est de loin la plus utile à connaître, et c'est exactement la même idée que ce cours applique partout : une couche de traduction qui empêche un modèle extérieur de contaminer le tien. Un mapper protège ton domaine de l'ORM ; un ACL protège ton contexte du modèle d'un autre système.

Et dans ce projet ?#

La réponse honnête mérite mieux qu'un « oui ». task et user sont souvent présentés comme deux bounded contexts. Regarde les indices, sans en faire un seul test : User n'a qu'un sens ici — le propriétaire d'une tâche, partout, sans ambiguïté. Une seule équipe, un seul déploiement, une seule migration qui crée les deux tables, et un CreateTask qui exige une cohérence immédiate entre les deux. Aucun indice ne va dans le sens de deux modèles distincts.

Les éléments du dépôt se lisent le plus simplement comme deux agrégats dans un même bounded context, organisés en modules. C’est une interprétation du modèle, pas une conséquence automatique d’un dépôt, d’une équipe ou d’une base partagée. Une extraction demanderait de revoir les appels inter-modules, la clé étrangère, les transactions et les contrats d’échange.

Le domaine de task ne dépend de user que par UserId. Ce constat ne vaut pas pour toute la tranche : ses use cases importent aussi UserRepository et UserNotFound, et la persistance joint les deux tables. domain/shared/ est ici du code commun ; un shared kernel DDD désigne un modèle partagé entre contextes avec une gouvernance commune, et peut contenir des règles métier.

Il n'y a pas d'ACL non plus. Le jour où user deviendrait un service distinct, c'est précisément là qu'il faudrait en introduire un — et le fait que task ne connaisse qu'un UserId rend ce jour-là beaucoup moins douloureux.

Ce qu'il faut retenir : le stratégique décide où sont les frontières ; le tactique décide comment on code à l'intérieur d'une frontière. Se tromper de frontière coûte infiniment plus cher que se tromper de pattern.

Pour la pratique Cette section donne le vocabulaire, pas la méthode. La procédure d'atelier (EventStorming, repérage des ruptures, context map, tests de validation d'une frontière) vit dans un document séparé — DDD stratégique, carnet de terrain — parce qu'elle se fait avec des experts métier et ne se lit pas dans un dépôt de code.

DDD tactique en 15 minutes#

Symétrique de la section précédente. Voici la boîte à outils complète, avant d'entrer dans le détail : ce chapitre traite les trois premières briques, les agrégats ont le chapitre 04, les repositories le chapitre 06, les événements le chapitre 11.

BriqueÀ quelle question elle répondDans ce projet
Value object« Cette chose est-elle définie par sa valeur ? »TaskTitle, Email, UserName, TaskStatus, TaskId, UserId
Entité« A-t-elle une identité qui survit à ses changements ? »Task, User
Agrégat + racine« Qu'est-ce qui doit être cohérent dans la même transaction ? »Task et User, chacune racine de son agrégat
Factory« Comment naît un objet déjà valide ? »Task.create(), User.create()
Repository (port)« Comment retrouver et persister un agrégat ? »TaskRepository, UserRepository, déclarés dans domain/
Service de domaine« Où va une règle métier qui n'appartient à aucun objet ? »absent — aucune règle n'est dans ce cas ici
Domain event« Que faire savoir quand quelque chose d'important s'est produit ? »absent — le métier est trop simple ; le message RabbitMQ est un événement d'intégration, ce qui est autre chose (ch. 11)

Deux remarques sur la colonne de droite.

Les absences sont documentées. Ce projet n'a ni service de domaine ni événement de domaine, et c'est écrit noir sur blanc. Un dépôt qui exhibe les sept briques parce qu'elles figurent dans le livre enseigne à cocher des cases ; un dépôt qui dit « je n'en ai pas besoin ici, et voilà pourquoi » enseigne à décider.

Ce ne sont pas des cases à cocher. Chaque brique répond à une question de la colonne du milieu. Si la question ne se pose pas, la brique est du décor — et du décor coûteux, parce qu'il faut le maintenir. Le chapitre 02 est précisément l'outil qui te dit laquelle sortir.

Ces briques ne se choisissent pas dans l'ordre du tableau. On part toujours du même endroit : où est la frontière de cohérence (ch. 04), puis quelle décision vit où (ch. 02). Le tableau est un inventaire, pas une méthode.

Value objects : définis par leur valeur#

La propriété qui définit un value object est l'absence d'identité propre : deux value objects de même valeur sont interchangeables. Deux Email("a@b.c") sont le même e-mail ; on ne se demande jamais « lequel des deux ? ».

L'immuabilité et l'auto-validation ne définissent pas le concept — ce sont des pratiques (excellentes) qui en découlent presque toujours.

ConceptCe que dit le principeCe que fait ce projet
Value objectPas d'identité, égalité par valeurdataclass(frozen=True, slots=True) : immuable, auto-validé, normalisé
# domain/task/value_objects.py
@dataclass(frozen=True, slots=True)   # frozen → immuable ; slots → pas de __dict__
class TaskTitle:
    value: str

    def __post_init__(self) -> None:
        cleaned = self.value.strip()
        if not cleaned:
            raise EmptyTitle()                     # invalide → l'objet n'existe pas
        if len(cleaned) > TITLE_MAX_LENGTH:
            raise TitleTooLong(TITLE_MAX_LENGTH)
        object.__setattr__(self, "value", cleaned) # normalisation, voir ci-dessous

Pourquoi object.__setattr__ ?#

frozen=True fait lever une erreur à toute affectation self.value = ..., y compris dans __post_init__. Pour normaliser la valeur (ici, retirer les espaces de bord) au moment de la construction, on contourne explicitement le garde-fou via object.__setattr__.

C'est une exception assumée et localisée : elle n'a lieu qu'à la construction, avant que l'objet ne soit visible de qui que ce soit. Après __init__, les affectations ordinaires sont interdites. Ce garde-fou n’est pas une frontière de sécurité : le même contournement reste techniquement possible ailleurs.

Le gain : always-valid model#

Monde extérieur TaskTitle(…) Domaine EmptyTitle str · JSON · argv validation + normalisation toujours valide l'objet n'est jamais construit " Rédiger la doc " "Rédiger la doc" ""

Un TaskTitle construit normalement garantit les validations du type. Cette garantie suppose que le code utilise les types et points d’entrée prévus : les annotations Python ne contrôlent pas les affectations à l’exécution, les attributs des entités restent publics et object.__setattr__ peut contourner un objet gelé.

Compare :

Email("bob@example.com")      # ✅ construit → forcément valide
Email("n'importe quoi")      # ❌ construction refusée par le value object

Un value object ne fait pas d'I/O#

Email illustre magnifiquement une frontière qu'on franchit sans y penser :

# domain/user/value_objects.py
validate_email(cleaned, check_deliverability=False)

Activer check_deliverability (le défaut de la bibliothèque) résoudrait les enregistrements MX du domaine — donc une I/O réseau au cœur du domaine. La validité d'un e-mail dépendrait alors du DNS : non déterministe, lente, et capable de rejeter une adresse parfaitement valide sur une panne de résolution.

La règle générale : un value object valide une forme, jamais un fait du monde extérieur. Vérifier qu'une adresse reçoit réellement du courrier est le travail d'un adaptateur (envoi d'un e-mail de confirmation), pas d'un VO.

Autres value objects du projet : UserName, TaskStatus (énumération d'états), et les identités TaskId / UserId.

Les identités sont des value objects#

UserId encapsule un uuid.UUID. Deux détails de conception :

@classmethod
def generate(cls) -> UserId:
    return cls(uuid.uuid7())      # UUIDv7, pas uuid4

@classmethod
def from_string(cls, raw: str) -> UserId:
    try:
        return cls(uuid.UUID(raw))
    except ValueError as exc:
        raise InvalidUserId(raw) from exc   # erreur métier, pas ValueError brute
UUIDv7 n'est pas une mesure de sécurité Il n'est pas séquentiel comme un auto-increment, mais il révèle l'instant de création. Si la confidentialité des identifiants compte, c'est une décision à prendre séparément.

Entités : définies par leur identité#

Une entité a une identité stable dans le temps. Une Task reste « la même tâche » quand son titre change. Deux entités sont égales si elles ont le même identifiant — pas la même valeur.

def __eq__(self, other: object) -> bool:
    return isinstance(other, Task) and other.id == self.id

def __hash__(self) -> int:
    return hash(self.id)

Note le __hash__ : Python le met à None dès qu'on définit __eq__. Sans lui, une Task ne pourrait plus entrer dans un set ni servir de clé de dict.

Les invariants vivent dans l'entité#

Un invariant est une règle qui doit rester vraie à tout instant. Le point crucial : l'entité protège ses propres invariants via ses méthodes. On ne modifie pas son état de l'extérieur en assignant ses attributs.

task.status = TaskStatus.DONE   # ❌ contourne la règle, completed_at reste None
task.complete()                 # ✅ la règle et la cohérence sont garanties

C'est le modèle riche (l'objet a du comportement), par opposition au modèle anémique (un sac de données que du code extérieur manipule).

Modèle anémiqueModèle riche
L'objet contientdes attributsdes attributs et des comportements
La règle vitdans les services / routersdans l'objet
Un nouvel appelantdoit reconnaître la règlel'obtient gratuitement
Symptômedes setters partoutdes verbes métier (complete, rename)
Nuance Python Ce projet laisse les attributs de Task publics : rien n'empêche techniquement task.status = .... Un modèle plus strict les préfixerait _status et exposerait des @property en lecture seule. C'est un curseur : plus de protection contre plus de cérémonie. Ce qui compte est que le chemin prévu passe par les méthodes, et que la revue de code sanctionne l'autre. Retiens le principe, pas la syntaxe.

Ce que l'outillage peut faire. La revue de code n'est pas le seul recours. Le jour où l'on passe aux propriétés, deux garde-fous existants ferment les deux contournements sans une ligne d'outillage sur mesure : mypy strict refuse task.status = X (« Property "status" is read-only »), et la règle Ruff SLF001 refuse la porte de service task._status = X. Cette dernière est déjà activée dans ce dépôt — elle attend le refactoring. En revanche, aucun linter ne peut rien tant que les attributs sont publics : sans inférence de types, il ne sait pas que task est une Task. C'est ce qui fait de l'encapsulation un choix de conception, pas un réglage d'outil.

Les factories : construire un objet valide#

Task.create() est une factory : elle produit un objet dans un état initial cohérent (statut TODO, created_at renseigné, completed_at à None, identité générée). Le constructeur brut, lui, sert à reconstituer une tâche existante — c'est ce qu'utilise le mapper de persistance (ch. 07).

Deux intentions différentes, deux points d'entrée : « créer quelque chose de neuf » ≠ « rematérialiser quelque chose qui existe déjà ».

Les exceptions métier#

Les erreurs du domaine sont des exceptions métier, pas des codes HTTP. Le domaine ignore HTTP ; il lève TaskNotFound, EmailAlreadyUsed, et c'est la présentation qui traduira (ch. 08).

# domain/shared/exceptions.py
class DomainError(Exception): ...
class ValidationError(DomainError): ...   # invariant de VO non respecté
class NotFoundError(DomainError): ...     # ressource introuvable
class ConflictError(DomainError): ...     # conflit d'unicité ou d'état

Attention à ne pas tout ranger sous DomainError. Une base injoignable, un timeout SMTP, un broker indisponible ne sont pas des erreurs métier. Ce projet leur donne une hiérarchie parallèle, InfrastructureError (application/shared/errors.py), traduite en 503 et non en 4xx. La distinction est développée au chapitre 08.

Pourquoi pas Pydantic dans le domaine ?#

Question légitime : ce serait plus concis. L'argument tient en une phrase :

Le domaine ne devrait pas dépendre d'une bibliothèque de validation/sérialisation dont il n'a pas besoin.

Pydantic est excellent — ce projet s'en sert massivement pour les schémas HTTP et la configuration, et le chapitre 07 montre pourquoi c'est le bon outil à la frontière. Simplement, dans le cœur, il apporte des fonctionnalités (parsing, coercition, sérialisation JSON) que le domaine n'utilise pas, au prix d'une dépendance externe à l'endroit qui devrait être le plus stable du projet. Les dataclass de la stdlib offrent l'essentiel du confort pour zéro dépendance.

Ce n'est pas une loi : certaines équipes acceptent Pydantic dans le domaine en assumant d'en suivre le cycle de vie. Le point non négociable est de savoir pourquoi on l'y met, plutôt que de l'y trouver par défaut.

À retenir#

À toi de jouer#

  1. Écris un value object Money (montant + devise) : refuse un montant négatif, refuse l'addition de deux devises différentes. Quelle méthode dois-tu ajouter pour que Money(10, "EUR") == Money(10, "EUR") soit vrai — et pourquoi dataclass te la donne-t-elle gratuitement, contrairement à Task ?
  2. Un collègue ajoute à Email une vérification que le domaine possède bien un enregistrement MX. Cite deux conséquences concrètes sur les tests unitaires du domaine.
  3. Task.rename() accepte de renommer une tâche DONE. Où irait la garde si le métier décidait de l'interdire — et où surtout pas ?

→ Corrigés