18 Septembre 2022
Aucun projet de développement logiciel d’une certaine envergure ne peut faire l'économie de la question de la structure logicielle. L'adoption de plus en plus courante, voire systématique, des approches agiles sur ces projets peut laisser penser que la structure doit être purement émergente et ne doit faire l'objet d'aucune anticipation. Il s'agit là d'une erreur qui, à moyen terme, se paie au prix fort pour les équipes qui s'en laissent convaincre.
La question de la structure se pose à différents niveaux : au niveau du système et au niveau de ses composants. L’objet de cet article est de proposer un panorama des structures qu’on peut trouver au niveau des composants. Ainsi, certains styles d’architectures de niveau système tels que les « micro-services » ne seront pas évoqués ici. On notera néanmoins que les motifs de structure proposés dans cet article restent applicables individuellement à un micro-service dans une architecture de ce type.
Commençons avec la structure la plus simple qu’on peut qualifier de monolithe. Ce terme est généralement opposé à celui de micro-service ; on retiendra par exemple le conseil de Simon Brown qui nous invite à savoir concevoir des monolithes modulaires avant de concevoir des architectures à base de micro-services. Le terme de monolithe évoqué ici fait référence aux modalités de déploiement de la solution en suggérant qu’un système complet puisse être déployé comme un tout, indépendamment de sa structure interne. Dans cet article, le concept de monolithe fait référence à la structure interne des composants. D’un point de vue technique on pourra concevoir le monolithe comme un composant constitué d’un seul module (ou paquetage), d’une seule classe, voire d’une seule méthode. Une telle structure, ou plus précisément une telle absence de structure, est tout à fait acceptable, voire souhaitable, tant que la complexité du composant reste élémentaire. On trouve de nombreux exemples de code (tutoriels ou « hello world ») qui présentent cette structure pour des raisons de simplicité et de concision. Cet argument vaut pour le développement de solutions opérationnelles ; si on suit le principe KISS (Keep It Simple Studid), il est contreproductif de venir introduire des éléments de structure dans un composant qui propose un comportement trivial.
Dès que le comportement visé suppose un volume de code plus conséquent il convient d’envisager une approche plus structurée. En effet, rester sur une approche monolithique avec un volume de code qui approche le millier de lignes de code pose plusieurs problèmes :
La nécessité de structurer le logiciel s’impose à partir d’une certaine taille, mais toutes les structures ne se valent pas. Et modulariser un logiciel reste une vaine incantation si on ne suit pas certains principes qui permettent de répondre aux enjeux posés ci-dessus.
Au début des années 2000, lorsque j’ai commencé à travailler, le style architectural « en couches » était le plus communément adopté. Robert Martin explique assez bien dans son ouvrage « Clean Architecture », et mieux encore dans son article « No DB », comment les architectes logiciels s’étaient alors laissés convaincre de construire leurs solutions autour de systèmes de gestion de bases de données relationnelles. D’un autre côté, il était nécessaire d’exposer l’application à ses utilisateurs, ce qui supposait d’avoir des éléments IHM dont la migration progressive vers des contenus Web s’amorçait. En complément de la persistance et de l’interface utilisateur, une application porte également une logique métier qui se veut la plus indépendante des externalités techniques induites par la base de données et la technologie des IHM. Il en résulte trois tiers assez distincts qui ont guidé la mise en place des architectures en couches pendant de nombreuses années : présentation, application, données.
Le principe de ce style d’architecture est de limiter les dépendances en imposant que chaque couche de l’architecture ne dépend directement que de la couche inférieure et qu’il n’existe aucune dépendance, même indirecte, vers une couche supérieure. Autrement dit, la couche de donnée ne dépend d’aucune autre couche du logiciel développé ; la couche applicative ne dépend que de la couche de donnée ; la couche de présentation s’appuie sur la couche applicative sans avoir de dépendance directe avec la couche de données. Dans cette approche, le flux de contrôle peut suivre les dépendances logicielles ; autrement dit, il n’est pas nécessaire de recourir à un mécanisme d’inversion de dépendance.
D’une certaine façon, ce style d’architecture s’inspire du modèle OSI qui a fait ses preuves dans le domaine réseau depuis les années 1970. Les principes de séparation des responsabilités et de communication entre couches au travers d’interfaces standardisées ont été repris par les ingénieurs logiciel qui ont mis en œuvre ces architectures en couches.
Il est ici important de souligner le concept d’interface standardisée. S’il est tout à fait possible d’établir des dépendances directes entre les implémentations d’une couche et celles de la couche inférieure, il est préférable que ces dépendances passent par des interfaces abstraites. Il ne s’agit pas là seulement d’une lubie d’architecte mais bien d’une nécessité pour les tests unitaires. En effet, si on souhaite par exemple tester la couche applicative sans s’appuyer sur la couche de données, et notamment sur la base de données, il est nécessaire de pouvoir injecter une dépendance vers une doublure de test.
Le silotage de l’architecture en couche ne répond pas toujours aux enjeux de complexité d’un composant logiciel. Dans certains cas, il est en effet nécessaire d’isoler certains domaines fonctionnels ; or l’organisation traditionnelle d’une architecture en couche est plutôt orientée selon une décomposition technique (base de données, métier, IHM). Introduire un axe de découpage supplémentaire permet de répondre à cette nécessité de décomposer fonctionnellement. En prenant l’exemple d’un catalogue de produits en ligne à partir duquel un utilisateur peut passer des commandes on peut produire le diagramme suivant :
Dans ce cas, on respecte une isolation stricte des domaines fonctionnels. Une telle approche est généralement celle adoptée par les concepteurs qui visent une architecture orientée services, à la différence près que les silos sont déployés de manière indépendante.
L’introduction d’un axe fonctionnel dans l’architecture matricielle préserve un bon niveau de découplage. Néanmoins, il est assez rare que de telles organisations soient retenues : d’une part les domaines fonctionnels sont rarement isolés et la tentation est grande venir coupler par les données, en particulier si le système s’adosse à une base de données relationnelle dont le but est, comme son nom l’indique, de gérer des relations ; d’autre part, le silotage interdit les mutualisations sur les éléments techniques (par exemple un système de gestion des paramètres de configuration, ou bien un mécanisme de journalisation). De nombreuses structures logicielles s’appuient ainsi sur une couche d’éléments « communs » et il n’est pas rare de voir un module « commons » dans une solution logicielle.
En abandonnant la logique du silo pour les domaines fonctionnels, il est assez naturel de jouer la carte de la convergence jusqu’à la couche IHM où il est nécessaire de proposer à l’utilisateur une vision agrégée des domaines fonctionnels plutôt qu’une segmentation qui n’a de sens qu’au niveau technique. Dans les architectures qui préservent le silotage des domaines fonctionnels, comme les micro-services, on retrouve d’ailleurs souvent un composant dont le rôle est de réagréger les données de manière à la présenter à l’utilisateur final. Ainsi, l’extension de l’architecture en couches à plusieurs domaines fonctionnels va plutôt tendre vers une architecture pyramidale que vers une architecture matricielle.
Le diagramme ci-dessus illustre la nature des couplages qu’on peut trouver dans une architecture pyramidale : un couplage technologique par les éléments communs, et un couplage par les données (le rôle central de la « commande » qui fait le lien entre les produits du catalogue et les consommateurs y est manifeste). Ces couplages restent des compromis vis-à-vis de la mutualisation du code et la consistance du modèle de données. Tant que ces compromis sont conscients, la mise en place d’une telle structure est tout à fait pertinente.
Dans les exemples précédents, la présentation est présentée comme un unique module. Une telle approche « monolithique » est tout à fait acceptable pour des IHM assez simples. Pour des interfaces utilisateurs plus complexes, il est généralement nécessaire d’introduire des éléments de structure intermédiaires, c’est-à-dire d’autres modules. Et une approche classique en la matière consiste à adopter le patron de conception MVC proposant une séparation du modèle de données IHM, des vues, des contrôleurs. Selon cette approche, la vue a la charge de présenter les informations à l’utilisateur et d’intégrer les widgets permettant à l’utilisateur d’interagir avec l’interface ; la vue dépend donc à la fois du modèle qu’elle représente et des contrôleurs qui prennent en charge les actions des utilisateurs. Le contrôleur dépend également du modèle qui expose les opérations métier que l’utilisateur souhaite atteindre via les widgets de la vue. De son côté, le modèle ne dépend ni des vues ni des contrôleurs mais est orienté vers le cœur applicatif, par exemple la couche « Application » dans l’exemple de l’architecture en couche traditionnelle. Les modifications du modèle qui doivent être répercutées au niveau de la vue passent en général par un mécanisme de notification selon le patron de conception « Observateur » qui évite le couplage du modèle vers la vue.
Il est intéressant de noter que le patron MVC définisse un modèle alors même que l’architecture en couche situe la définition des données beaucoup plus bas dans la pile de dépendances. Ce point suggère que plusieurs modèles de données coexistent dans une application complète. De fait, la modélisation d’une donnée à des fins de présentation, de traitement métier et de persistance peut nécessiter des ajustements locaux qu’il est important de ne pas propager. Par exemple, dans la couche de présentation, on pourra imaginer qu’un produit du catalogue soit sélectionné par l’utilisateur ; associer un état de sélection à une commande à du sens pour le modèle de l’IHM mais n’a pas de sens pour les traitements métier. De même, attribuer un identifiant numérique aux produits du catalogue pour optimiser la persistance des relations entre ces produits et les commandes n’a pas de sens au niveau métier et encore moins au niveau de la présentation.
Les différents modèles d’architecture présentés ci-dessus ont une caractéristique en commun : elles sont construites verticalement. Et même si chaque couche de la structure n’est directement dépendante que de la couche inférieure, les dépendances transitives ne sont pas à négliger. Ces architectures débouchent ainsi souvent sur un empilement de couches non interchangeables qui contraignent la testabilité et le déploiement : en effet, il est assez difficile de venir intégrer des doublures de test ou des implémentations alternatives dans une structure aussi verticalisée.
Après l’âge d’or des architectures en couches, des modèles d’architecture favorisant l’horizontalité de la structure ont été de plus en plus fréquemment adoptées et sont aujourd’hui assez souvent recommandées. C’est le cas des architecture orientées événements et des architectures hexagonales discutées ci-dessous. Naturellement, l’émergence de ces modèles d’architecture ne doit pas remettre en cause le bien fondé des architectures historiques. Tout comme il est précisé qu’un monolithe est acceptable dans le cadre d’une application simple, une architecture en couches peut tout à fait répondre aux besoins de simplicité, de modularité et d’évolutivité d’une application. Rappeler ici la nécessité de rechercher la simplicité est essentiel dans la mesure où les deux modèles suivants présentent quelques éléments de complexité technique à ne pas sous-estimer.
La déclinaison typique de l’architecture en couche proposait trois tiers : un tiers pour la persistance, un tiers pour la logique métier et un tiers pour la présentation. Dans une application qui requiert ces trois couches, la dynamique de la plupart des cas d’utilisation répond à une sollicitation de l’utilisateur qui est prise en charge par la couche de présentation, déléguée à la couche applicative qui s’appuie à son tour sur la couche de donnée pour insérer, récupérer, modifier ou supprimer des données (logique CRUD – Create, Request, Update, Delete). Dans cette architecture, le flux de contrôle et les dépendances structurelles sont orientées dans le même sens. C’est ce qui conduit à la verticalité de solution. Pour orienter la solution selon un axe horizontal, il faut rompre avec l’alignement des liens de dépendances et du flux de contrôle en recourant à l’inversion de dépendance (le « D » des principes SOLID).
La place centrale de la logique métier est renforcée dans une architecture hexagonale ce qui en fait un style d’architecture particulièrement utilisée pour les équipes suivant le Domain Driven Design qui est centré sur le modèle métier. Une architecture hexagonale place en son cœur le modèle de données et la logique du métier. Les interactions avec la présentation ou la persistance des données sont alors contractualisées au niveau noyau métier. Prenons l’exemple d’un scénario de création d’une commande depuis un catalogue : l’interaction utilisateur gérée par la présentation se conclut par une validation du panier qui crée la commande. Ce scénario dont le point de départ se situe dans le module de présentation se traduit par une délégation de la création effective de la commande vers le noyau applicatif. Dans le ce cas, le flux de contrôle suit la dépendance de la présentation vers le module applicatif. Ce n’est plus le cas lorsqu’il s’agit de persister la commande ; en effet la logique métier spécifie le contrat que le module de persistance doit implémenter, contrat qui comporte une opération de création de commande. Ce contrat est défini dans le cœur applicatif et l’implémentation est apportée par le module de persistance pour être injectée dans le module métier. Ainsi la dépendance va du module de persistance vers le module applicatif alors que le flux de contrôle va dans l’autre sens.
Les avantages d’une telle approche sont nombreux ; en gérant le couplage de cette manière, les modifications qui surviennent sur un module périphérique (ici les modules de présentation et de persistance) n’impactent pas structurellement le module métier (il faut préciser ici « structurellement » parce que les changements de comportement vont nécessairement avoir un impact sur le fonctionnement global du composant). On peut même aller jusqu’à remplacer une de ces modules périphériques sans remettre en cause le code de la logique métier, sous réserve que le contrat d’interface est respecté. Cet avantage est particulièrement intéressant pour gérer l’obsolescence des modules qui sont technologiquement adhérents, sans remettre la logique métier qui s’avère généralement plus stable. On peut aussi profiter de cet avantage pour intégrer des doublures de test qui permettent de tester logique métier dans les meilleures conditions : remplacer une base de données par une doublure de stockage en mémoire évite bien des soucis de performance ou de répétitivité des tests unitaires.
Quand on regarde la représentation classique d’une architecture hexagonale, on pourrait douter qu’il s’agit d’une architecture horizontale. Pourtant, il s’agit bien d’une architecture à plat où toutes les modules sont dépendants du module métier et d’aucun autre. La représentation canonique de l’architecture hexagonale consiste simplement à replier l’architecture, en suivant généralement une logique de traversée du flux de contrôle de la gauche vers la droite. Sur la partie gauche, on trouvera ainsi le module de présentation, mais on pourrait également y trouver un frontal de services REST.
L’architecture hexagonale propose un très bon niveau d’évolutivité, ce qui en fait un style de plus en plus adopté. Mais rappelons qu’il n’existe pas de balle en argent et que l’architecture hexagonale ne doit pas être retenue les yeux fermés. En effet, l’inversion de dépendance qu’elle suppose pour réduire les couplages présente un coût : l’introduction d’abstractions (les contrats d’interfaces), le recours à des fabriques (pour ne pas créer des couplages directs vers des implémentations de ces contrats) et la mise en place de DTO - Data Transfer Object (pour dissocier proprement les modèles de données). Si le besoin à adresser est simple, que le modèle de données est anémique, que la logique se limite à du CRUD, ou que le composant est voué à une grande stabilité, une architecture en couches peut répondre plus efficacement.
L’organisation horizontale qui ne semblait pas manifeste pour une architecture hexagonale, l’est davantage dans une architecture orientée événement. Ce style d’architecture qui vient clore notre bestiaire présente la même « hauteur » que l’architecture hexagonale mais elle introduit un concept supplémentaire qui vient encore réduire les couplages entre modules. En effet, le flux de contrôle dans une architecture hexagonale reste assez classique dans le sens où les transferts de contrôle entre modules sont explicites et généralement synchrones (même si ce n’est pas une nécessité absolue). Autrement dit, pour reprendre l’exemple précédent, quand le module de présentation délègue la création d’une commande au module applicative, il le fait avec un appel direct. De la même façon le module métier sait qu’une implémentation se cache derrière son interface de persistance et l’appelle explicitement. Cette hypothèse de l’existence d’un, et un seul, module cible lors du transfert du flux de contrôle est le couplage que les architectures orientées événements viennent lever.
A la place d’un appel explicite, le module qui souhaite déléguer une action à un autre module le fait en émettant un événement. Si un module est conçu pour réagir à cet événement, il prendra sa part dans le scénario système, déléguant à son tour le flux de contrôle en émettant un ou plusieurs événements. L’émetteur d’un événement ne sait pas quel module le prendra en charge, ni même si un module le prendra ou si plusieurs modules le traiteront. Le seul couplage qui perdure entre ces modules est celui du modèle de données des événements échangés et du médium de communication, c’est-à-dire du bus d’événements.
L’évolutivité apportée par une telle architecture est plus importante encore que celle de l’architecture hexagonale. Par exemple, imaginons que nous ayons réalisé une première version de notre système de commandes dans lequel les données sont stockées par le module de persistance dans une base de données relationnelle. Le client sollicite une évolution pour que les commandes soient également transmises vers un autre système de stockage de l’un de ses partenaires. Dans l’architecture hexagonale, il était simple de remplacer un support de persistance par un autre mais supposait un changement de la logique métier pour en adresser deux. Avec une architecture orientée événement, il suffit d’introduire un nouveau module s’abonnant aux événements de stockage de la commande pour prendre en compte le besoin, sans qu’il soit nécessaire de venir modifier la logique métier qui émet l’événement.
Tout comme pour l’architecture hexagonale, le gain en matière de couplage vient avec une contrepartie. En effet, avec une architecture orientée événements, la dynamique de l’application est plus souple mais aussi moins facilement lisible. Pour comprendre les implications de l’émission d’un événement, il faut en effet connaître les modules qui vont y réagir. De plus, la logique est fragmentée par les échanges d’événements davantage qu’elle ne l’est pour des appels directs : à l’appel, il n’y a pas de grande différence puisque les arguments d’un appel direct peuvent se concevoir comme un la charge utile (ou « payload ») d’un événement. Mais le traitement du retour est plus complexe : là où un appel direct et synchrone est mis en attente et dispose de son contexte de travail lorsqu’il reçoit des données en retour, le traitement d’un retour asynchrone suppose de préalablement restaurer un contexte de traitement, entraînant une lourdeur algorithmique. D’ailleurs, lorsqu’on projette ce style d’architecture (qui, je le rappelle, est abordé ici dans le cadre d’un composant) sur une architecture système, les architectes adoptent souvent le patron CQRS (Command Query Responsibility Segregation) qui permet justement de dissocier les commandes et requêtes.
Les sections précédentes mettent en évidence plusieurs critères qui permettent de cartographier les styles d’architecture. Le diagramme suivant s’attache à deux aspects essentiels que sont la verticalité de la solution et la force des couplages :
Face à cette diversité de styles d’architecture, il est essentiel de garder en tête que le choix doit se faire en contexte. Ces styles viennent en effet avec leurs qualités et leurs limites. La pondération des avantages et des inconvénients ne peut faire l’économie d’une mise en perspective avec le contexte du projet. Le diagramme de décision suivant synthétise les questions à se poser pour choisir le style le mieux adapté à votre contexte.
Cet arbre de décision reste une illustration des critères à considérer pour choisir son style d’architecture ; d’autres critères peuvent être intégrés à la prise de décision et des variantes des styles d’architecture (comme le MVC est une variante des architectures en couches adaptée aux IHM) peuvent être appliquées en conséquence. Il reste également important de rappeler pour conclure cet article que les styles abordés ici sont ceux des constituants d’un système plus vaste dont la structure doit elle-même être définie. Certainement le sujet d’un prochain article.