09. Lire sans passer par le domaine
Le contrepoint de tout ce qui précède — et l'endroit où beaucoup d'architectures propres deviennent lentes.
Le malentendu qui coûte cher#
Quand on a passé du temps à construire un domaine soigné, un réflexe s'installe : tout doit passer par lui. C'est vrai en écriture. C'est faux en lecture, et s'y accrocher produit des systèmes corrects et inutilisables.
Un agrégat est optimisé pour une seule chose : garantir des invariants au moment d'écrire. Il faut donc réunir tout ce dont ces invariants dépendent, et reconstruire le modèle métier — value objects compris — avant de pouvoir décider quoi que ce soit. Ce contrat a un prix, et ce prix n'achète rien quand on affiche un tableau : il n'y a aucun invariant à faire respecter.
L'écriture et la lecture n'ont tout simplement pas les mêmes besoins :
| Écriture | Lecture | |
|---|---|---|
| Objectif | Protéger les invariants | Servir un écran, vite |
| Forme utile | Agrégat cohérent | Projection à plat |
| Granularité | L'agrégat entier | Exactement les champs affichés |
| Traverse les frontières d'agrégats ? | Non — les invariants sont en jeu | Oui — aucun invariant n'est engagé |
| Ce qui compte | La justesse | Le temps de réponse et la charge base |
Ce que coûte vraiment une lecture par les agrégats#
Prenons un écran banal : 50 tâches avec le nom de leur propriétaire.
Par les agrégats#
tasks = await task_repository.list(limit=50) # 1 requête
for task in tasks:
user = await user_repository.get(task.owner_id) # 50 requêtes ← N+1
...Le compte : 51 requêtes pour afficher 4 colonnes. Chaque
Task reconstruit un TaskId, un UserId, un
TaskTitle, un TaskStatus ; chaque User
reconstruit un UserName et un Email — dont on
n'affichera que le nom. Puis tout est reconverti en str dans un DTO
de sortie.
C'est le N+1 (ch. 05), vu du côté applicatif. Et il est pernicieux : il ne se voit pas en développement avec dix lignes de données, il se voit en production à 500 requêtes par seconde, quand le pool de connexions est saturé et que la base passe son temps à répondre à des micro-requêtes.
Par un query service#
SELECT t.id, t.title, t.status, u.name AS owner_name
FROM tasks t
JOIN users u ON u.id = t.owner_id
ORDER BY t.created_at DESC
LIMIT 50;Une requête. Aucun agrégat construit, aucun value object alloué, exactement les quatre colonnes affichées. La base fait ce qu'elle sait faire de mieux.
La mesure, pas l'intuition#
Ce dépôt implémente les deux chemins, et un test d'intégration
compte les requêtes SQL réellement émises en s'abonnant à l'événement SQLAlchemy
before_cursor_execute. Sur 200 tâches réparties entre 10
propriétaires, contre un vrai PostgreSQL :
| Chemin | Requêtes SQL | Durée |
|---|---|---|
Par les agrégats (list puis un get par tâche) | 201 | 84,6 ms |
| Par le query service | 1 | 6,6 ms |
201× moins de requêtes, 12,8× plus rapide — et l'écart se creuse avec le volume, puisque l'un est linéaire en nombre d'allers-retours et l'autre non.
Les tests de tests/integration/api/test_exports_api.py vérifient le nombre de SELECT : un pour la projection, 1 + N pour la boucle naïve. Les durées ci-dessus sont un relevé historique, dépendant de la machine et des données ; elles ne sont pas des assertions de test ni une promesse de performance.
Le geste : jeter le domaine, délibérément#
C'est la partie contre-intuitive. Sur ce chemin de lecture, on abandonne volontairement :
- les entités et les value objects — on ne construit ni
TaskTitleniEmail; - les invariants — leurs transitions ne sont pas nécessaires pour une projection. Les règles d’autorisation et de confidentialité restent obligatoires ; ce dépôt de démonstration ne les implémente pas ;
- la séparation des agrégats à la lecture — on joint
tasksetuserssans scrupule, puisqu'aucun invariant de l'un ou de l'autre n'est engagé ; - les repositories — on ne passe pas par
TaskRepository.
Ce n'est pas une entorse honteuse à l'architecture : c'est l'architecture qui fonctionne comme prévu. Les invariants existent pour empêcher d'écrire un état incohérent ; une projection de lecture n'en engage aucun. Payer le prix d'une protection qui ne protège rien ici est une erreur de conception, pas une preuve de rigueur.
Le corollaire vaut d'être dit : ce n'est pas le mot « lecture » qui autorise le raccourci. Une lecture qui sert à décider d'une écriture — charger une tâche pour la compléter — repasse par l'agrégat, parce que la décision, elle, engage bien les invariants.
La règle qui reste, elle, est ferme :
Une écriture qui doit faire respecter des invariants passe par le modèle qui les porte. Un query service, lui, est en lecture seule, sans exception : le jour où il écrit, les invariants ne valent plus rien nulle part.
Anatomie d'un query service#
Ce dépôt en implémente un, qui sert l'export global des tâches avec leur
propriétaire. Le port, en
application/task/queries.py :
@dataclass(frozen=True, slots=True)
class TaskWithOwner: # DTO de LECTURE : plat, des types simples
task_id: str
title: str
description: str | None # ← porté pour l'import, jamais affiché (ch. 10)
status: str
created_at: datetime
completed_at: datetime | None
owner_id: str
owner_name: str
owner_email: str
class TaskQueryPort(ABC):
"""Chemin de LECTURE. Aucune écriture, sans exception."""
@abstractmethod
def stream_with_owner(self) -> AsyncIterator[TaskWithOwner]: ...
@abstractmethod
async def count(self) -> int: ...L'implémentation, en
infrastructure/persistence/task/queries.py :
async def stream_with_owner(self) -> AsyncIterator[TaskWithOwner]:
statement = (
select(
TaskModel.id, TaskModel.title, TaskModel.description, TaskModel.status,
TaskModel.created_at, TaskModel.completed_at,
UserModel.id.label("owner_id"),
UserModel.name.label("owner_name"),
UserModel.email.label("owner_email"),
)
.join(UserModel, TaskModel.owner_id == UserModel.id)
.order_by(TaskModel.created_at)
.execution_options(yield_per=500) # curseur serveur, mémoire constante
)
result = await self._session.stream(statement)
async for row in result:
yield TaskWithOwner(task_id=str(row.id), ...)Cinq points de discipline :
- L'implémentation vit dans l'infrastructure, comme tout ce qui parle SQL.
- Elle ne renvoie jamais d'entités, seulement des DTO de lecture — sinon on réintroduit la navigation entre agrégats par la porte de derrière.
- Elle est en lecture seule. Aucun
INSERT, aucunUPDATE, jamais. - Elle peut traverser plusieurs frontières d'agrégats, parce qu'elle ne cherche ni à faire respecter ni à modifier leurs invariants : elle construit une vue de lecture adaptée au besoin. Le critère n'est pas « lecture = libre, écriture = interdit », c'est est-ce que cette opération engage les invariants des agrégats qu'elle touche ?. Ce qu'on veut éviter, c'est la reconstruction artificielle de plusieurs agrégats dans le modèle métier pour produire un écran.
- Elle est bornée — ou elle diffuse. Une lecture non
bornée qui matérialise tout est une panne en attente. Ici on a choisi la
seconde voie : un flux, avec
yield_per, dont la mémoire ne dépend pas du volume.
Pas de use case : le chemin de lecture est plus court#
Le router appelle le port directement. Il n'y a rien à orchestrer : aucune orchestration métier supplémentaire. La lecture SQL ouvre néanmoins une transaction et le streaming conserve la session jusqu’à la fin du flux. Ajouter un use case ne ferait qu'insérer une indirection vide.
écriture : router → use case → domaine → repository (4 niveaux)
lecture : router → query service (2 niveaux)Cette asymétrie est visible dans l'arborescence, et c'est voulu : la lecture n'a pas besoin des mêmes garanties, elle ne doit donc pas payer la même cérémonie.
Streamer plutôt que paginer#
L’export utilise du NDJSON, un objet JSON par ligne, dans une StreamingResponse. Un repository pourrait aussi proposer une itération paginée ; le query service évite ici la construction d’agrégats et les lectures supplémentaires. Le streaming n’est pas une propriété exclusive de ce pattern.
200 OK. Le frontend compare les lignes reçues au compte fourni par une autre requête : c’est un indice, pas une preuve d’intégrité, car des écritures peuvent intervenir entre les deux. Une garantie forte demande par exemple un snapshot commun et un manifeste de fin avec nombre de lignes et checksum ; le client doit aussi détecter les erreurs de lecture réseau.Un détail non évident, vérifié plutôt que supposé : avec une
StreamingResponse, la dépendance yield qui fournit la
session reste ouverte pendant toute la consommation du corps.
Sans cette propriété, le curseur serait fermé avant la fin du flux. Le curseur vit
ainsi dans la transaction de la requête, ce qui reste compatible avec
PgBouncer en mode transaction — seuls les curseurs WITH HOLD,
qui survivent au commit, y sont interdits.
Faut-il un port ?#
Deux écoles, et les deux se défendent.
| Sans port | Avec port | |
|---|---|---|
| Le router dépend de… | la classe concrète en infrastructure | une interface en application/ |
| Cérémonie | minimale | une interface de plus |
| Test du router | exige une vraie base | doublure en mémoire possible |
| Cohérence avec ce dépôt | rompt le motif (tout passe par un port) | conforme |
Ce projet a tranché pour le port : la thèse du dépôt est que la règle de dépendance est vérifiée et uniforme, et un router qui importerait directement une classe SQLAlchemy serait le seul endroit à rompre le motif. Ailleurs, sauter le port est parfaitement légitime — un query service est déjà le modèle de lecture, et l'abstraire n'apporte parfois rien.
| Concept | Ce que dit le principe | Ce que fait ce projet |
|---|---|---|
| Chemin de lecture | Peut court-circuiter les agrégats | Agrégats pour les listes simples ; query service pour l'export joint (voir plus bas) |
| Abstraction des lectures | Non prescrite | Port en application/, implémentation en infrastructure/ (par cohérence) |
Quand basculer ? Le tableau de décision#
| Situation | Chemin |
|---|---|
| Créer, modifier, supprimer | Agrégats, toujours |
| Charger un objet pour le modifier | Agrégats (il faut les invariants) |
| Afficher un objet seul, quelques champs | Agrégats — le coût est négligeable |
| Afficher une liste | Query service dès que la liste est longue ou fréquente |
| Afficher des données de deux agrégats | Query service |
| Compter, agréger, faire des statistiques | Query service (COUNT, GROUP BY — jamais en Python) |
| Exporter, alimenter un rapport | Query service |
Le signal le plus fiable : tu écris une boucle Python qui refait ce
qu'un JOIN ou un GROUP BY ferait mieux. À ce
moment-là, la base est en train de travailler pour rien.
Et CQRS, alors ?#
CQRS signifie Command Query Responsibility Segregation : séparer les modèles utilisés pour modifier et consulter les données. Cela n’impose ni deux bases, ni une projection asynchrone, ni Event Sourcing. Voir la définition de Martin Fowler.
Un query service est un mécanisme possible de cette séparation. Le dépôt utilise une forme légère : agrégats pour les mutations, TaskWithOwner pour la projection d’export, sur la même base. Ajouter un service de lecture ne force pas à transformer tous les endpoints.
1. Un modèle pour les commandes et les lectures
2. Commandes via agrégats + projections de lecture sur la même base (ici)
3. Modèles de lecture pré-calculés si les mesures le justifient
4. Stockage de lecture séparé, si le besoin le justifie
Event Sourcing : choix indépendant de conserver les événements comme source de vérité.Chaque étape ajoute des coûts différents. Une vue matérialisée peut vivre dans la même base ; une projection asynchrone ajoute du retard et des reprises à gérer. Il n’existe pas de passage obligé vers une architecture « complète ».
Et ce projet ?#
Il est au niveau 2, et de façon délibérément minimale : un seul chemin de lecture, introduit parce qu'un besoin réel l'exigeait.
Ce besoin : le frontend a un bouton « Exporter » qui télécharge toutes les tâches avec les informations de leur propriétaire. C'est le cas d'école — deux agrégats joints, volume non borné, aucun invariant en jeu. C'est exactement le point de bascule que ce chapitre annonçait ; il a été franchi quand il s'est présenté, pas avant.
Le reste du projet est resté au niveau 1, et c'est un choix, pas un oubli :
- Les trois endpoints de liste (
GET /users,GET /tasks,GET /users/{id}/tasks) passent toujours par les agrégats. Aucun ne joint deux agrégats, donc aucun n'a de N+1 :list_by_ownerest unSELECTfiltré sur une clé étrangère indexée. - Il y a du gaspillage résiduel, assumé. Chaque ligne y est reconstruite en agrégat complet avec ses value objects, pour être aussitôt reconvertie en DTO de sortie. Invisible à cette échelle.
ListTasksByOwnerfait deux allers-retours : unexists()sur l'user, puis la liste. C'est le prix de la distinction entre « user inconnu » (404) et « user sans tâche » (liste vide). Un query service le ferait en une requête, avec unLEFT JOINet un test sur le résultat.
C'est la leçon qui compte plus que le code : on ne migre pas tout au niveau 2 parce qu'on a compris le niveau 2. On y déplace ce qui le justifie, et on mesure. Le dépôt contient les deux états côte à côte, ce qui rend la comparaison lisible.
À retenir#
- Les agrégats protègent les écritures. En lecture, leur contrat coûte sans rien rapporter.
- Sur un chemin de lecture, on abandonne délibérément entités, value objects, invariants et séparation des agrégats. Ce n'est pas une entorse : c'est le bon usage.
- La règle qui ne bouge pas : une écriture qui doit faire respecter des invariants passe par le modèle qui les porte — y compris quand il y en a cent mille (chapitre 10).
- Un query service fait le SQL exact et rend un DTO plat, borné, en lecture seule.
- Le vrai danger du N+1 n'est pas la page lente, c'est la charge base qui manque ensuite aux écritures.
- Un query service peut participer à un CQRS léger. Séparer les modèles de lecture et d’écriture n’impose pas de séparer les bases.
À toi de jouer#
- Un écran affiche « mes 20 dernières tâches + le nom du propriétaire + le
nombre total de mes tâches ». a. écris la signature du
query service et de son DTO ; b. combien de requêtes SQL au
minimum ? c. pourquoi ne pas ajouter cette méthode à
TaskRepository? - Un collègue propose que le query service renvoie des entités
Task« pour réutiliser le mapper ». Donne les deux raisons de refuser. - Sur
GET /users/{id}/tasks, on veut supprimer leexists()préalable pour n'avoir qu'une requête. Que perd-on du point de vue de l'appelant HTTP, et comment un query service pourrait-il rendre les deux à la fois ?
→ Corrigés