Micro-frontend : un exemple d'implémentation

Introduction

La démarche Micro-Frontend suit son ascension. De nombreuses entreprises tentent de modulariser et découpler les parties de leurs interfaces utilisateurs. Après avoir expliqué la démarche en général, allons plus loin en réalisant ce découpage sur un cas concret : de l’observation des symptômes, à l’implémentation de la solution la plus répandue aujourd’hui.

Pour entrer directement dans le vif du sujet, mettons-nous en situation. Commençons par discuter de Micro-Assu, une entreprise très semblable aux licornes du numérique de nos jours. Le domaine de l’assurance est extrêmement compétitif et réglementé. Il implique des métiers très différents : actuaires, marketing, juridique, banque, réassureur et commerciaux. Mais Micro-Assu se distingue de ses concurrents grâce à son application web qui leur permet de fournir des devis en un temps record à ses prospects. Ses dirigeants la voient comme un levier très fort pour se distinguer sur le marché et veulent continuer à accélérer son développement.

Avec cette ambition, voyons de plus près, ce qui se passe dans leur équipe de développement frontend.

Micro-Assu et son monolithe, sa stack et ses problématiques

Sa stack

Micro-Assu expose ses services sur cette application web.

On peut distinguer deux grandes parties dans l’application : les pages concernant l’acquisition de nouveaux clients (la page d’accueil et le formulaire de création de projet), et les pages de suivi (qui permettent le suivi d’un projet jusqu’à son aboutissement, puis de la rentabilité d’un produit d’assurance). Ces deux parties impliquent des domaines métiers de l’assurance différents et indépendants.

L’application web utilise des technologies répandues, comme le framework Next.js. Il utilise le design system Carbon de IBM, teste les parcours utilisateurs avec playwright et déploie sur gitlab-pages avec l’aide de gitlab-ci. Concernant son architecture, elle s’inspire de l’architecture JAMStack. La plupart des pages sont construites au moment du déploiement et déployées sur un CDN.

Dans des stacks et des contextes similaires, nous observons que les équipes grandissent à vive allure. On remarque également un ralentissement de la vélocité de l'équipe lorsque sa taille atteint environ 12 personnes. Les problématiques que l’on observe sont les suivantes.

Couplage

Le couplage fait référence à la manière dont différentes parties d'un système dépendent les unes des autres. Plus le couplage est fort, plus les éléments du système sont interconnectés, et dépendent les uns des autres pour fonctionner correctement.

Le code de notre application est dans une seule base de code couplée. Pourquoi en arrive-t-on là ? En général, ces organisations ont sorti des fonctionnalités très rapidement pour satisfaire un marché ou prendre des feedbacks utilisateurs. Mais cette rapidité s’est faite au détriment de la qualité du code et du couplage entre ses composants.

Cela ne concerne pas seulement les composants internes, mais aussi les dépendances externes. Par exemple, si votre application utilise Vue.js comme framework principal, et que celle-ci est fortement couplée à la version actuelle de Vue, la migration vers une nouvelle version sera fatalement aussi conséquente que ce framework est utilisé. Ce type de couplage à des librairies externes rend chaque mise à jour de la librairie plus difficile.

Synchronization

L’état de l’art a du mal à évoluer. Les points techniques de l’équipe et la prise de décision sont de plus en plus difficiles à mener. À 12 personnes, les tech lead ont du mal à trancher face à la diversité des sujets.

De plus, l’équipe prend plusieurs sujets en même temps, et 12 personnes essaient d’avancer tous les sujets. Le product owner se plaint de devoir réexpliquer à plusieurs personnes les sujets qui passent de main en main.

De même, du côté des développeurs, ceux-ci se plaignent de devoir passer de sujet en sujet toujours plus complexe, mais aussi de devoir naviguer entre des parties très différentes de l'application en matière d’état de l’art. Il est très différent de créer une nouvelle fonctionnalité, ou bien d’apporter une modification sur une fonctionnalité faite il y a deux ans.

Charge cognitive et onboarding

Les nouveaux arrivants dans l'équipe rencontrent des difficultés pour se familiariser avec la base de code qui grandit toujours plus, ainsi qu’aux librairies dont celle-ci dépend. Comprendre l'ensemble de l'application et son domaine métier est une tâche toujours plus compliquée. En conséquence, la charge cognitive croissante détériore la capacité de l’équipe à apporter des modifications rapidement. Cela ralentit le processus d'onboarding.

Déploiement

Lors de déploiement de l’application, toute la pipeline sur l’intégralité du code front doit s'exécuter (build, lint, test et déploiement). Fatalement, plus du code s’ajoute au logiciel, plus sa pipeline de déploiement sera longue.

Autre souci, pour déployer une version de l’application, tous les sujets en cours doivent être terminés. Même si certains développements sont prêts, ils doivent attendre que toutes les autres tâches soient bouclées, ce qui rallonge souvent le délai de mise en production.

Pour répondre à tous ces symptômes, nous nous proposons d’explorer une solution basée sur un découpage en micro-frontends. Ce découpage nous permettra d'atteindre plusieurs objectifs:

  • Réduire la charge cognitive des développeurs
  • Découpler les parties du code de l’interface qui devraient être indépendantes
  • Déployer des parties de l’application indépendamment

On code

Découper en plusieurs modules

En principe, le framework Next.js oblige ses utilisateurs à créer un dossier pages qui gérera le routing. Le framework ne donne aucune autre contrainte. Nous voyons régulièrement des projets où tout le reste est mis sous le dossier components ou app.

Nous avons des projets qui sont architecturés comme ceci.

On a cette arborescence :

Arborescence

Pour commencer, nous allons nous concentrer sur le découpage du dossier en deux parties représentants les deux domaines métiers indépendants au sein de Micro-Assu : "acquisition" et "suivi". À l’intérieur de ces dossiers, peu importe l’organisation, tant que chaque dossier correspond à un domaine métier bien distinct et ne comporte pas d’import de l’autre. On se base sur les tests e2e pour garantir que rien ne casse après ces changements.

Lors de cette division, si l’application n’a pas de design system, il peut s’avérer que les deux dossiers doivent se partager certains composants graphiques. Dans notre exemple, nous utilisons un design system, donc la problématique ne se pose pas. Dans d’autres contextes, l’absence de design system peut contraindre à créer une librairie de composants graphiques.

On arrive à cette arborescence, visible à partir de ce commit :

Arborescence

Pour garantir cette indépendance, la bibliothèque TS-Arch peut-être utilisée afin de vérifier que les deux modules sont bien indépendants.

À partir d’ici, nous avons déjà résolu les problèmes liés à l’onboarding et à la charge cognitive, car on a isolé les composants qui traitent d’un domaine métier dans un dossier spécifique. Pour s’intéresser à ce domaine métier, seul le code à l’intérieur de ce dossier sera important à comprendre.

La prochaine étape est de trouver une solution pour déployer les différentes parties de l’application de manière indépendante.

Module Federation

Pour déployer les différentes parties de l’application de manière indépendante, il est essentiel de penser à une architecture qui permette de maintenir cette séparation, tout en facilitant les déploiements. C'est là qu'intervient Module Federation.

Module Fédération est une fonctionnalité introduite par Webpack 5 qui permet aux applications de charger des modules au runtime depuis des applications distantes. Cette approche facilite le partage de code entre différentes applications, permettant ainsi une intégration flexible.

Quelles sont les motivations qui l’ont amené ?

Dans de gros projets, des parties de code pourraient être utilisées comme des modules indépendants, du fait qu’il n’aient pas la même temporalité d’évolution. Module Fédération est venu simplifier ce processus de découpage et d’assemblage au runtime entre ces différentes parties de code. Module Fédération permet aux équipes de travailler sur des parties de l’application différentes avec un cycle de déploiement indépendant, tout en assurant que tout s’assemble en runtime.

Concrètement, Module Fédération propose de découper une application en module. Un module est un ensemble de composants, classes, ou fonctions regroupées pour accomplir une tâche spécifique. Voici à quoi pourrait ressembler les différents modules de Micro-Assu :

Module Federation

On retrouve trois types de modules :

  • Un module host est un module qui charge des modules externes ou "remotes". Dans notre cas, l'app-shell est le module host. Il est responsable de récupérer et d'afficher les fonctionnalités provenant des autres modules, comme acquisition et suivi.
  • Un module remote est un module qui expose des composants ou des fonctionnalités qu’un module host peut utiliser. Dans notre architecture, les modules acquisition et suivi sont des remotes. Ils fournissent des fonctionnalités particulières, comme le formulaire de création des projets ou des composants avec des graphes pour faire le suivi de nos projets. L'app-shell peut charger ces modules au runtime.
  • Les shared modules sont des librairies ou dépendances partagées entre plusieurs modules "remote", permettant de charger une seule fois le code de librairies communes. Par exemple, React et React DOM pourraient être considérés comme des shared modules du fait qu'ils soient utilisés à la fois par l'app-shell, l'acquisition, et le suivi.

En bref, Module federation permet de découpler les cycles de déploiement de différents modules d'une application.

Découper en plusieurs packages et repo

L'objectif ici est de séparer le code en différents modules, qui auront chacun leur propre repository. Cela permet de mieux organiser le code, de faciliter la maintenance, et d'offrir la possibilité de déployer ces modules indépendamment.

Pour découper un projet en plusieurs packages et repositories, voici les étapes détaillées que vous pouvez suivre.

Repo et git-submodules

Git-submodules permet de gérer des repositories comme des sous-projets au sein d'un projet principal.

Déplacer les modules "suivi" et "acquisition" hors du dossier src

Commençons par déplacer les dossiers suivi et acquisition du dossier src vers l'extérieur, pour en faire des packages indépendants.

mv src/suivi ../suivi
mkdir src/pages

Chaque module doit toujours contenir un dossier pages. Ce dossier pages est essentiel pour que Next.js puisse gérer les routes associées à chaque module.

Créer un dossier app-shell:

Ensuite, crée un dossier app-shell.

mkdir app-shell
mv src/* app-shell/

Le dossier app-shell contiendra maintenant tout le reste de l'application, à l'exception des modules suivi et acquisition.

Initialiser des nouveaux repo git pour suivi et acquisition et app-shell

Créons le nouveau repository git qui aura le code du module suivi (pour les repo app-shell et acquisition, ce seront les mêmes étapes). Préalablement, il faut créer les dépôts Git qui accueilleront les trois nouveaux modules.

cd ../suivi
git init
git remote add suivi <url repo suivi>

git checkout -b main // si la branche main est absente

git add . 
git commit -m “Initial commit for suivi module”
git push -f suivi main

rm -rf ../suivi

cd ..
git add .
git commit -m “Remove local suivi directory, use suivi submodules instead”

git submodule add <url dépôt suivi> suivi

On se retrouve avec l’arborescence suivante au niveau de notre repo :

Arborescence

Arborescence

Arborescence

Maintenant, chacun de nos modules sont des applications autonomes.

Configurer Module Federation

Avec Module Fédération, l'objectif est de rendre accessibles les composants de suivi et acquisition dans app-shell, tout en permettant de les déployer indépendamment. Dans app-shell, nous configurons Module Fédération pour charger les modules remotes suivi et acquisition.

import { NextFederationPlugin } from '@module-federation/nextjs-mf';

export default {
	output: 'export',
	webpack: (config, options) => {
    	config.plugins.push(
        	new NextFederationPlugin({
            	name: 'app-shell',
            	filename: 'static/runtime/remoteEntry.js',
            	exposes: {},
            	remotes: {
                	'acquisition': 'acquisition@https://gitlab.octo.tools:15095/micro-frontend-article-acquisition-tous-les-octo-f67fe2f398279a/_next/static/runtime/remoteEntry.js',
                	'suivi': 'suivi@https://gitlab.octo.tools:15095/micro-frontend-article-suivi-tous-les-octos-atel-813b8855e51590/_next/static/runtime/remoteEntry.js'
            	}
        	})
    	);
    	return config;
	},
};

app-shell/next.config.mjs

Ici, app-shell est configuré pour charger les modules remotes suivi et acquisition, via les URLs des fichiers remoteEntry.js. Cela permet à app-shell de consommer ces modules comme s'ils faisaient partie de l'application.

Pour bien comprendre la configuration de module federation:

  • name: ce champ définit le nom du module. Ce nom est utilisé pour identifier ce bundle dans le système de fédération, permettant à d'autres modules de le référencer s'ils en ont besoin.
  • filename: ce champ spécifie le nom du fichier qui sera généré pour exposer ce module. Ce fichier, remoteEntry.js, est essentiel car il contient les métadonnées nécessaires pour que d'autres modules puissent charger les composants ou fonctionnalités de ce module à distance.
  • exposes: cette section est utilisée pour déclarer quels modules ou composants du module seront exposés pour être utilisés par d'autres modules remotes. On peut observer sur app-shell qu'il n’en expose pas.
  • remotes: ce champ indique les modules distants que app-shell peut charger.Dans l’exemple de configuration de app-shell, on a deux modules distants définis : suivi et acquisition. Chaque clé dans l'objet remotes (ici, 'acquisition' et 'suivi') représente le nom du module remote. La valeur associée est une URL qui pointe vers le fichier remoteEntry.js de ce module, permettant à app-shell de charger ses composants ou fonctionnalités dynamiquement.
  • output: ce champ spécifie le mode de sortie du build Next.js. Dans notre cas, 'export' indique que le projet est configuré pour un export statique, ce qui signifie que le site sera exporté sous forme de fichiers HTML statiques.

Pour rendre les composants de acquisition disponibles pour app-shell, nous devons les exposer via Module Federation :

import { NextFederationPlugin } from '@module-federation/nextjs-mf';

export default  {
	output: 'export',
	webpack: (config, options) => {
    	config.plugins.push(
        	new NextFederationPlugin({
            	name: 'acquisition',
            	filename: 'static/runtime/remoteEntry.js',
            	exposes: {
                	'./CreationProjet': './src/components/formulaire-creation-projet/index.tsx',
                	'./PresentationValeur': './src/components/presentation-valeur/index.tsx'
            	}
        	})
    	);
    	return config;
	}
};

acquisition/next.config.mjs

Dans notre configuration de module acquisition, les composants CreationProjet et PresentationValeur sont exposés pour être utilisés dans app-shell.

Si vous voulez voir le projet en entier, allez voir : le commit step 3

Conséquences sur le delivery

La mise en place de cette architecture avec Module Fédération modifie la manière dont la CI gère les builds, les déploiements, mais aussi le testing.

Pipelines

Chaque module est construit et testé de manière indépendante, ce qui simplifie et accélère les processus de build. Les pipelines dédiés à chaque module sont généralement plus rapides car ils traitent des unités de code plus petites, ce qui permet de détecter et résoudre les problèmes plus rapidement.

De même concernant les tests unitaires et d’intégration dans le module. Ils deviennent plus efficaces car chaque module peut être testé indépendamment des autres. Les équipes peuvent ainsi se concentrer sur des modules spécifiques sans avoir à tester l'ensemble de l'application à chaque modification, ce qui accélère le cycle de développement.

Déploiements

Chaque module ayant son propre pipeline, il devient possible de déployer des parties de l'application indépendamment, ce qui réduit les risques associés aux déploiements groupés. Chaque module peut être déployé dès qu'il est prêt, sans attendre d'autres modules. Cela rend le processus de déploiement plus flexible.

Testing e2e

Une réflexion sur les tests end-to-end doit être menée lors du découpage en micro-frontend. Comment vérifier que malgré ce découpage, un parcours utilisateur impliquant plusieurs domaines métier est toujours fonctionnel ? Ceci est indispensable pour prendre la décision de pousser en production.

Dans notre cas, nous avons gardé les tests playwright au niveau le plus haut du projet. À chaque fois que nous poussons une nouvelle version de notre submodule, la pipeline de test-e2e qui teste le workflow de l’application est lancée. Cela garantit qu’à chaque modification, l’application mise entre les mains des utilisateurs fonctionne.

Conclusion

Dans cet article, nous vous avons exposé le processus de conversion d'une application monolithique Next.js en une architecture de micro-frontends, permettant un déploiement indépendant de chaque composant.

Ce processus s’est fait en trois étapes. Dans un premier temps nous avons mis en lumière les problématiques de delivery ressentis par l'équipe. Cette démarche de découpage est utile uniquement si des douleurs similaires sont ressentis par votre équipe. Dans un deuxième temps nous avons fait émerger deux modules indépendants dans notre application monolithique afin de diminuer la charge cognitive nécessaire pour développer dessus. Et dans un troisième temps, nous avons rendu le déploiement de ces modules indépendants grâce à module fédération.

Enfin, on a discuté des conséquences de cette nouvelle architecture sur les pipelines de déploiement et le testing.