« Tout passe par le domaine » — la plus coûteuse des idées reçues

Vous l'avez déjà vu passer en review. Peut-être même écrit. Cette phrase qui semble tomber sous le sens dans un projet DDD ou en architecture hexagonale : "Tout passe par le domaine." Bonne intention, mauvaise application. Et dans la pratique, ça coûte cher.

On va partir d'un cas qu'on a tous croisé, revenir sur ce que l'hexagonale et DDD font vraiment chacun de leur côté, et comprendre pourquoi forcer les lectures à passer par l'agrégat est un piège — avant de voir comment s'en sortir.


Le symptôme avant le diagnostic

Use case simple : afficher la liste des prescriptions d'un patient — juste le nom du médicament et le libellé de posologie.

Voici le code qu'on rencontre souvent :

// application/usecase/ListPrescriptionsUseCase.java
@Service
public class ListPrescriptionsUseCase {

    private final PrescriptionLineRepository repo;
    private final PrescriptionLineMapper mapper;

    public ListPrescriptionsUseCase(PrescriptionLineRepository repo,
                                    PrescriptionLineMapper mapper) {
        this.repo   = repo;
        this.mapper = mapper;
    }

    public List<PrescriptionLineDomain> execute(PatientId patientId) {
        List<PrescriptionLineEntity> entities =
            repo.findByPatientId(patientId.value()); // charge tout
        return mapper.toDomain(entities);            // mapping #1
    }
}
// adapter/in/http/PrescriptionController.java
@RestController
@RequestMapping("/patients")
public class PrescriptionController {

    private final ListPrescriptionsUseCase useCase;
    private final PrescriptionLineMapper mapper;

    public PrescriptionController(ListPrescriptionsUseCase useCase,
                                  PrescriptionLineMapper mapper) {
        this.useCase = useCase;
        this.mapper  = mapper;
    }

    @GetMapping("/{patientId}/prescriptions")
    public ResponseEntity<List<PrescriptionLineDto>> list(
            @PathVariable String patientId) {

        List<PrescriptionLineDomain> domains =
            useCase.execute(new PatientId(patientId));
        List<PrescriptionLineDto> dtos =
            mapper.toDto(domains);              // mapping #2
        return ResponseEntity.ok(dtos);
    }
}

Est-ce que vous l'avez déjà écrit ? Vu en review ? Mergé ?

Regardons ce que ce code fait vraiment :

Ce n'est pas un problème mineur. C'est du boilerplate, de la dégradation de performance, et du bruit dans le code. Alors d'où ça vient ?


La confusion à l'origine de tout

Quand on cherche l'explication, deux phrases reviennent souvent :

"DDD protège le domaine donc tout passe par le domaine."

"C'est à cause de l'architecture hexagonale qu'on mappe autant."

Ces deux phrases sont fausses.

Le mapping excessif n'est pas une conséquence de l'architecture hexagonale, ni une exigence de DDD. C'est le résultat d'une confusion entre deux outils complémentaires qu'on applique ensemble sans comprendre ce que chacun fait vraiment.

Architecture Hexagonale vs Domain-Driven Design

Architecture Hexagonale Domain-Driven Design
Question centrale Comment orienter les dépendances ? Comment modéliser le domaine ?
Bénéfice principal Testabilité, isolation de l'infra Cohérence métier, expressivité
Risque si mal appliqué Trop de couches inutiles Agrégat partout, même en read
Utilisé pour Structure du code Modélisation métier

L'hexagonale répond à : comment j'oriente mes dépendances ? Le domaine est au centre et ne dépend de rien d'externe. Les ports sont des interfaces définies par le domaine, les adapters sont les implémentations (JPA, HTTP, Kafka…). La règle est structurelle : les dépendances pointent toujours vers l'intérieur.

DDD répond à : comment je modélise ma complexité métier ? L'agrégat protège les invariants, l'ubiquitous language traduit le vocabulaire métier en code, les bounded contexts délimitent des périmètres cohérents. C'est une approche de modélisation, pas d'architecture.

On peut faire de l'hexagonal sans DDD, et du DDD sans hexagonal. Ils sont complémentaires, pas synonymes. Les mélanger sans distinguer leurs rôles, c'est précisément ce qui génère le mapping excessif.


Ce qu'est vraiment un agrégat DDD

Un agrégat n'est pas un simple conteneur de données. C'est une frontière de cohérence : il regroupe les objets qui doivent être modifiés ensemble pour rester dans un état valide, et il garantit que personne ne peut les mettre dans un état incohérent depuis l'extérieur.

Prenons PrescriptionLine :

// domain/model/PrescriptionLine.java
public class PrescriptionLine {

    private final PrescriptionId id;
    private final PatientId patientId;
    private final Medicament medicament;
    private Posologie posologie;    // mutable → pas final
    private boolean suspendue;

    // Invariant : tous les champs obligatoires à la construction
    // → Un PrescriptionLine dans un état partiel ne peut pas exister
    public PrescriptionLine(PrescriptionId id,
                            PatientId patientId,
                            Medicament medicament,
                            Posologie posologie) {
        Objects.requireNonNull(id,         "id obligatoire");
        Objects.requireNonNull(patientId,  "patientId obligatoire");
        Objects.requireNonNull(medicament, "medicament obligatoire");
        Objects.requireNonNull(posologie,  "posologie obligatoire");
        this.id         = id;
        this.patientId  = patientId;
        this.medicament = medicament;
        this.posologie  = posologie;
        this.suspendue  = false;
    }

    // Invariant : on ne peut pas suspendre deux fois
    public void suspendre() {
        if (this.suspendue) {
            throw new PrescriptionDomainException("La prescription est déjà suspendue");
        }
        this.suspendue = true;
    }

    // Invariant : pas de changement de posologie sur une ligne suspendue
    public void changerPosologie(Posologie nouvellePosologie) {
        Objects.requireNonNull(nouvellePosologie, "posologie obligatoire");
        if (this.suspendue) {
            throw new PrescriptionDomainException(
                "Impossible de modifier une prescription suspendue");
        }
        this.posologie = nouvellePosologie;
    }

    // Getters — lecture seule, aucun setter public
    public PrescriptionId getId()     { return id; }
    public PatientId getPatientId()   { return patientId; }
    public Medicament getMedicament() { return medicament; }
    public Posologie getPosologie()   { return posologie; }
    public boolean isSuspendue()      { return suspendue; }
}

Trois propriétés clés :

Un agrégat, c'est un gardien des règles, pas juste un conteneur de données.


Les domain events — ce que l'agrégat sait faire d'autre

En DDD, un agrégat ne se contente pas de protéger ses invariants. Quand son état change de façon significative, il enregistre un domain event : un fait métier qui s'est produit, exprimé dans le langage du domaine.

// domain/event/PrescriptionSuspendue.java
public record PrescriptionSuspendue(
    PrescriptionId prescriptionId,
    PatientId patientId,
    Instant occurredAt
) {}
// domain/event/PosologieModifiee.java
public record PosologieModifiee(
    PrescriptionId prescriptionId,
    PatientId patientId,
    Posologie anciennePosologie,
    Posologie nouvellePosologie,
    Instant occurredAt
) {}

L'agrégat enregistre ces events en mémoire lors d'une opération métier, mais ne les publie pas lui-même. C'est la couche application qui s'en charge, après avoir persisté l'agrégat, dans la même transaction. En Spring, le pattern le plus courant s'appuie sur AbstractAggregateRoot :

// domain/model/PrescriptionLine.java
public class PrescriptionLine extends AbstractAggregateRoot<PrescriptionLine> {

    // ... champs identiques à ci-dessus ...

    public void suspendre() {
        if (this.suspendue) {
            throw new PrescriptionDomainException("La prescription est déjà suspendue");
        }
        this.suspendue = true;
        // L'event est enregistré en mémoire dans l'agrégat — pas encore publié
        registerEvent(new PrescriptionSuspendue(this.id, this.patientId, Instant.now()));
    }

    public void changerPosologie(Posologie nouvellePosologie) {
        Objects.requireNonNull(nouvellePosologie, "posologie obligatoire");
        if (this.suspendue) {
            throw new PrescriptionDomainException(
                "Impossible de modifier une prescription suspendue");
        }
        Posologie ancienne = this.posologie;
        this.posologie = nouvellePosologie;
        registerEvent(new PosologieModifiee(
            this.id, this.patientId, ancienne, nouvellePosologie, Instant.now()));
    }
}

Le dispatch transactionnel : le rôle de la couche application

La couche application orchestre le write : elle charge l'agrégat, appelle la méthode métier, persiste, et c'est Spring Data qui publie les events via ApplicationEventPublisher juste après le save(), toujours dans la même transaction.

// application/usecase/command/SuspendPrescriptionCommandHandler.java
@Service
@Transactional
public class SuspendPrescriptionCommandHandler {

    private final PrescriptionLineRepository repo;

    public SuspendPrescriptionCommandHandler(PrescriptionLineRepository repo) {
        this.repo = repo;
    }

    public void handle(SuspendPrescriptionCommand command) {
        PrescriptionLine prescription = repo
            .findById(command.prescriptionId())
            .orElseThrow(() -> new PrescriptionNotFoundException(command.prescriptionId()));

        prescription.suspendre();
        // ↑ L'event PrescriptionSuspendue est enregistré en mémoire dans l'agrégat

        repo.save(prescription);
        // ↑ Spring Data appelle @DomainEvents → publie PrescriptionSuspendue
        //   dans la même transaction, avant le commit
    }
}

D'autres parties du système peuvent écouter cet event :

// application/listener/PrescriptionSuspendueListener.java
@Component
public class PrescriptionSuspendueListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onPrescriptionSuspendue(PrescriptionSuspendue event) {
        // notifier le médecin prescripteur, mettre à jour un index, etc.
    }
}

Ce mécanisme repose sur une garantie forte : l'event est publié si et seulement si la transaction est committée. Cette garantie s'effondre si l'agrégat sort de la frontière de l'hexagone.


Pourquoi la fuite de l'agrégat est particulièrement dangereuse

Si le use case retourne directement l'agrégat domaine au lieu d'un Read Model, plusieurs problèmes graves apparaissent.

1. Sérialisation accidentelle des domain events

Un agrégat qui hérite d'AbstractAggregateRoot porte en lui une liste interne d'events en attente de dispatch. Quand Jackson sérialise cet objet pour le renvoyer en HTTP, tous ces events se retrouvent dans la réponse JSON.

{
  "id": "rx-001",
  "medicament": { "nom": "Metformine" },
  "posologie": { "libelle": "1 comprimé matin et soir" },
  "suspendue": false,
  "domainEvents": [
    {
      "prescriptionId": "rx-001",
      "patientId": "patient-42",
      "occurredAt": "2024-03-15T10:30:00Z"
    }
  ]
}

Un client HTTP voit des events internes qui n'ont rien à faire dans une API publique. C'est une fuite d'information métier sensible, et un couplage fort entre le contrat HTTP et les internals de l'agrégat.

2. Dispatch hors du contexte transactionnel

Le dispatch des events est conçu pour se produire après la persistance, dans la transaction ouverte par la couche application. Si l'agrégat circule librement en dehors de ce contexte — passé de main en main entre services, mis en cache, retourné à un controller — le cycle de vie de l'event n'est plus garanti. L'event peut être déclenché trop tôt, trop tard, plusieurs fois, ou jamais.

3. Couplage du contrat API au modèle domaine

Le modèle domaine doit pouvoir évoluer librement. Si demain on ajoute un champ, on renomme un invariant, on change la structure de Medicament ou Posologie — tout cela doit rester interne. Si l'agrégat est exposé directement dans l'API HTTP, chaque évolution interne casse le contrat public. C'est l'inverse de ce que l'architecture hexagonale promet.

4. L'API impose sa forme à l'agrégat

C'est la conséquence la plus pernicieuse. Une fois que l'agrégat est sérialisé tel quel, les développeurs front ou les consommateurs API commencent à dépendre de sa structure. On finit par ne plus pouvoir refactorer le domaine sans négocier un breaking change côté API. Le domaine, censé rester autonome, est désormais contraint par l'extérieur.


L'erreur : utiliser un outil de write pour les reads

Revenons à notre use case de lecture. Le problème de fond, c'est qu'on utilise l'agrégat — un outil conçu pour les writes — pour répondre à une question de lecture.

Voici le chemin complet exécuté pour afficher deux champs :

HTTP GET /patients/42/prescriptions
    ↓
ListPrescriptionsUseCase.execute()
    ↓
repo.findByPatientId("42")
→ SELECT * FROM prescription_line WHERE patient_id = '42'
  + potentiellement N requêtes supplémentaires (lazy loading sur Medicament, Posologie…)
    ↓
mapper.toDomain(entities)
→ instanciation de PrescriptionLine : vérification de tous les requireNonNull,
  construction des value objects Medicament et Posologie,
  initialisation de AbstractAggregateRoot,
  création de la liste d'events vide…
    ↓
mapper.toDto(domains)
→ re-parcours de tous les objets pour en extraire deux champs
    ↓
JSON : { "nomMedicament": "...", "posologie": "..." }

Le problème du lazy loading. Avec Hibernate, les relations sont chargées en lazy par défaut. Si Medicament est une entité liée (@ManyToOne), Hibernate émet une requête séparée par ligne pour le charger. Sur 50 prescriptions : 1 requête pour la liste + 50 requêtes pour les médicaments = 51 requêtes pour afficher un écran. C'est le problème N+1, et il est directement causé par l'utilisation de l'agrégat en lecture.

Les coûts réels :

Le point clé : l'agrégat existe pour protéger les invariants lors des opérations d'écriture. Sur une lecture pure, il n'y a aucun invariant à protéger. L'utiliser ici est du gaspillage pur.


La solution : bypasser l'agrégat sur les reads

La solution émerge naturellement une fois qu'on a compris le problème.

Sur les writes, on passe par l'agrégat : il protège les invariants et enregistre les domain events.

Sur les reads, on bypasse complètement l'agrégat. Un Query Handler appelle un Query Port, qui retourne un Read Model directement depuis la base avec une requête ciblée. L'agrégat n'est jamais instancié.

Ce n'est pas un nouveau pattern exotique. C'est du CQRS léger — Command Query Responsibility Segregation — appliqué au niveau de l'application : le chemin des commands (writes) et le chemin des queries (reads) sont distincts. Et c'est l'hexagonale bien appliquée : le domaine définit ce qu'il veut lire via un port, l'infra décide comment.

Situer cette approche sur le spectre CQRS

Le CQRS est un spectre, pas un interrupteur binaire. Ce qui est proposé ici se situe volontairement au premier niveau : la séparation des chemins de code au sein d'une même application, sur une même base de données.

Au-delà, il existe des niveaux de séparation plus forts. On peut alimenter un store de lecture dédié (une vue matérialisée, un index Elasticsearch, une table dénormalisée) via des projections asynchrones construites à partir des domain events. Les writes et les reads ne partagent plus le même modèle de données — les projections sont optimisées pour les écrans, et elles sont mises à jour de façon réactive quand un event est publié.

Poussé à son maximum, le CQRS se combine avec l'event sourcing : l'état de l'agrégat n'est plus stocké directement en base, mais reconstitué à partir de la séquence complète de ses events. Le store de lecture devient alors une projection à part entière, recalculable à tout moment en rejouant l'historique.

Ces niveaux apportent des bénéfices réels — scalabilité indépendante des reads et des writes, auditabilité native, possibilité de reconstruire des vues a posteriori — mais aussi une complexité significative : eventual consistency, gestion de l'idempotence, infrastructure de messaging, debugging plus difficile.

Le CQRS léger présenté ici n'a aucun de ces coûts. Même base, même transaction, même cohérence forte. Il suffit de séparer les chemins de code. C'est un premier pas qui apporte 80 % des bénéfices architecturaux pour un coût quasi nul — et qui prépare le terrain si un jour la séparation plus forte devient nécessaire.

Concrètement

Le value object PatientId — utilisé partout, rarement montré :

// domain/model/PatientId.java
public record PatientId(String value) {
    public PatientId {
        Objects.requireNonNull(value, "patientId obligatoire");
    }
}

Le Read Model — un record plat, dans le domaine :

// domain/readmodel/PrescriptionLineReadModel.java
public record PrescriptionLineReadModel(
    String prescriptionId,
    String nomMedicament,
    String posologie
) {}

Ce record vit dans domain/readmodel, et c'est sa place. Le Read Model est un contrat de sortie défini par le métier : c'est le domaine qui sait ce qu'il expose en lecture, quelles données sont visibles, et sous quelle forme. Le placer dans le domaine garantit que c'est le métier — pas l'infra, pas un adapter — qui en est propriétaire. C'est cohérent avec la règle de l'hexagonale : les ports sont définis par le domaine, et le Read Model est le type de retour du port de lecture.

Le port de lecture — dans le domaine :

// domain/port/out/PrescriptionLineQueryPort.java
public interface PrescriptionLineQueryPort {
    List<PrescriptionLineReadModel> findByPatientId(PatientId patientId);
}

L'entité JPA — dans l'infra, structure plate, aucune règle métier :

// infra/adapter/out/jpa/PrescriptionLineEntity.java
@Entity
@Table(name = "prescription_line")
class PrescriptionLineEntity {

    @Id
    String id;
    String patientId;
    String medicamentNom;       // dénormalisé — pas de relation @ManyToOne
    String posologieLibelle;
    boolean suspendue;
}

L'entité JPA est une projection plate du modèle relationnel. Elle ne connaît pas l'agrégat domaine, n'a pas de règle métier, pas d'events. Sa seule responsabilité : mapper la table SQL.

L'adapter JPA — dans l'infra, implémente le port :

// infra/adapter/out/jpa/JpaPrescriptionLineQueryAdapter.java
@Component
class JpaPrescriptionLineQueryAdapter implements PrescriptionLineQueryPort {

    private final PrescriptionLineJpaRepository jpaRepository;

    JpaPrescriptionLineQueryAdapter(PrescriptionLineJpaRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }

    @Override
    public List<PrescriptionLineReadModel> findByPatientId(PatientId patientId) {
        return jpaRepository.findReadModelsByPatientId(patientId.value());
    }
}
// infra/adapter/out/jpa/PrescriptionLineJpaRepository.java
// Spring Data — interne à l'infra, invisible du domaine
interface PrescriptionLineJpaRepository
        extends JpaRepository<PrescriptionLineEntity, String> {

    @Query("""
        SELECT new com.example.domain.readmodel.PrescriptionLineReadModel(
            p.id,
            p.medicamentNom,
            p.posologieLibelle
        )
        FROM PrescriptionLineEntity p
        WHERE p.patientId = :patientId
        """)
    List<PrescriptionLineReadModel> findReadModelsByPatientId(
            @Param("patientId") String patientId);
}

Le JPQL constructor expression (SELECT new …) instancie directement le record Java depuis la requête. Une seule requête SQL ciblée sur 3 colonnes. Le problème N+1 disparaît, l'agrégat n'est jamais touché, et les domain events ne risquent jamais de se retrouver dans la réponse HTTP.

Le handler — une délégation, rien de plus :

// application/usecase/query/ListPrescriptionsQueryHandler.java
@Service
public class ListPrescriptionsQueryHandler {

    private final PrescriptionLineQueryPort queryPort;

    public ListPrescriptionsQueryHandler(PrescriptionLineQueryPort queryPort) {
        this.queryPort = queryPort;
    }

    public List<PrescriptionLineReadModel> handle(PatientId patientId) {
        return queryPort.findByPatientId(patientId);
    }
}

Le controller — ne mappe rien, retourne directement le Read Model :

// adapter/in/http/PrescriptionController.java
@RestController
@RequestMapping("/patients")
public class PrescriptionController {

    private final ListPrescriptionsQueryHandler queryHandler;

    public PrescriptionController(ListPrescriptionsQueryHandler queryHandler) {
        this.queryHandler = queryHandler;
    }

    @GetMapping("/{patientId}/prescriptions")
    public ResponseEntity<List<PrescriptionLineReadModel>> list(
            @PathVariable String patientId) {
        return ResponseEntity.ok(queryHandler.handle(new PatientId(patientId)));
    }
}

Avant / Après

Avant Après
Mappings manuels 2 (entity→domain + domain→DTO) 1 (JPQL → record, délégué à l'ORM)
Requêtes SQL 1 SELECT * + N lazy loads 1 SELECT ciblé sur 3 colonnes
Agrégat instancié Oui (entier, avec events) Non
Risque de fuite d'events Oui Aucun
Overhead mémoire O(n) inutile Minimal
Code 3 classes + 2 mappers 1 handler + 1 adapter

Le résultat côté utilisateur est identique. La différence, c'est tout ce qu'on a évité de faire tourner inutilement.


Quand utiliser l'agrégat en lecture ?

Bypasser l'agrégat sur les reads est la règle générale — mais pas absolue. Il existe des cas où instancier l'agrégat pour une lecture a du sens. Pour que cette section soit réellement utile, il faut aller au-delà de la règle et regarder les cas concrets.

Cas 1 : Vérifier une pré-condition métier avant d'afficher une action

L'écran affiche un bouton "Modifier la posologie". Ce bouton ne doit apparaître que si la prescription n'est pas suspendue. La question "est-ce que cette prescription peut encore être modifiée ?" dépend des invariants de l'agrégat — c'est isSuspendue(), mais demain ça pourrait être une combinaison de statuts, de dates d'expiration, de rôles. La logique de décision vit dans l'agrégat, et c'est là qu'elle doit rester.

Dans ce cas, charger l'agrégat pour évaluer la pré-condition est justifié. On ne fait pas une lecture "plate" — on demande au domaine de rendre un verdict.

Cas 2 : L'état courant d'un processus métier complexe

Si l'écran doit refléter l'état interne d'un agrégat avec plusieurs statuts interdépendants — un workflow de validation à plusieurs étapes, par exemple — un Read Model plat risque de dupliquer la logique de calcul de cet état. Charger l'agrégat et lui demander "dans quel état es-tu ?" est plus sûr que de recalculer cet état dans le read path.

Cas 3 : La zone grise de la logique de calcul

C'est le cas qui pose le plus de questions en pratique. Imaginons que l'écran doive afficher un indicateur calculé : "nombre de jours restants avant expiration", ou "dose journalière totale". Cette logique existe déjà dans l'agrégat, sous forme de méthode.

Deux options :

Option A — Charger l'agrégat. On réutilise la logique existante, pas de duplication. Mais on paie le coût de l'instanciation complète, et on ramène en lecture un objet conçu pour l'écriture.

Option B — Dupliquer le calcul dans le read path. On garde un read path léger, sans agrégat. Mais on a deux endroits où la même règle est implémentée. Si elle évolue, il faut penser à modifier les deux.

Il n'y a pas de réponse universelle. Le critère de décision est la volatilité de la règle : si le calcul est stable et simple (une soustraction de dates), le dupliquer dans une requête SQL ou dans le Read Model est raisonnable. Si la logique est complexe, volatile, ou partagée entre plusieurs cas d'usage d'écriture, la centraliser dans l'agrégat et accepter le coût d'instanciation est un meilleur compromis.

La question opérationnelle

Pour chaque lecture, posez-vous cette question : est-ce que cette lecture nécessite qu'une règle métier soit évaluée par l'agrégat ?

Si la réponse est "non" — et c'est le cas de la grande majorité des lectures — on bypasse. Si la réponse est "oui", l'agrégat a sa place, même en lecture. La clé, c'est de faire ce choix consciemment, cas par cas, et non par défaut.


En résumé

L'idée reçue fondamentale qui génère tous ces problèmes :

"Hexagonal + DDD = tout passe par le modèle domaine car il est au centre."

La réalité :

Hexagonal protège votre code de l'infra. DDD le rend parlant pour le métier. Ensemble, ils se renforcent — mais seulement si on comprend ce que chacun fait vraiment.


Pour aller plus loin