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
| Couche | Contient | A 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 ports | domain/ uniquement |
infrastructure/ | Adapters concrets (base de données, Kafka, API externes) | domain/ et application/, jamais l'inverse |
presentation/ | Contrôleurs HTTP, composants d'interface | application/, 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.