Architecture hexagonale
Ports et adaptateurs : isoler le métier du reste du monde
L'architecture hexagonale (dite aussi "ports & adapters") propose une règle simple : le code qui exprime les règles métier ne doit dépendre de rien d'autre. Tout le reste - base de données, framework web, API externes - devient un détail interchangeable autour de ce noyau.
Adaptateurs entrants
Contrôleur REST
reçoit une requête HTTP
Commande CLI
reçoit une ligne de commande
Adaptateurs sortants
Repository PostgreSQL
persiste les données
Client email
envoie une notification
Le problème que ça résout
Dans beaucoup d'applications, le code métier et le code technique finissent mélangés : un contrôleur HTTP contient des règles de calcul de prix, un repository Doctrine décide si une commande peut être annulée, une classe de service importe directement le SDK Stripe.
Le symptôme n'est pas immédiat. Il apparaît quand on veut changer quelque chose : remplacer PostgreSQL par un autre stockage, ajouter une interface CLI en plus du web, ou simplement écrire un test unitaire sans démarrer une vraie base de données. À ce moment-là, on découvre que tout est couplé à tout.
L'architecture hexagonale, formulée par Alistair Cockburn en 2005, part d'un constat : le métier devrait pouvoir être testé, compris et modifié sans jamais toucher à une ligne de framework, de SQL ou de HTTP.
L'idée centrale : ports et adaptateurs
Le nom "hexagonale" ne veut rien dire de spécial - Cockburn a choisi un hexagone simplement pour avoir assez de côtés où dessiner des ports, sans donner de sens particulier au chiffre six. Ce qui compte, c'est la structure : un noyau au centre, entouré de frontières explicites.
Le noyau, c'est le domaine : les entités, les règles métier, les cas d'usage. Il ne connaît ni base de données, ni HTTP, ni file d'attente. Pour communiquer avec l'extérieur, il définit des ports : des interfaces qui décrivent ce dont il a besoin, ou ce qu'il propose, en langage métier.
Les adaptateurs sont les implémentations concrètes de ces ports, côté technique. Un port "envoyer une notification" peut être adapté par un envoi d'email, un SMS ou un simple log en test. Le domaine ne sait jamais laquelle est utilisée.
Les ports entrants (driving / primary)
Un port entrant est une interface que le domaine expose pour être piloté depuis l'extérieur. C'est typiquement l'interface d'un cas d'usage : "PlaceOrder", "CancelSubscription", "AuthenticateUser".
Les adaptateurs entrants appellent ces ports : un contrôleur REST, un handler de CLI, un consumer de message Kafka, un test automatisé. Ils traduisent une requête technique (un JSON, une ligne de commande, un message) en appel de méthode métier.
Deux règles simples permettent de repérer un bon port entrant :
- Son nom et sa signature utilisent le vocabulaire du métier, jamais des termes HTTP ou SQL.
- Il pourrait être appelé aussi bien depuis un test unitaire que depuis un vrai contrôleur, sans aucune différence.
Les ports sortants (driven / secondary)
Un port sortant est une interface que le domaine définit parce qu'il a besoin de quelque chose de l'extérieur : persister une entité, envoyer un email, appeler un service de paiement. C'est le domaine qui décide de la forme de l'interface, pas la technologie choisie pour l'implémenter.
C'est le point souvent inversé par erreur : on a le réflexe d'écrire d'abord le repository SQL, puis d'en extraire une interface a posteriori. Fait dans l'autre sens - le domaine exprime son besoin, l'infrastructure s'y adapte - le port reste stable même si la base de données change complètement.
Exemple concret : passer une commande
Prenons un cas d'usage simple : passer une commande. Le domaine définit ce dont il a besoin (un port sortant pour la persistance) et ce qu'il propose (un port entrant pour le cas d'usage), sans jamais mentionner PostgreSQL ni HTTP.
// Port sortant : défini par le domaine, implémenté par l'infrastructure
interface OrderRepository {
suspend fun save(order: Order)
suspend fun findById(id: OrderId): Order?
}
// Port entrant : ce que le domaine propose à l'extérieur
interface PlaceOrder {
suspend fun execute(command: PlaceOrderCommand): OrderId
}// Adaptateur sortant : une des implémentations possibles du port
class PostgresOrderRepository(private val db: DatabaseClient) : OrderRepository {
override suspend fun save(order: Order) {
db.query(
"insert into orders (id, status, total) values (?, ?, ?)",
order.id.value, order.status, order.total.amount,
)
}
override suspend fun findById(id: OrderId): Order? {
val row = db.queryOne("select * from orders where id = ?", id.value)
return row?.let { Order.fromRow(it) }
}
}Ce que ça change vraiment : la testabilité
Une fois les ports en place, tester le cas d'usage "passer une commande" ne nécessite plus de base de données. On fournit une implémentation en mémoire du port OrderRepository, on exécute le cas d'usage, et on vérifie l'état final - en quelques millisecondes, sans Docker ni fixtures SQL.
C'est ce gain-là, très concret, qui justifie l'architecture : des tests rapides et fiables sur le cœur métier, et des tests d'intégration ciblés, en nombre réduit, pour vérifier que chaque adaptateur respecte bien son port.
Pièges fréquents
L'architecture hexagonale est simple sur le papier, mais quelques erreurs reviennent souvent :
- Créer un port qui recopie exactement une table SQL (getAll, getById, update) : c'est un CRUD déguisé, pas une interface métier. Le port doit exprimer des besoins ("réserver le stock"), pas des opérations génériques.
- Laisser fuiter un type d'infrastructure dans le domaine (une entité Doctrine, un DTO HTTP) : dès qu'un fichier du domaine importe une bibliothèque technique, la frontière est rompue.
- Appliquer cette structure à une application vraiment triviale (un simple CRUD sans règle métier) : le découpage ajoute alors de la complexité sans bénéfice réel.