Ports & Adapters, ou la frontière qu'on dessinePorts & Adapters, or the boundary you draw
Le #1 retournait une flèche. Une vraie application en a des dizaines — base, paiement, mail, file. On cesse d'empiler des couches et on dessine une frontière fermée autour du métier : ce qui la traverse passe par un port qu'il possède, l'infra n'est plus qu'un adapter aux bords. Hexagonal, Onion, Clean : trois noms, une idée.Issue #1 flipped one arrow. A real application has dozens — database, payment, mail, queue. Stop stacking layers and draw a closed boundary around the business: what crosses it goes through a port it owns, infra becomes mere adapters at the edges. Hexagonal, Onion, Clean: three names, one idea.
AudienceAudience
Dev à l'aise avec l'inversion de dépendance, prêt à raisonner topologieDev comfortable with dependency inversion, ready to reason about topology
Des « couches » bien empilées — et toutes les flèches pointent vers la base.Neatly stacked "layers" — and every arrow points to the database.
Au numéro précédent, on a retourné une flèche. Une vraie application en a des dizaines : base, paiement, mail, file de messages, horloge. On dessine alors des « couches » — Controller, Service, Repository, DB — et on se sent propre. Mais empiler n'est pas isoler : la couche métier importe encore l'ORM et Stripe. Le cœur, transitivement, connaît Postgres.Last issue we flipped one arrow. A real application has dozens: database, payment, mail, message queue, clock. So we draw 'layers' — Controller, Service, Repository, DB — and feel tidy. But stacking isn't isolating: the business layer still imports the ORM and Stripe. The core, transitively, knows Postgres.
Le code « en couches » — le cœur importe l'infraThe 'layered' code — the core imports infra
# « couche domaine » — censée être le cœur stable
from sqlalchemy import Session # ← déjà de l'infraimport stripe # ← déjà de l'infraclass BookingService:
def confirm(id, db: Session):
b = db.query(Booking).get(id) # SQL ici
stripe.Charge.create(amount=b.total) # Stripe ici
db.commit()
Le sens des flèchesThe direction of arrows
Controller
webweb
▼
BookingService
le métier · stablethe business · stable
▼
Repository / ORM
infra · volatilinfra · volatile
▼
Postgres · Stripe
le monde extérieurthe outside world
L'empilement vertical cache un fait : toutes les dépendances descendent vers l'infra. Le métier est en haut de l'organigramme, mais en bas de la chaîne de dépendances.The vertical stack hides one fact: every dependency runs down toward infra. The business sits atop the org chart, but at the bottom of the dependency chain.
Le réflexe du volumeThe volume's reflex
« Qui dépend de qui, et la flèche pointe-t-elle vers le stable ou vers le volatil ? » Le #1 retournait une flèche. Ce numéro pose la question à l'échelle du système : où vit l'application, et qu'est-ce qui la sert seulement ? Tant que le cœur importe l'infra, il n'y a pas de cœur — il y a une couche du milieu.'Who depends on whom, and does the arrow point toward the stable or the volatile?' Issue #1 flipped one arrow. This one asks the question at system scale: where does the application live, and what merely serves it? As long as the core imports infra, there is no core — there's a middle layer.
Couches ≠ frontière.Layers ≠ boundary.
Le modèle en couches est une métaphore d'empilement : chacun pose sur celui d'en dessous. Il n'interdit rien — une couche peut sauter par-dessus, et toutes pointent vers le bas. Ports & Adapters remplace l'empilement par une frontière fermée autour du métier. Ce n'est pas une couche de plus : c'est un changement de topologie.The layered model is a stacking metaphor: each rests on the one below. It forbids nothing — a layer can reach across, and all point downward. Ports & Adapters replaces stacking with a closed boundary around the business. It's not one more layer: it's a change of topology.
№ 02La douleurThe pain4 min
Un seul point d'entrée câblé en dur, et l'application ne sait faire qu'une chose.One hardwired entry point, and the application can only do one thing.
Le couplage du #1 était local : une règle, un détail. Ici la douleur est systémique. Le métier vit au milieu d'un empilement, donc il hérite de tout ce qui est en dessous — et il se moule sur ce qui est au-dessus. Le jour où le même cas d'usage doit être déclenché autrement, ou testé sans le monde réel, on découvre qu'il n'a jamais été indépendant.The #1 coupling was local: one rule, one detail. Here the pain is systemic. The business lives in the middle of a stack, so it inherits everything below — and it molds to whatever sits above. The day the same use case must be triggered differently, or tested without the real world, you discover it was never independent.
№ 1 · Le cœur n'est pas testable seulThe core isn't testable alone
Vérifier « une réservation confirmée déclenche le paiement » exige une base Postgres et une clé Stripe joignables. On teste une règle métier à travers deux frontières réseau — lent, instable, sauté en pratique.Checking 'a confirmed booking triggers payment' needs a reachable Postgres and a Stripe key. You test a business rule through two network boundaries — slow, flaky, skipped in practice.
№ 2 · Le framework remonte dans le métierThe framework climbs into the business
Migrer d'un ORM à un autre, ou monter de version majeure, oblige à rouvrir des fichiers métier qui importaient ses types. Une décision d'infra paie son coût dans le cœur stable.Migrating ORMs, or a major-version bump, forces you to reopen business files that imported its types. An infra decision pays its cost in the stable core.
№ 3 · Chaque nouvel entrant dupliqueEach new caller duplicates
Le service supposait une requête HTTP. Ajouter une CLI, un consommateur de file ou un cron force à recopier l'orchestration — parce que le cas d'usage n'a jamais été exposé comme une opération neutre.The service assumed an HTTP request. Adding a CLI, a queue consumer or a cron forces you to re-copy the orchestration — because the use case was never exposed as a neutral operation.
Le test révèle l'absence de frontièreThe test reveals the missing boundary
def test_confirm_charges_customer():
BookingService.confirm(id, db) # ← exige Postgres + Stripe live# rien ici n'est métier : tout est plomberie
Le diagnostic.The diagnosis.
Même symptôme qu'au #1 — « pour tester la règle, il me faut le monde réel » — mais multiplié par chaque frontière d'I/O. Le réflexe ne change pas, il se généralise : au lieu de retourner une flèche à la fois, on trace une frontière unique autour du métier et on impose que tout ce qui la traverse passe par un contrat qu'il possède.Same symptom as #1 — 'to test the rule I need the real world' — but multiplied by every I/O boundary. The reflex doesn't change, it generalizes: instead of flipping one arrow at a time, draw a single boundary around the business and require everything crossing it to pass through a contract it owns.
№ 03La frontièreThe boundary5 min
L'empilement se replie : le métier au centre, l'infra aux bords.The stack folds: the business at the center, infra at the edges.
Ports & Adapters, c'est le retournement du #1 appliqué à toutes les frontières d'I/O d'un coup. On cesse d'empiler et on dessine une frontière fermée autour du domaine. Ce qui la traverse passe par un port — un contrat que le cœur possède. Côté pilote (gauche), des adapters appellent le cœur ; côté piloté (droite), des adapters l'implémentent. Dans les deux cas, la dépendance pointe vers le centre. L'animation replie les couches en hexagone.Ports & Adapters is the #1 flip applied to every I/O boundary at once. Stop stacking and draw a closed boundary around the domain. What crosses it goes through a port — a contract the core owns. On the driving side (left), adapters call the core; on the driven side (right), adapters implement it. Either way, the dependency points to the center. The animation folds the layers into a hexagon.
Animation — l'empilement devient une frontièreAnimation — the stack becomes a boundary
Départ — les couchesStart — the layers · 1/6
Controller
web
▼
BookingService
métier
▼
Repository / ORM
infra
▼
Postgres · Stripe
monde
L'empilement vertical : tout descend vers l'infra. Le métier dépend de ce qui le sert.The vertical stack: everything runs down to infra. The business depends on what serves it.
Le code de la frontière — 3 lignes à retrouverThe boundary's code — 3 lines to recall
# --- le cœur : aucun import d'infra ---interface BookingStore: # port piloté (le cœur appelle)
get(id) -> Booking
save(b: Booking)
interface PaymentPort: # port piloté
charge(customer, amount)
def run(id):
b = store.get(id); pay.charge(b.customer, b.total); store.save(b)
# --- les bords : ils dépendent du cœur, pas l'inverse ---# composition root : le seul endroit qui connaît tout le monde
3 ligne(s) à retrouver — écris-la de tête avant de cliquer3 line(s) to recall — write it in your head before clicking
Pilote vs piloté.Driving vs driven.
Deux familles de ports, c'est la seule subtilité. Le port pilote (driving / primary) est le cas d'usage lui-même : HTTP, CLI ou test l'appellent. Le port piloté (driven / secondary) est un besoin du cœur — stockage, paiement — que l'infra satisfait. Les premiers pointent vers le centre par usage ; les seconds par conformité. Résultat identique : aucune flèche ne sort du cœur.Two families of ports — that's the only subtlety. A driving (primary) port is the use case itself: HTTP, CLI or a test call it. A driven (secondary) port is a need of the core — storage, payment — that infra satisfies. The first point inward by use; the second by conformance. Same result: no arrow leaves the core.
Modèle mentalMental model
L'imageThe image
Un appareil audio et ses prises. Le domaine, c'est l'appareil : il expose des prises normalisées — une entrée jack, une sortie jack. On branche n'importe quelle source à l'entrée (micro, platine) et n'importe quelle sortie (casque, enceinte). L'appareil ignore ce qu'on branche : il ne connaît que la forme de ses prises.An audio device and its jacks. The domain is the device: it exposes standardized sockets — an input jack, an output jack. You plug any source into the input (mic, turntable) and any output (headphones, speaker). The device doesn't know what's plugged in: it knows only the shape of its jacks.
La règleThe rule
Le domaine possède tous les contrats à sa frontière ; rien à l'intérieur ne connaît ce qui est branché dehors. Entrée = port pilote (on l'appelle), sortie = port piloté (il l'appelle) — et toutes les flèches pointent vers le centre.The domain owns every contract at its boundary; nothing inside knows what's plugged in outside. Input = driving port (it's called), output = driven port (it calls) — and every arrow points to the center.
Où l'analogie casseWhere the analogy breaks
Le dessin de l'hexagone ne protège rien par lui-même. Si un port rend un type d'infra — un Row, un HttpRequest —, la prise a la forme d'une norme mais laisse fuir le courant du dehors : la flèche ressort. Et si le cœur n'est que du CRUD, l'hexagone n'entoure qu'un traducteur — une prise soudée autour du vide. La topologie ne vaut que par ce qu'elle contient et par les types qu'elle laisse passer.Drawing the hexagon protects nothing by itself. If a port returns an infra type — a Row, an HttpRequest —, the socket has the shape of a standard but lets the outside current leak in: the arrow escapes. And if the core is just CRUD, the hexagon wraps only a translator — a socket soldered around emptiness. The topology is worth only what it contains and the types it lets through.
№ 04Le piègeThe trap4 min
Dessiner l'hexagone ne suffit pas. Le port qui fuit, l'adapter qui décide.Drawing the hexagon isn't enough. The port that leaks, the adapter that decides.
On a vu le piège du #1 : injecter sans inverser. À l'échelle du système, il prend deux formes jumelles. Le port fuit un type d'infra (l'ORM, le format HTTP) — la flèche ressort du cœur. Ou bien une décision métier migre dans l'adapter — le cœur se vide. Dans les deux cas, l'hexagone est dessiné mais creux. Bascule le toggle : suis le type que le contrat laisse passer, et cherche le if.We saw #1's trap: injecting without inverting. At system scale it takes two twin forms. The port leaks an infra type (the ORM, the HTTP shape) — the arrow escapes the core. Or a business decision migrates into the adapter — the core hollows out. Either way the hexagon is drawn but empty. Flip the toggle: follow the type the contract lets through, and hunt the if.
# le port parle le vocabulaire du domaine, point.interface BookingStore:
get(id: BookingId) -> Booking # type métier
save(booking: Booking)
# l'adapter TRADUIT — il ne décide jamais.class PostgresStore implements BookingStore:
def get(id):
row = db.query(Booking).get(id)
return to_domain(row) # SQL → métier, ici seulement
Pourquoi c'est justeWhy it's right
Le contrat ne nomme que des types du domaine — BookingId, Booking. Le cœur ignore qu'il existe une base. L'adapter fait une seule chose : traduire SQL ↔ métier. Toute décision (« confirmer ? facturer ? ») reste dans le cas d'usage. Passer à une autre base, c'est un nouvel adapter ; tester, c'est une doublure en mémoire. Le cœur ne bouge pas.The contract names only domain types — BookingId, Booking. The core doesn't know a database exists. The adapter does one thing: translate SQL ↔ domain. Every decision ('confirm? charge?') stays in the use case. Switching databases is a new adapter; testing is an in-memory double. The core doesn't move.
La règle qui tranche.The rule that settles it.
Un adapter traduit, il ne décide jamais. S'il contient un if sur une règle métier, la frontière fuit. Et un port n'expose que des types que le domaine possède : dès qu'un Row, un HttpRequest ou une entité ORM apparaît dans la signature, ce n'est plus un port, c'est l'infra déguisée — exactement la fausse abstraction du #1, à l'échelle du système.An adapter translates, it never decides. If it holds an if about a business rule, the boundary leaks. And a port exposes only types the domain owns: the moment a Row, an HttpRequest or an ORM entity appears in the signature, it's not a port — it's infra in disguise — exactly #1's fake abstraction, at system scale.
Hexagonal = DIP × n frontières.Hexagonal = DIP × n boundaries.
Il n'y a rien de neuf depuis le #1 : c'est la même flèche retournée, appliquée systématiquement à chaque I/O. La seule discipline ajoutée est topologique — une frontière fermée, pas un empilement — pour qu'on ne puisse plus « oublier » d'inverser une dépendance au coup par coup. Le pattern force le réflexe au lieu de le laisser à la vigilance.Nothing is new since #1: it's the same flipped arrow, applied systematically to every I/O. The only added discipline is topological — a closed boundary, not a stack — so you can't 'forget' to invert a dependency case by case. The pattern enforces the reflex instead of leaving it to vigilance.
№ 05Bilan & éditoWrap-up & editorial2 min
Le métier au milieu d'un empilement, ou au centre d'une frontière.The business in the middle of a stack, or at the center of a boundary.
Les deux topologies, côte à côteThe two topologies, side by side
En couchesLayered
Ports & AdaptersPorts & Adapters
Sens des dépendancesDependency direction
tout descend vers l'infraall run down to infra
tout pointe vers le cœurall point to the core
Le cœur importe…The core imports…
l'ORM, le frameworkthe ORM, the framework
rien que ses propres portsnothing but its own ports
Tester la règleTest the rule
exige base + tiers réelsneeds real DB + third parties
doublures en mémoire, zéro réseauin-memory doubles, zero network
Nouveau point d'entréeNew entry point
dupliquer l'orchestrationduplicate the orchestration
un adapter pilote de plusone more driving adapter
Coût payéCost paid
nul à l'écriture, lourd au changementnone to write, heavy to change
Hexagonal (Cockburn), Onion (Palermo), Clean (Martin) décrivent la même topologie : le métier au centre, l'infra aux bords, les dépendances qui pointent vers l'intérieur. Hexagonal insiste sur la symétrie pilote/piloté ; Onion sur les anneaux concentriques ; Clean sur la « règle de dépendance » (rien de l'intérieur ne connaît l'extérieur). Choisis le vocabulaire que ton équipe lira le mieux — ne collectionne pas les trois.Hexagonal (Cockburn), Onion (Palermo), Clean (Martin) describe the same topology: business at the center, infra at the edges, dependencies pointing inward. Hexagonal stresses the driving/driven symmetry; Onion the concentric rings; Clean the 'dependency rule' (nothing inside knows the outside). Pick the vocabulary your team reads best — don't collect all three.
Quand ne pas tracer la frontièreWhen not to draw the boundary
L'hexagone a un loyer plus lourd que le simple DIP : des ports des deux côtés, une couche de traduction, une composition root à tenir, un domaine qui doit exister pour valoir d'être protégé. On l'évite quand :The hexagon's rent is heavier than plain DIP: ports on both sides, a translation layer, a composition root to maintain, a domain that must exist to be worth protecting. Avoid it when:
le « domaine » n'est que du CRUD : si confirmer une réservation ne fait que copier des champs en base, le cœur est vide et l'hexagone n'entoure que du translateur. Laisse le framework piloter.the 'domain' is just CRUD: if confirming a booking only copies fields to a table, the core is empty and the hexagon wraps nothing but a translator. Let the framework drive.
le service EST l'intégration : un connecteur dont la raison d'être est de parler à Stripe n'a pas de métier à isoler de Stripe. Le détail est le produit.the service IS the integration: a connector whose whole purpose is to speak to Stripe has no business to isolate from Stripe. The detail is the product.
le prototype doit prouver une idée avant la prochaine démo : la frontière se trace quand le métier se stabilise, pas avant. C'est le YAGNI du Vol 0 #3 — on attend le point où le coût du changement dépasse le coût de l'indirection.the prototype must prove an idea before the next demo: the boundary gets drawn when the business stabilizes, not before. It's Vol 0 #3's YAGNI — wait for the point where the cost of change exceeds the cost of indirection.
Le réflexe n'est donc pas « hexagone partout ». C'est : « ai-je un métier qui survivra à son infra, et plusieurs frontières d'I/O qui changeront indépendamment ? Si oui, je trace la frontière ; sinon, je laisse les couches et j'économise la cérémonie. »So the reflex isn't 'hexagon everywhere'. It's: 'do I have a business that will outlive its infra, and several I/O boundaries that will change independently? If yes, draw the boundary; if not, keep the layers and save the ceremony.'
Mot de l'éditeurFrom the editor
Ports & Adapters n'invente rien que le #1 n'ait montré : c'est Dependency Inversion qu'on arrête de faire à la main et qu'on inscrit dans la forme du système. Le débutant range son code en couches parce que c'est l'ordre d'exécution. L'architecte le range autour d'une frontière parce que c'est l'ordre des dépendances — et les deux ordres ne coïncident presque jamais.Ports & Adapters invents nothing #1 didn't show: it's Dependency Inversion you stop doing by hand and inscribe into the system's shape. The beginner arranges code in layers because that's execution order. The architect arranges it around a boundary because that's dependency order — and the two almost never coincide.
La fonctionnalité est identique dans les deux topologies — mêmes requêtes, mêmes octets sur le réseau. Ce qui change, c'est ce qu'on peut tester sans le monde réel, et ce qui reste immobile quand le monde réel bouge. Le couplage était la racine ; on a appris à le casser et à l'inverser. Reste à organiser ce qui vit à l'intérieur de la frontière — c'est le prochain volume.Functionality is identical in both topologies — same queries, same bytes on the wire. What changes is what you can test without the real world, and what stays still when the real world moves. Coupling was the root; we learned to break it and flip it. What remains is organizing what lives inside the boundary — that's the next volume.
Les voisins que ce numéro débloqueThe neighbors this issue unlocks
Onion · Clean Architecture — les deux autres noms de la même topologie ; mêmes dépendances vers l'intérieur, accent différent (anneaux concentriques, règle de dépendance).the same topology's other two names; same inward dependencies, different emphasis (concentric rings, the dependency rule).
Repository — le cas particulier le plus courant d'un port piloté : un contrat de persistance possédé par le domaine, dont l'adapter SQL ou en mémoire se conforme.the most common special case of a driven port: a persistence contract owned by the domain, with a SQL or in-memory adapter conforming to it.
Anti-Corruption Layer — l'adapter qui ne traduit pas seulement les types mais protège le domaine d'un modèle externe hostile. On y revient quand on parlera des frontières entre contextes.the adapter that doesn't just translate types but shields the domain from a hostile external model. More when we cover boundaries between contexts.
Le kata — écris-le, ne le relis pasThe kata — write it, don't reread it
Tu as vu l'empilement se replier en frontière au chapitre 3 et retrouvé ses trois lignes porteuses. Reste le geste qui ancre : l'écrire. Copie ce fichier de tests dans ton langage habituel — la spec tient dans les noms des tests — et fais-le passer au vert en pilotant le cas d'usage avec des doublures en mémoire, sans jamais toucher au réseau. Le dernier test est le sournois : c'est lui qui vérifie que tes ports ne nomment que du métier (Booking, BookingId) et jamais un type d'infra — l'hexagone creux du chapitre 4.You watched the stack fold into a boundary in chapter 3 and recalled its three load-bearing lines. What's left is the gesture that anchors it: writing it. Copy this test file into your usual language — the spec fits in the test names — and turn it green by driving the use case with in-memory doubles, never touching the network. The last test is the sneaky one: it's what checks that your ports name only domain types (Booking, BookingId) and never an infra type — chapter 4's hollow hexagon.
test_hexagon.py
# test_hexagon.py — la spec EST le harnais : fais-le passer au vert.# un cas d'usage qui ne connaît QUE ses ports. doublures en mémoire, zéro réseau.def test_confirme_via_les_ports():
store = InMemoryStore({7: Booking(id=7, customer=alice, total=100)})
pay = FakePayment()
ConfirmBooking(store, pay).run(7)
assert pay.charged == [(alice, 100)] # le cœur a facturé via le portdef test_le_coeur_ignore_l_infra():
uc = ConfirmBooking(InMemoryStore({}), FakePayment())
assert "sqlalchemy" not in modules_touched(uc.run) # aucun import d'infra dans le cas d'usagedef test_changer_d_adapter_ne_touche_pas_le_coeur():
pay_a, pay_b = FakePayment(), FakePayment()
ConfirmBooking(InMemoryStore(seed), pay_a).run(7)
ConfirmBooking(PostgresStore(fake_conn(row_7)), pay_b).run(7) # même cœur, autre bord
assert pay_a.charged == pay_b.charged # même comportement, use case non rouvertdef test_l_adapter_traduit_ne_decide_pas():
booking = PostgresStore(fake_conn(row_7)).get(7)
assert isinstance(booking, Booking) # SQL → métier, et aucune règle ici# l'adapter mappe Row→Booking ; « faut-il facturer ? » n'est PAS son affairedef test_le_port_ne_nomme_que_du_metier():
# la signature n'expose que Booking, BookingId — jamais Row ni Query.
assert port_types(BookingStore) <= {Booking, BookingId} # une fuite d'infra = un test rouge
Chez toi, cette semaine.In your codebase, this week.
Ouvre tes interfaces de repository et de service, et lis leurs signatures : une seule y nomme-t-elle un Row, un QuerySet, un HttpRequest, une entité ORM ? C'est une prise qui fuit — le cœur devra importer l'infra pour s'en servir. Un point de départ : grep -rn "import stripe\|from sqlalchemy\|import boto3" src/domain/ src/core/ — chaque import d'infra dans le domaine est une flèche qui ressort. Pose la question du bilan : « ai-je un métier qui survivra à cette infra ? » Sinon, laisse les couches et n'entoure pas du vide.Open your repository and service interfaces and read their signatures: does a single one name a Row, a QuerySet, an HttpRequest, an ORM entity? That's a leaking socket — the core will have to import infra to use it. A starting point: grep -rn "import stripe\|from sqlalchemy\|import boto3" src/domain/ src/core/ — every infra import inside the domain is an arrow that escaped. Ask the wrap-up's question: 'do I have a business that will outlive this infra?' If not, keep the layers and don't wrap emptiness.