Riadh Mnasri
← Retour au blog
4 min de lecture

L'architecture hexagonale en pratique

L'architecture hexagonale part d'une idée simple : le cœur métier d'une application ne devrait rien savoir de la base de données, du framework web ou du système de messages qu'on utilise autour. Cette séparation, souvent perçue comme une contrainte supplémentaire, devient un accélérateur dès que le projet dépasse quelques semaines de développement.

Ports et adapters, en une phrase

Le domaine définit des interfaces (les ports) qui décrivent ce dont il a besoin, sans dire comment c'est fait : « j'ai besoin de sauvegarder une mission » plutôt que « j'ai besoin d'un INSERT SQL ». Les adapters implémentent ensuite ces interfaces avec une technologie concrète : une base Postgres aujourd'hui, un simple fichier JSON en test, une API externe demain si le besoin change.

Le sens de la dépendance est ce qui compte : le domaine ne dépend jamais de l'infrastructure. C'est l'infrastructure qui dépend du domaine, en implémentant ses interfaces.

                 ┌─────────────────────────┐
   HTTP  ──────▶ │        adapters         │ ◀────── Kafka
                 │  (infrastructure)       │
                 └───────────┬─────────────┘
                             │ implémente
                             ▼
                 ┌─────────────────────────┐
                 │   ports (interfaces)    │
                 │        domain           │
                 └─────────────────────────┘

Concrètement, sur MissionMatch, un port ressemble à ceci :

// application/port/output/MatchResultRepository.kt
interface MatchResultRepository {
    fun save(matchResult: MatchResult): MatchResult
    fun findByFreelancerId(freelancerId: FreelancerId): List<MatchResult>
    fun findByMissionIdAndFreelancerId(missionId: MissionId, freelancerId: FreelancerId): MatchResult?
}

L'adapter concret, dans infrastructure/, implémente cette interface avec Spring Data JPA, sans que le domaine n'en sache jamais rien :

// infrastructure/adapter/output/persistence/MatchResultRepositoryAdapter.kt
@Component
class MatchResultRepositoryAdapter(
    private val jpaRepository: MatchResultJpaRepository,
) : MatchResultRepository {
    override fun save(matchResult: MatchResult): MatchResult =
        jpaRepository.save(MatchResultEntity.fromDomain(matchResult)).toDomain()
    // ...
}

Le cas d'usage qui dépend de MatchResultRepository n'a aucune idée que Spring Data JPA existe. En test, cette même interface est simplement mockée (avec Mockito) plutôt que remplacée par une base réelle : c'est exactement la frontière où l'approche mockiste évoquée dans l'article sur le vrai TDD prend le relais de l'approche classiciste utilisée à l'intérieur du domaine.

Ce que ça change concrètement

Un cas d'usage métier écrit sans dépendance framework se teste en quelques millisecondes, sans base de données ni serveur HTTP. On peut donc écrire les tests avant l'implémentation (TDD) et les garder rapides sur toute la durée du projet, même quand la suite de tests atteint plusieurs centaines de cas.

Ça change aussi la façon de faire évoluer le système. Remplacer PostgreSQL par autre chose, ou brancher un nouveau canal de notification, devient un changement d'adapter isolé, qui ne touche jamais la logique métier. Le risque de régression sur une évolution technique redevient proportionné à l'ampleur réelle du changement.

Le piège le plus courant

L'erreur classique, c'est de croire que l'architecture hexagonale impose un nombre fixe de couches ou un nommage de dossiers particulier. Ce n'est pas une question de structure de fichiers : c'est une question de direction des dépendances. On peut très bien avoir un dossier domain/ qui importe discrètement un type venu d'une librairie ORM, et se retrouver avec un hexagone de façade, couplé à l'infrastructure en pratique.

La question à se poser en revue de code n'est pas « est-ce dans le bon dossier ? » mais « si je supprime tous les adapters, le domaine compile-t-il encore seul ? ».

Les quatre couches, et ce qu'elles ont le droit de connaître

CoucheContientA le droit de dépendre de
domain/Entités métier, règles invariantes, ports (interfaces)Rien d'externe, aucun framework
application/Cas d'usage, orchestration des portsdomain/ uniquement
infrastructure/Adapters concrets (base de données, Kafka, API externes)domain/ et application/, jamais l'inverse
presentation/Contrôleurs HTTP, composants d'interfaceapplication/, jamais directement infrastructure/

Ce tableau se lit dans un seul sens : une flèche qui remonterait de infrastructure/ vers domain/ en sens inverse (le domaine qui importe un type d'un adapter) est le signal exact d'un hexagone de façade, quel que soit le nom donné aux dossiers.

Un exemple

Sur MissionMatch, chaque cas d'usage vit dans application/, ne dépend que d'interfaces définies dans domain/, et les adapters concrets (base de données, Kafka, HTTP) sont branchés dans infrastructure/. Le domaine ne connaît ni Spring Boot, ni Kafka, ni Postgres : il pourrait être extrait tel quel dans un autre projet sans modification.

C'est ce même principe qui structure kotlin-counterparty-risk, où les calculs de risque de contrepartie (EAD, PFE, CVA) sont entièrement testables sans dépendre d'un quelconque moteur de calcul externe : la complexité mathématique est isolée du reste du système, exactement comme le reste du domaine métier.

Ce que j'en retiens

L'architecture hexagonale n'est pas un objectif en soi. C'est un moyen de garder la possibilité de changer d'avis tard dans le projet, sans que ce changement d'avis coûte une réécriture. Le jour où cette flexibilité n'est plus nécessaire, la rigueur qu'elle impose devient effectivement un coût inutile. Mais sur tout projet destiné à vivre plus de quelques mois, ce coût est largement remboursé dès la première évolution technique imprévue.