FastAPI Hexagonal Code source

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.

TL;DR — Les agrégats sont conçus pour protéger les écritures. Pour lire, ils sont souvent le mauvais outil : sur-lecture, allers-retours, N+1, et une base qui sature pour afficher trois colonnes. Dans ces cas-là, on court-circuite le domaine : un query service fait le SQL exact et rend un DTO plat. Écriture stricte d'un côté, lecture rapide de l'autre — la forme légère de CQRS.

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 :

ÉcritureLecture
ObjectifProtéger les invariantsServir un écran, vite
Forme utileAgrégat cohérentProjection à plat
GranularitéL'agrégat entierExactement les champs affichés
Traverse les frontières d'agrégats ?Non — les invariants sont en jeuOui — aucun invariant n'est engagé
Ce qui compteLa justesseLe 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.

Le point important n'est pas la lenteur d'une page C'est qu'une lecture mal conçue consomme des connexions et du CPU base qui manquent alors aux écritures — celles-là mêmes qu'on a tant soigné à rendre correctes. Une lecture négligée finit par dégrader le chemin critique.

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 :

CheminRequêtes SQLDurée
Par les agrégats (list puis un get par tâche)20184,6 ms
Par le query service16,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 :

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 :

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.

Le prix du streaming. Le statut est envoyé avant que le corps soit entièrement produit. Une panne peut donc tronquer un flux annoncé avec 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 portAvec port
Le router dépend de…la classe concrète en infrastructureune interface en application/
Cérémonieminimaleune interface de plus
Test du routerexige une vraie basedoublure en mémoire possible
Cohérence avec ce dépôtrompt 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.

ConceptCe que dit le principeCe que fait ce projet
Chemin de lecturePeut court-circuiter les agrégatsAgrégats pour les listes simples ; query service pour l'export joint (voir plus bas)
Abstraction des lecturesNon prescritePort en application/, implémentation en infrastructure/ (par cohérence)

Quand basculer ? Le tableau de décision#

SituationChemin
Créer, modifier, supprimerAgrégats, toujours
Charger un objet pour le modifierAgrégats (il faut les invariants)
Afficher un objet seul, quelques champsAgrégats — le coût est négligeable
Afficher une listeQuery service dès que la liste est longue ou fréquente
Afficher des données de deux agrégatsQuery service
Compter, agréger, faire des statistiquesQuery service (COUNT, GROUP BY — jamais en Python)
Exporter, alimenter un rapportQuery 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 :

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#

À toi de jouer#

  1. 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 ?
  2. Un collègue propose que le query service renvoie des entités Task « pour réutiliser le mapper ». Donne les deux raisons de refuser.
  3. Sur GET /users/{id}/tasks, on veut supprimer le exists() 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