Vous avez déjà vécu ça : une fonctionnalité qui devrait prendre un après-midi en prend trois jours, parce que la « petite modification » dans la façon d'envoyer les e-mails s'avère enchevêtrée dans douze fichiers. Le code de paiement connaît la base de données, le code de la base de données connaît la requête web, et quelque part au milieu — à peine visible — se trouvent les vraies règles métier qui font que votre produit est le vôtre.
Ports & Adapters (vous l'entendrez aussi appeler Hexagonal Architecture, terme inventé par Alistair Cockburn en 2005) est une façon d'organiser le code pour que cela cesse d'arriver. Ce n'est pas un framework, vous ne l'installez pas, et ça fonctionne dans n'importe quel langage. C'est en réalité une seule idée portant un nom un peu ronflant. Décortiquons-la lentement, avec des illustrations et des exemples concrets, et à la fin vous saurez exactement quand l'utiliser — et quand ne pas le faire.
Le problème : du code qui grandit mal
La plupart des applications démarrent de la même façon sympathique — un contrôleur appelle un service, le service parle à une base de données et quelques fournisseurs, et tout le monde est content. L'ennui, c'est ce qui se passe avec le temps. Voici un processus de commande qui n'a que quelques mois et qui est déjà douloureux :
// checkout.ts — tout est emmêlé ensemble
class OrderService {
async checkout(cart: Cart) {
const db = new PgClient(process.env.DB_URL) // une base de données spécifique
const stripe = new Stripe(process.env.STRIPE_KEY) // un fournisseur spécifique
const sg = new SendGrid(process.env.SENDGRID_KEY) // un autre fournisseur
// les vraies règles métier sont enfouies entre les appels aux fournisseurs…
const total = cart.items.reduce((s, i) => s + i.price, 0)
await stripe.charges.create({ amount: total, source: cart.token })
await db.query('INSERT INTO orders …')
await sg.send({ to: cart.email, template: 'order-ok' })
}
}
Rien ici n'est vraiment « faux ». Mais la logique métier (ce qu'est une commande, quand elle est valide, ce que signifie « confirmée ») est imbriquée dans trois fournisseurs et un driver de base de données. Vous ne pouvez pas tester les règles sans une base de données réelle. Vous ne pouvez pas quitter SendGrid sans une intervention chirurgicale. Et un nouveau coéquipier lisant ceci apprend Stripe avant même d'apprendre ce que sont vos commandes.
Ports & Adapters répond à une seule question : comment garder la partie qui génère de l'argent (les règles métier) séparée des parties que l'on finit inévitablement par remplacer (bases de données, fournisseurs, frameworks, interfaces utilisateur) ?
L'idée centrale, en une seule image
Placez vos règles métier — le domaine — au centre. Interdisez-lui d'importer tout framework, driver de base de données ou SDK fournisseur. Laissez ensuite le monde extérieur lui parler uniquement via des ports (interfaces), avec des adapters qui font le travail concret et encombrant de l'autre côté.
C'est toute la structure. On dessine un hexagone plutôt qu'un cercle pour une raison charmante : les six côtés rappellent qu'une application a de nombreuses entrées et sorties (une interface web, une API, des tests, un cron job, une base de données, une file de messages…), et pas seulement « en haut » et « en bas ». Le nombre six ne signifie rien — n'essayez pas de compter vos ports.
Le vocabulaire, en termes simples
Trois mots font tout le travail. Les voici, sans jargon :
- Domaine (le cœur) : vos règles et concepts métier —
Order,Money, « un panier avec zéro article ne peut pas passer en caisse ». Logique pure. Il devrait se lire comme une description de votre activité, pas de votre stack technique. - Port : une interface dont le domaine est propriétaire, décrivant quelque chose dont il a besoin ou qu'il offre — « j'ai besoin de pouvoir
save(order)» ou « j'ai besoin decharge()un paiement ». Un port est une promesse, rédigée dans le langage du domaine, sans qu'aucun nom de fournisseur n'y apparaisse. - Adapter : le code concret qui remplit un port en utilisant une vraie technologie — un
PostgresOrderRepo, unStripeGateway. Les adapters sont l'endroit où Stripe, Postgres et React ont le droit d'exister.
Et les ports se déclinent en deux types, ce qui est la seule subtilité qui vaut la peine d'être apprise :
- Les adapters pilotants (primaires) se trouvent à gauche et appellent vers votre application : un contrôleur web, une commande CLI, un test. Ils utilisent un port d'entrée (votre interface de cas d'utilisation).
- Les adapters pilotés (secondaires) se trouvent à droite et sont appelés par votre application : une base de données, un service d'e-mail, une API de paiement. Ils implémentent un port de sortie que le domaine a défini.
Si quelque chose initie une conversation avec votre application, c'est qu'il est pilotant. Si votre application initie la conversation, l'autre partie est pilotée. Un utilisateur qui clique sur « Acheter » pilote ; le fournisseur de paiement est piloté.
Un exemple concret : avant et après
Démêlons ce processus de commande. Commencez par écrire ce dont le domaine a besoin — les ports — en utilisant uniquement les termes de votre métier :
// ports.ts — le vocabulaire du domaine. Aucun nom de fournisseur autorisé.
interface PaymentGateway { charge(amount: Money, token: string): Promise<Receipt> }
interface OrderRepository { save(order: Order): Promise<void> }
interface Notifier { orderConfirmed(order: Order): Promise<void> }
Maintenant le cas d'utilisation ne dépend que de ces promesses, et de rien d'autre. Lisez-le à voix haute : ça ressemble à une description du processus de commande, pas à une liste de fournisseurs.
// checkout-service.ts — dépend uniquement des ports, jamais des fournisseurs
class CheckoutService {
constructor(
private payments: PaymentGateway,
private orders: OrderRepository,
private notify: Notifier,
) {}
async checkout(cart: Cart): Promise<Order> {
const order = Order.fromCart(cart) // règle métier pure
await this.payments.charge(order.total, cart.token)
await this.orders.save(order)
await this.notify.orderConfirmed(order)
return order
}
}
Enfin, les vrais fournisseurs vivent dans des adapters qui implémentent les ports. En remplacer un est un changement localisé — et dans les tests, vous les remplacez par des faux et faites tourner des milliers de cas dans le temps qu'un seul appel réseau prenait autrefois :
// adapters/*.ts — les fournisseurs vivent ici, derrière le port qu'ils implémentent
class StripeGateway implements PaymentGateway { /* SDK Stripe */ }
class PostgresOrderRepo implements OrderRepository { /* SQL ici */ }
class SendGridNotifier implements Notifier { /* SendGrid */ }
// Dans un test, remplacez les vrais fournisseurs par des faux en mémoire — pas de réseau, quelques millisecondes :
const service = new CheckoutService(new FakePayments(), new InMemoryOrders(), new NullNotifier())
Remarquez ce qui a bougé : Stripe, Postgres et SendGrid sont désormais des détails à la périphérie. Ce qui vous fait gagner de l'argent se trouve au centre, lisible et testable, et n'a aucune idée que ces fournisseurs existent.
La règle unique qui tient tout ensemble
Si vous ne devez retenir qu'une seule chose, retenez celle-ci : les dépendances dans le code source pointent toujours vers l'intérieur. L'anneau extérieur (adapters, frameworks, I/O) peut connaître les anneaux intérieurs. Le domaine au centre n'a le droit de connaître rien en dehors de lui-même.
C'est pourquoi vous pouvez remplacer l'intégralité de votre framework web, ou migrer de MySQL vers DynamoDB, sans que le domaine s'en aperçoive. Les flèches de dépendance ne pointent jamais en dehors du cœur, donc les changements à la périphérie ne peuvent pas y faire des remous. (Si vous avez entendu parler du Principe d'Inversion de Dépendance — le « D » dans SOLID — c'est lui en pratique : le domaine définit l'interface, l'adapter s'y conforme.)
Pilotant et piloté, mis en pratique
Pourquoi la distinction primaire/secondaire compte-t-elle au quotidien ? Parce qu'elle vous dit qui possède l'interface.
- Pour un port piloté (base de données, e-mail), c'est le domaine qui possède l'interface et l'adapter s'adapte à elle. C'est ce qui vous permet de changer de fournisseur librement.
- Pour un port pilotant (vos cas d'utilisation), c'est l'application qui possède l'interface et le monde extérieur (HTTP, CLI, un harnais de test) l'appelle. C'est ce qui permet à la même logique d'alimenter une API REST aujourd'hui et un endpoint gRPC ou un job planifié demain, sans changer une seule règle.
Un test utile : un tout nouveau mécanisme de diffusion (par exemple un bot Slack) devrait être uniquement un nouvel adapter pilotant. Un tout nouveau fournisseur (par exemple passer de Stripe à Adyen) devrait être uniquement un nouvel adapter piloté. Si l'un ou l'autre vous oblige à ouvrir le domaine, c'est qu'il y a eu une fuite quelque part.
À quoi ça ressemble selon la taille de l'entreprise
C'est là que beaucoup de conseils déraillent — ils traitent l'architecture comme une solution universelle. La bonne dose de Ports & Adapters dépend fortement de où en est votre entreprise. Voici la version honnête :
| Stade de l'entreprise | La douleur qu'il supprime | Quelle dose appliquer |
|---|---|---|
| Solo / startup précoce | Vous avez choisi SQLite et un fournisseur de paiement à la hâte et vous craignez maintenant d'être enfermé dedans. | Un ou deux ports maximum — généralement la base de données et les paiements. Laissez tout le reste en direct. La vitesse prime sur la pureté ici. |
| Petite / en croissance (≈ Série A) | Les tests frappent la vraie base de données et un sandbox fournisseur, donc ils sont lents, fragiles, et les développeurs arrêtent de les lancer. | Mettez des ports autour de tous les I/O. Les faux permettent aux tests unitaires de tourner en quelques millisecondes et aux nouvelles recrues de comprendre le domaine en une journée. |
| Taille moyenne / scale-up | Plusieurs équipes se marchent dessus ; une décision de réduction de coûts « quitter SendGrid » se transforme en une réécriture d'un mois. | Les ports deviennent des contrats entre les équipes. Changer de fournisseur, c'est un nouvel adapter, pas une migration. |
| Grande entreprise / enterprise | Systèmes hérités, fournisseurs régionaux, audits, et règles d'approvisionnement qui exigent « pas d'enfermement chez un seul fournisseur ». | Plusieurs adapters par port (ancien + nouveau), migrations en strangler-fig, et l'indépendance vis-à-vis des fournisseurs comme exigence écrite. |
Trois courtes histoires du monde réel
La petite boutique e-commerce (10 personnes). Ils ont encapsulé les e-mails derrière un port Notifier à une seule méthode « au cas où ». Deux ans plus tard, leurs coûts d'e-mail ont grimpé en flèche ; passer de SendGrid à Amazon SES a consisté en un seul nouvel adapter et une modification d'une ligne dans le câblage, livrée en un après-midi. La logique de commande — leurs joyaux — n'a pas été touchée.
La fintech en scale-up (≈ 120 ingénieurs). Les paiements devaient transiter par un fournisseur local dans un pays et un fournisseur international dans un autre, le choix étant fait à l'exécution transaction par transaction. Parce que PaymentGateway était un port, « quel fournisseur » est devenu une décision de routage cachée derrière l'interface. Les auditeurs de conformité ont adoré : la frontière était une couture évidente et testable.
La grande banque (1 000+ ingénieurs). Un mainframe vieux de 20 ans ne pouvait pas être remplacé du jour au lendemain. Ils ont défini un port AccountLedger et écrit deux adapters — l'un parlant à l'ancien mainframe, l'autre au nouveau système core-banking — puis ont migré le trafic progressivement (un « strangler fig »). Les équipes développaient de nouvelles fonctionnalités contre le port pendant que la migration se déroulait en dessous, invisible.
Ports & Adapters face à l'approche en couches classique
La plupart des équipes viennent d'une architecture en couches traditionnelle (UI en haut, services au milieu, base de données en bas). Voici comment les deux se comparent :
| Question | En couches classique (UI → service → DB) | Ports & Adapters |
|---|---|---|
| Qui dépend de qui ? | De haut en bas : chaque couche dépend de celle du dessous, jusqu'à la base de données. | Tout dépend vers l'intérieur, jusqu'au domaine. La base de données n'est qu'un plugin parmi d'autres. |
| Peut-on tester les règles sans DB ? | Généralement non — le service accède directement à la DB. | Oui — injectez un faux dépôt ; pas de DB, pas de réseau. |
| Changer de fournisseur ? | Touche la couche service et se propage vers l'extérieur. | Écrivez un nouvel adapter ; le domaine ne change jamais. |
| Où vivent les règles métier ? | Souvent étalées entre les services et la DB. | Concentrées dans le domaine, en langage métier. |
| Coût | Peu de cérémonie, mais vous lie à votre stack avec le temps. | Quelques interfaces supplémentaires au départ ; rentable à mesure que les changements s'accélèrent. |
La différence principale est la direction des flèches. Dans le code en couches, tout dépend finalement de la base de données. Dans Ports & Adapters, la base de données dépend de vous. Cette inversion est tout l'intérêt.
Quand l'adopter (et quand ne pas le faire)
Une bonne architecture consiste à calibrer l'effort aux enjeux. Adoptez Ports & Adapters quand :
- L'application a de vraies règles métier qui méritent d'être protégées (pas seulement du CRUD sur une table).
- Vous prévoyez de remplacer ou d'ajouter des intégrations — fournisseurs, bases de données, canaux — au fil de sa vie.
- La vitesse et la fiabilité des tests comptent, et vous voulez des tests unitaires rapides et déterministes.
- Plusieurs équipes ou une longue durée de vie font que des frontières claires rentabilisent d'elles-mêmes l'investissement.
Soyez sceptique — ou appliquez-le très légèrement — quand :
- C'est une application CRUD légère ou un prototype jetable ; l'indirection n'est qu'une surcharge inutile.
- Le « domaine » est essentiellement votre schéma de base de données ; il n'y a rien à protéger.
- Vous êtes un développeur solo qui livre vite et le coût d'une réécriture future est inférieur au coût de la cérémonie maintenant.
Envelopper un endpoint CRUD sans valeur dans trois interfaces ne le rend pas « propre » — ça le rend plus difficile à lire. L'architecture est un coût que vous payez pour acheter de la flexibilité au changement. Si rien ne va changer, vous payez pour rien.
Les erreurs courantes (et des corrections simples)
- Des ports qui fuient. Si votre
PaymentGatewayrenvoie un objetStripe.Charge, le fournisseur a fuité à travers le port. Correction : les ports ne parlent qu'avec vos propres types (Receipt,Money). - Un domaine anémique. Si toute la vraie logique vit dans les adapters et que le domaine n'est que des sacs de données, vous avez tracé la frontière au mauvais endroit. Correction : poussez les règles vers le cœur.
- Un port géant. Un unique
IRepositoryavec 40 méthodes n'est pas une frontière, c'est un fourre-tout. Correction : des ports petits, nommés selon leur objectif (OrderRepository,InventoryRepository). - Confondre les DTOs avec les modèles de domaine. La forme que vous envoyez via HTTP n'est pas votre entité de domaine. Correction : laissez les adapters traduire entre le format de transport et le domaine.
- Des interfaces pour tout. Toutes les classes n'ont pas besoin d'un port — seulement celles qui franchissent une frontière (I/O, fournisseurs, mécanismes de diffusion). Correction : ajoutez un port quand vous ressentez la douleur, pas par précaution.
Comment commencer lundi matin
Vous ne réécrivez rien. Vous introduisez une seule couture là où ça fait le plus mal, et vous vous arrêtez là :
- 1. Choisissez votre dépendance la plus douloureuse. Généralement la base de données ou un fournisseur peu fiable.
- 2. Rédigez l'interface que vous souhaiteriez avoir. Avec les mots de votre métier :
save(order), pasexecSql(...). - 3. Déplacez le code du fournisseur derrière un adapter qui l'implémente. Rien d'autre ne change pour l'instant.
- 4. Injectez l'adapter au lieu de l'instancier en ligne, et écrivez un faux pour les tests.
- 5. Savourez le soulagement, puis recommencez — mais seulement là où la turbulence ou la douleur des tests justifie un nouveau port.
En une semaine, vous aurez des tests rapides autour de votre intégration la plus épineuse et un domaine qui commence à se lire comme votre métier. C'est tout le bénéfice, disponible de façon incrémentale.
Points clés à retenir
- Une seule idée : les règles métier au centre, la technologie à la périphérie, communiquant uniquement via des interfaces.
- Port = une interface dont le domaine est propriétaire. Adapter = l'implémentation réelle (Stripe, Postgres, React).
- Les dépendances pointent vers l'intérieur. Le domaine ne sait rien du monde extérieur.
- Les adapters pilotants appellent votre application ; les adapters pilotés sont appelés par elle.
- Adaptez la dose aux enjeux : quelques ports pour une startup, des frontières indépendantes des fournisseurs pour une grande entreprise, et presque rien pour une application CRUD jetable.
- Adoptez-le de façon incrémentale — une couture douloureuse à la fois. Vous n'avez jamais besoin d'une grande réécriture.
Dans le prochain article de cette série, nous construirons sur cette base et examinerons comment Ports & Adapters se relie à Clean Architecture et Onion Architecture — trois noms qui pointent vers la même étoile du nord — et où ils diffèrent vraiment en pratique.