Nguyen Le Phong

Les fondamentaux de l'architecture logiciellePartie 1 sur 6

Ports & Adapters (Hexagonal Architecture), Expliqués Simplement

Un guide accessible et riche en exemples sur l'Hexagonal Architecture : ce que sont vraiment les ports et les adapters, la règle unique qui la fait fonctionner, et exactement quelle dose en appliquer — que vous soyez un fondateur solo ou une équipe d'entreprise.

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.

La question centrale

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é.

Le domaine se trouve au centre. Les adapters s'y branchent via des ports. DRIVING SIDE · calls us DRIVEN SIDE · we call it Domain business rules, no vendors Web / Mobile UI REST / GraphQL Tests / CLI / Cron Database Email / SMS Payment vendor ● = a port (an interface the domain owns)
L'hexagone n'est qu'une image : le domaine vit au centre et n'importe jamais un framework ou un fournisseur. Tout ce qui est à l'extérieur le rejoint via un port. Les adapters à gauche pilotent l'application ; les adapters à droite sont pilotés par elle.

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 de charge() 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, un StripeGateway. 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 :

Une requête circule depuis un adapter pilotant, à travers le domaine, jusqu'à un adapter piloté. PRIMARY · driving SECONDARY · driven User / Test actor Controller / CLI driving adapter Use case domain rules Repo / Gateway driven adapter DB / Vendor input port output port
Le même domaine, deux types d'adapters. Un adapter pilotant (primaire) pousse une requête vers l'intérieur via un port d'entrée ; le domaine pousse du travail vers l'extérieur via un port de sortie vers un adapter piloté (secondaire). Les flèches indiquent la direction des appels — pas la direction des dépendances.
  • 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.
Moyen mnémotechnique rapide

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.

Les dépendances pointent toujours vers l'intérieur, vers le domaine. ADAPTERS · frameworks · databases · HTTP · I/O APPLICATION · use cases + ports Domain pure rules Outer layers know about inner layers — never the other way around.
La règle d'or : les dépendances dans le code source ne pointent que vers l'intérieur. Le domaine ne sait pas que la base de données existe ; c'est l'adapter de base de données qui connaît le domaine. Retournez une couche extérieure, le domaine reste intact.

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'entrepriseLa douleur qu'il supprimeQuelle 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 :

QuestionEn 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ûtPeu 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.
N'imitez pas aveuglément

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 PaymentGateway renvoie un objet Stripe.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 IRepository avec 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), pas execSql(...).
  • 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.