Fan de l’architecture hexagonale et de ses promesses depuis très longtemps, j’ai souvent passé du temps à la comprendre, l’expliquer mais aussi à démystifier son implémentation. Ce pattern a d’ailleurs accompagné une grosse partie de mes premières mises en œuvre du DDD au travail. Mais comme beaucoup trop de développeuses et de développeurs, je me suis souvent arrêté aux cas simples dans mes explications, aux “happy path” où tout se passe bien. Dans ce premier article d’une mini-série à venir, je souhaite évoquer cette fois quelques détails d’implémentation ainsi qu’un piège dans lequel il est très facile de tomber. L’idée étant de vous faire gagner un peu de temps lors de vos mises en œuvre de ce pattern d’architecture toujours aussi incroyablement utile.
Un Design-smell pour votre hexagone
Une fois n’est pas coutume, on va parler aujourd’hui d’un
design-smell ou d’un anti-pattern : l’adaptateur de droite non cohésif. Si
vous ne connaissez pas encore l’architecture hexagonale, je vous suggère de
commencer par la lecture de mon précédent post sur le sujet qui récapitule tout
ça ( http://tpierrain.blogspot.com/2016/04/hexagonal-layers.html). Car pour expliquer l’anti-pattern adaptateur de droite non cohésif, on
va juste rappeler ici quelques fondamentaux au sujet de l’architecture
hexagonale :
- Pour interroger le métier (le code du domaine), on s’adresse à un port que l’on appelle « driver port » ou « left-side port »
- Pour que le code du domaine puisse faire son travail, celui-ci peut avoir besoin d’utiliser des systèmes tierces (data store, web API, etc.). Pour ce faire, le code métier reste au niveau d’expressivité métier en interrogeant un ou plusieurs « driven ports » ou « right-side ports ».
- Un port est donc une interface (qui fait partie du Domaine) qui utilise une sémantique métier pour exprimer des demandes que l’on adresse à notre système (left-side) ou des actions/demandes que celui-ci effectue en cours de route pour arriver à ses fins (right-side). J’aime bien voir les ports comme des pont-levis pour aller et venir entre l’infra et le domaine et vice-versa (analogie que m’avait soufflée Cyrille MARTRAIRE il y a très longtemps)
- En termes de dépendance, la technique (le code d’infrastructure) connait et référence le métier. Enfin, soyons précis (au passage, merci à Alistair COCKBURN pour cette remarque pertinente) : seul le code d’infrastructure qui se trouve à gauche de l’hexagone connait le métier (à travers les portes d’entrée vers celui-ci que constituent les ports de gauche), pour interagir avec celui-ci.
- A l’inverse, le code métier (domaine) ne référence pas et ne connait pas le niveau technique. L’ensemble arrive à fonctionner au runtime grâce à l’inversion de dépendance que Gerard MESZAROS appelle lui des « configurable dependency » (ce qui se prête très bien au pattern d’Alistair)
- Un adapteur est un bout de code qui permet de passer d’un monde à l’autre au runtime (infra => domain ou domain => infra) et qui s’occupe notamment de convertir les structures de données d’un monde (ex : JSON ou DTO) à l’autre (ex : POJO ou POCO du domaine)
- Il y a 2 types d’adaptateurs :
- Les « left-side adapters », qui ont une relation de type agrégation avec une instance de left-side port qu’ils utilisent
- Les « right-side adapters », qui implémentent une instance de right-side port
J’ai connu un adaptateur de droite une fois, qui avait dix fois plus de classes...
Vous aurez peut-être remarqué ici que je n’ai pas dit que
les right-side adapters implémentaient plusieurs interfaces de right-side
ports. C’est intentionnel.
Car si vous le faisiez, il y a de fortes chances pour que
vous arriviez à l’anti-pattern que je veux évoquer ici : l’adaptateur de droite
non cohésif.
De quoi s’agit-il ? C’est ce qui se passe lorsque vous
confiez trop de responsabilités à un adaptateur de droite : vous risquez alors
de déporter une partie de la logique de l’hexagone dans un de ses éléments
périphérique (l’adaptateur). C’est ce qui se passe notamment lorsqu’un adapteur
référence et utilise en direct un autre adapteur. C’est bien entendu quelque
chose que je déconseille vivement (pour conserver toute la logique
d’orchestration métier au niveau du domaine et pas la repartir au niveau du
code d’infrastructure), mais vu que tout le monde tombe dans ce piège au moins
une fois, ça vaut le coup d’être mentionné. Fuyez cette situation comme la
peste (ou le COVID19).
Pire. Pour celles et ceux qui ne testeraient que le
centre de l’hexagone (le code du domaine) en bouchonnant vos adaptateurs, vous
passeriez à côté de pleins de code et de bugs potentiels qui se trouveraient
nichés dans vos adaptateurs non testés.
Il est en effet extrêmement facile de se bercer
d’illusion en mettant plein d’implicites dans le comportement de vos stubs qui
vont faire office d’adaptateurs. Vous y mettrez très souvent ce que vous pensez
que les adaptateurs feront ou doivent faire sans pour autant vous donner la
garantie que cela sera le cas. Cela vous donnera pleins de tests verts, mais
avec des comportements potentiellement buggés en prod (quand vous utiliserez les
vrais adaptateurs concrets).
Quelle stratégie de test au fait ?
J’ai déjà écrit et parlé sur la question mais comme je
n’ai pour l’instant pas traduit en anglais mon article le plus spécifique sur le sujet, je vais juste clarifier ici quelques termes indispensables pour
comprendre la suite de mon article.
Après plus de 15 ans de pratique du TDD au boulot, je
suis arrivé au fil des années à une forme économe d’outside-in TDD qui convient
très bien à 90% de mes projets. Par Outside-in, je veux dire que je considère
mon système (souvent une API web) dans son ensemble et depuis l’extérieur. Un
peu comme une grosse boite noire que je vais faire grandir et améliorer
progressivement en écrivant des tests d’acceptation qui la pilote. Ceux-ci ne
vont pas s’intéresser aux détails d’implémentation (ce qui est fait et codé à
l’intérieur de la boite) mais uniquement au comportement métier de celle-ci en
interagissant avec elle depuis l’extérieur. Mes tests d’acceptation sont donc
des tests gros grains, qui testent la boite noire (ici, l’ensemble de
l’hexagone) mais qui ne font pas appel à des vrais protocoles techniques ni a
de vrais data store pour être le plus rapide possible (je suis accro au
feedback court et utilise pour ça un outil qui fait tourner mes tests à chaque
fois que je change une ligne de code).
A côtés de ces tests d’acceptation qui constituent 80% de
ma stratégie, j’écris en général quelques tests unitaires grains-fins (les
petits tests de la « double loop » quand j'en ai besoin), quelques tests d’intégrations,
et des tests de stubs ou de contrats vis à vis de mes dépendances externes
(pour détecter quand ils changent des choses). Dans la suite de cet article, je
ne parlerai que de mes tests d’acceptation.
Le meilleur des compromis possibles
Cette parenthèse sur les types de tests étant fermée,
revenons à notre problème qui est d’éviter d’écrire des tests incomplets de
notre système, et qui vous donneraient l’impression que tout va bien dans votre
hexagone alors qu’au runtime vous aurez des mauvaises surprises.
Ma recommandation : écrivez des tests d’acceptation qui
couvrent tout votre hexagone, à l’exception des I/Os en bout de chaine. Oui,
vous m’avez bien entendu : je vous conseille d’écrire des tests d’acceptations
qui utilisent vos adaptateurs concrets et pas des stubs. La seule chose qu’il
vous faudra bouchonner (stubber) du coup, ce sont les appels réseaux, les accès
disques ou base de données qui eux, sont faits par vos adaptateurs. Ce faisant,
vous testez tout votre code, en mode assemblage complet (sans illusion ni
mauvaise surprise en prod donc), mais blazing fast ;-)
C’est toujours plus clair avec un Example
Oui, prenons un Example pour être encore plus clair
(déformation professionnelle ;-)
Imaginez que vous ayez à coder un générateur de fichiers sitemap.xml pour le SEO d’une plate-forme web au contenu complètement dynamique et contrôlée par plusieurs ligne métiers (e-biz, marketing). Cet hexagone pourrait être une web API utilisée quotidiennement à côté du site web, pour régénérer tous les fichiers sitemaps de celui-ci suite à un appel de type POST (HTTP) par exemple. PS: Par contre, ne dites pas à ma mère que j’ai parlé de micro-service dans un de mes articles ;-)
Je vous conseille plutôt d’écrire des tests d’acceptation dans lesquels vous allez inclure vos adaptateurs concrets et uniquement stubber leurs I/Os, comme ça :
Peut mieux faire
Bien sûr, pour avoir des tests d’acceptations lisibles,
concis (pas plus de 10/15 lignes) et expressifs (c.a.d. en voyant bien les
valeurs transmises à vos bouchons), vous allez en général passer par des
builders pour vos stubs, mocks, voire même pour l’assemblage de votre hexagone.
Mais cela aurait été moins clair ici, pour mon exemple. Une version cible
pourrait ressembler à ça :
Avertissements
On pourra m’objecter ici que mes tests d’acceptation sont un peu hybrides et qu’ils couvrent à la fois des considérations métiers mais aussi certaines plus techniques. Pas très orthodoxe tout ça ;-)
Bon déjà moi l’orthodoxie ou le by-the-book, je m’en fous. Et puis c’est
complètement transparent si vous faites en sorte que chacun de vos tests d’acceptation
couvrent bien des comportements métiers de votre système
(service/API/micro-service... choisissez ici le nom approprié). A vous de
travailler l’expressivité de vos tests : le nom de ceux-ci, la simplicité et la
brièveté de l’assemblage et du « arrange » en mode DSL par exemple.
Avant d’arriver par tâtonnement à cette nouvelle
stratégie (cette année), je mettais uniquement en œuvre la stratégie de test mise
en avant par nos copains Londoniens de Cucumber (Seb Rose, Steve Tooke et Aslak
Hellesøy). Celle-ci vise à combiner :
- Énormément de tests d’acceptation mais
pour lesquels on stubbe l’intégralité des adaptateurs (des tests supers rapides
comme les miens donc)
- Et puis quelques tests complémentaires de
contrats, dans un autre projet. Ceux-ci vérifiant que nos stubs d’adaptateurs utilisés
côté acceptation ont exactement le même comportement que les vrais adaptateurs
concrets qui sont eux assemblés et livrés avec notre hexagone en prod. Ces
derniers tests d’intégration sont donc beaucoup plus lents et exécutés plus
rarement (essentiellement sur l’usine de dev, pas dans mon Runner automatique
local et permanent ncrunch) mais c’est un compromis volontaire.
On avait donc une situation vraiment intéressante qui
permettait d’avoir pleins de tests d’acceptation supers rapides qu’on faisait
tourner en permanence, et d’être suffisamment en confiance sur la capacité de
nos stubs d’adaptateurs de droite pour nous aider à atteindre cet objectif (car
on testait aussi qu’ils sont fidèles à la réalité dans quelques scenarios de
référence).
J'en parle au passé ici, car je n’ai pas réellement réussi à
avoir suffisamment confiance dans ce dispositif dans mes expériences avec
différentes équipes. Ce qui nous posait régulièrement problème avec cette
stratégie telle-quelle, c’était l’effort moindre mise par toute l’équipe dans
ces derniers tests de contrats. On arrivait encore plus souvent que d’habitude
à des situations avec beaucoup trop de happy path (des scénarios de base où
tout se passe bien) dans ces tests de contrats. Ceux-ci ne couvraient pas assez
de cas d’erreurs ou d’exceptions. Pour le dire autrement, nos tests
d’acceptations sollicitaient nos stubs sur beaucoup plus de cas que ce qui
était prévu pour eux dans nos tests de contrat les concernant.
Alors c’est vrai que les devs que nous sommes sont
attirés par les happy path autant que les papillons de nuit le sont par une
ampoule allumée. Ça semble être un de nos déterminisme ;-) Mais j’ai un peu
observé ces situations pour essayer de comprendre ce qui n’allait pas et en
suis arrivé à la conclusion que c’etait parce que ces tests d’intégrations -en
plus d’être très « orientés plomberie »-sont beaucoup plus lents à
faire tourner. C’est pour ça que les gens y apportaient moins d’attention. Un
peu en mode : "de toute façon c’est un test d’intégration pour un stub... ça
doit faire le job mais on ne va pas y passer trop de temps non plus".
La conséquence de tout ça ? On testait en général
beaucoup moins de combinaisons de cas dans ces tests de contrats de nos stubs
beaucoup que ce dont on avait l’habitude de faire dans nos tests unitaires
grains fin ou dans nos tests d’acceptation gros grains.
Ceci avait pour effet que cette association des deux
types de tests n’était pas suffisante, pour arriver à attraper tous les bugs ou
les problèmes de plomberie dans notre assemblage final. C’est donc pour ces
raisons que j’en suis finalement arrivé à la stratégie de test que je vous ai
présentée dans cet article, et qui inclus les adaptateurs concrets dans les
tests d’acceptation. Par contre, j’utilise toujours cette stratégie de test
de contrats pour les composants extérieurs ou des APIs tierce, c’est
juste que maintenant je stubbe moins de choses, mes stubs ne couvrent plus
qu’une partie très fine.
Comme dernier avertissement, je dois aussi préciser que ma
stratégie de test fonctionne incroyablement bien dans mon contexte.
Je n’ai en effet jamais rencontré de cas comme celui d’Alistair, où j’avais
besoin d’exposer la même API avec plusieurs technologie différentes à gauche (HTTP, AMQP, MQTT...). J’ai en général une
exposition unique de mon domaine : du HTTP dans une web API, ou du Aeron dans
un service low latency, ou du RPC dans... (non je rigole, je déteste trop le modèle
RPC ;-)
Je n’ai donc besoin que d’un seul adaptateur côté gauche.
Ce qui m’évite d’être confronté au fait de devoir rejouer autant de fois mes
tests d’acceptation que je j’aurai de technologies d’exposition différentes. Ca
vaut le coup de le rappeler ici, j’utilise surtout l’architecture hexagonale
parce qu’elle me permet de bien séparer mon code métier de mon code technique. Pas pour pouvoir interchanger facilement ma technologie d'exposition.
Je sais que ce côté « plugins pour attaquer la même
logique métier via des technos différentes » est une des forces de
l’architecture hexagonale, mais c’est un cas que je n’ai pour l’instant
rencontré que dans des katas. Je ne suis d’ailleurs pas le seul dans ce cas, car
dans toutes les formations DDD que j’ai pu donner ou lors de discussions dans
des conférences, j’ai eu de nombreux témoignages de gens qui venaient le voir
en me disant la même chose : « j’utilise mes contrôleurs web (de web API)
comme adaptateur de gauche, car je n’ai pas le cas d’une multi-exposition à
gauche ».
Un dernier bénéfice pour la route
Ce qui est sûr en revanche, c’est que le fait de tout tester -sauf les I/Os- vous mettra dans tous les cas dans une situation confortable car :- vos tests d’acceptation couvriront une code base très fidèle à la réalité de la prod
- votre harnais de tests d’acceptation vous permettra de refactorer sereinement votre base de code dans le cas où vous vous seriez trompés et auriez mis du comportement métier dans vos adaptateurs. Avec ces tests qui couvrent l’ensemble, la move pourra être fait sans risque.
Peut-être pourrions-nous trouver un moyen facile (autre
que le pair/code review) pour éviter aux personnes les moins expérimentées de
tomber dans ce piège. Pour l’instant cette capacité de refactorer facilement a
posteriori si mes collègues ou moi-même nous sommes trompés (ou avons pris un
peu de dette technique court-terme), était largement suffisante sur mes projets.
En conclusion
- Passez toujours par le centre de l’hexagone, ne connectez pas vos right-side adapters entre eux
- Ne codez pas VOTRE logique métier ni VOTRE logique d’orchestration dans vos adaptateurs
- Testez l’intégralité de votre hexagone (adaptateurs compris); ne bouchonnez que vos I/Os en bout de chaine utilisés par vos adaptateurs de droite
Les 2 prochains articles de cette série dédiée à
l’architecture hexagonale parleront du sujet des health-checks (comment savoir
si notre hexagone est en forme ou pas) mais aussi de la comparaison avec une
variante du pattern, à savoir le Functional Core (imperative shell). A très vite.
Thomas
No comments:
Post a Comment