Wednesday 25 March 2020

Architecture Hexagonale : ne vous perdez pas à droite


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 ;-)

Bon, et bien au lieu d’écrire des tests d’acceptation comme ça :


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

  1. Passez toujours par le centre de l’hexagone, ne connectez pas vos right-side adapters entre eux
  2. Ne codez pas VOTRE logique métier ni VOTRE logique d’orchestration dans vos adaptateurs
  3. 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