Dev Life
Analyse approfondie de la refonte de l’application Mailgun : Partie 1 – Excellentes idées
Salutations, chers développeurs. Nous sommes ravis d’annoncer qu’après trois longues années de planification, de conception et de construction, notre équipe a publié notre fantastique mise à jour de l’application. Certes, nous avons travaillé sans relâche pour mettre à jour l’apparence et l’ergonomie de notre interface utilisateur, mais nous avons effectué une refonte encore plus audacieuse de notre pile technologique fondamentale.
Comment cela se traduit-il pour vous ? De meilleures performances, une meilleure interface, et évolutive pour la croissance future. Nous remontons le temps pour vous présenter le rapport complet sur la façon dont nous y sommes parvenus. Rejoignez-nous pour l’aventure.
Pourquoi nous nous sommes lancés dans cette aventure
Mais attendez ! Avant de remonter le temps, il est important de présenter notre application améliorée. « Améliorée » est un euphémisme. Cela peut vous surprendre d’apprendre que ce lifting de l’interface utilisateur et des performances n’était pas un moteur de revenus. Alors, pourquoi l’avons-nous fait ?
Deux raisons :
- L’expérience utilisateur, telle qu’elle existait, freinait les performances des clients envoyant de plus gros volumes d’emails et rendait difficile la navigation entre les produits de notre portefeuille en pleine croissance. Si ce n’est pas facile à utiliser, alors vous ne l’utiliserez pas.
- Nous devions réévaluer notre flux de données API principal pour qu’il soit davantage axé sur les performances et plus évolutif.
Ce que nous avons créé est Portail, une interface épique pour mieux vous connecter à vos produits et outils de délivrabilité dès maintenant, et à mesure que nous évoluons. Portail vous permet de naviguer entre Mailgun et nos Mailgun Optimize produits avec facilité et offre une expérience très performante à nos utilisateurs avancés qui envoient des dizaines de milliers d’emails par jour.
Alors, restez avec nous pendant que nous jouons les geeks, nous concentrons et parcourons les trois dernières années de notre processus de développement.

Ce que nous voulions construire
Nous avons commencé par créer une liste exhaustive de toutes les différentes choses que nous voulions – cela allait de la mise à jour de la structure de notre référentiel à la mise en œuvre d’un nouveau système de déploiement, et nous savions que nous allions devoir le créer de toutes pièces, notre équipe SRE migrant d’AWS vers Google Cloud. Nous voulions également nous éloigner de notre conception monolithique étroitement couplée pour aller vers une conception plus distribuée. Cela faciliterait tout pour l’équipe à l’avenir : du développement de tests exhaustifs au ciblage de reconstructions sans avoir besoin de se plonger dans tout le code ou de le modifier.
Lors de toute refonte, nous voulons toujours prendre en compte l’utilisateur, mais une grande partie de la motivation derrière la mise à jour de notre application était également d’améliorer l’expérience développeur. Notre objectif était d’améliorer l’expérience développeur en réduisant considérablement la complexité de l’obtention des données de l’API vers la page rendue, et d’améliorer la maintenabilité en réduisant le nombre total de modèles dans notre boîte à outils.
Historique : Notre pile front-end d’origine
Les piles technologiques peuvent devenir obsolètes, et notre pile front-end d’origine a été construite il y a près d’une décennie – ce qui correspond à 350 années internet. Elle comprenait un serveur web Python Flask qui gérait probablement plus de tâches qu’il n’aurait dû et dont l’état était très étroitement couplé et partagé avec le client. Elle utilisait plusieurs couches d’abstraction, initialement prévues pour faciliter l’obtention de données à partir de nos API.
À mesure que cette surface devenait de plus en plus grande, les modèles apportaient plus de surcharge que de commodité. Il est devenu très difficile d’apporter des modifications fondamentales car de nombreux endroits pouvaient être impactés. À ce stade, notre client front-end était devenu un musée de modèles React obsolètes.
Toutes nos interactions avec nos propres API publiques et privées étaient obscurcies par de multiples couches de complexités de code excessives. Cela a créé un gouffre géant entre les développeurs front-end de Mailgun et leur compréhension de nos API et de nos expériences client.
Le bricolage des abstractions d’API a non seulement entraîné une complexité excessive, mais a considérablement inhibé notre capacité à faire évoluer notre application. Nous savions que nous voulions à terme intégrer de plus en plus de produits dans la base de code. Nous devions repenser totalement la façon dont nous consommions nos données.
Lorsque nous planifiions le nouveau framework, nous savions que nous voulions pouvoir effectuer des requêtes API directement du client vers les API publiques avec le moins de middleware possible. Pourquoi ? De cette façon, nous pourrions comprendre et consommer nos API de la même manière que nos clients le font en « mangeant notre propre nourriture pour chien » comme on dit. Je ne sais pas pour vous, mais je préfère manger un steak que de la nourriture pour chien.
Le problème de la gestion des états à l’échelle de l’application
Un autre changement important que nous voulions apporter était de remplacer Redux, qui gérait l’état de notre application (environ 150 états). Chaque état correspondait à un appel réseau, ou à un réseau de transformations de données, et notre structure existante n’établissait pas vraiment de directives sur la façon de l’utiliser. Le résultat ? Beaucoup de redondances.
Lorsqu’un développeur introduisait un appel pour manipuler des données, il devait s’intégrer à travers trois niveaux d’abstraction. De plus, nous avions nos propres clients personnalisés pour notre navigateur et notre serveur Flask (qui s’interfaçaient avec toutes nos API). Ainsi, pour qu’un développeur ajoute une nouvelle fonctionnalité, il devait traverser environ sept niveaux d’abstraction au total, et des bugs sont possibles à n’importe quel niveau.
Ce que nous avons finalement découvert, c’est que 95 % de l’état de notre application n’était qu’un cache réseau glorifié. Sauf que nous ne tirions même pas parti des données en cache, faisant souvent des appels redondants pour demander des données que nous avions déjà, provoquant des couches imbriquées de rendus inutiles dans nos composants de vue. Voilà qui s’appelle un parcours chaotique.
Cette décomposition du système explique les choses un peu plus clairement, et bien que nous ne dirions jamais de mal de nos origines… une image facétieuse vaut mille mots.
Système actuel
Notre équipe est le genre d’équipe qui s’appuie sur elle-même et itère, mais pour cette refonte de l’application, nous avons dû revenir en arrière et retravailler certains composants fondamentaux de notre infrastructure – en commençant par le contrôle du code source.
Limitations du polyrepo
Un polyrepo est un référentiel qui contient plusieurs projets. Nous avons utilisé un style d’organisation polyrepo, ce qui signifie simplement que chaque projet front-end avait son propre référentiel, même si plusieurs projets répétaient les mêmes tâches et reproduisaient les mêmes fonctionnalités. En tant que hub central, les polyrepos peuvent devenir un défi à maintenir à mesure que le nombre de projets stockés augmente. Dans de nombreux cas, nous devions déployer chaque polyrepo simultanément avec les mêmes mises à jour pour garantir une expérience cohérente sur l’ensemble de nos produits.
D’un point de vue de la maintenance, cela a rendu difficile pour les équipes de localiser et de travailler avec le code dont elles ont besoin. Gérer plusieurs référentiels avec des fonctionnalités redondantes signifie généralement qu’ils sont plus lents à répondre et plus gourmands en ressources, ce qui est un gros problème si vous poussez et tirez fréquemment du code. Parce que les fonctionnalités peuvent être distribuées sur plusieurs bases de code, les polyrepos peuvent rendre la recherche de bugs plus difficile que de trouver la proverbiale aiguille dans une botte de foin.
Avec notre structure polyrepo d’avant la refonte, nous savions que l’avenir serait une réalité où nous devrions fouiller dans les référentiels avec une lampe de poche pour chercher ce dont nous avions besoin, et qu’il serait difficile de collaborer, d’évoluer et de partager du code à mesure de notre croissance.
Voici nos principales préoccupations :
- Co-développement difficile
- Couplage étroit
- Gestion de l’évolutivité, de la disponibilité et des performances
- Difficulté à partager du code
- Duplication
- Outillage incohérent
En ce qui concerne la gestion du code source, il existe de nombreuses solutions, mais nous avons décidé qu’une structure modulaire et monorepo soulagerait la majorité de nos difficultés de développement à l’avenir.
Projets pour un tout nouveau référentiel : une approche modulaire
Une approche monorepo implique de stocker tout le code du projet dans un seul et grand référentiel. Cela résoudrait les problèmes de partage de code et de duplication que nous avons rencontrés avec notre organisation polyrepo, mais les monorepos ne sont pas une solution miracle. Si le code est étroitement couplé, les monorepos doivent également être conçus avec modularité.
Une approche modulaire consiste à diviser un vaste projet en modules plus petits et indépendants qui peuvent être développés, testés et maintenus séparément. Cela permet une plus grande flexibilité et réutilisabilité, ainsi que la capacité de mettre à jour facilement des modules individuels sans affecter le reste du projet.
Nous savions qu’une solution modulaire de type monorepo nous permettrait d’être plus efficaces avec :
- Génération de code
- Partage de code
- Exécution et orchestration de tâches distribuées
- Mise en cache
- TypeScript
Se marcher sur les pieds : La danse du déploiement
Indéniablement, le plus grand défi de vivre dans un monorepo (monolithique ou autre) est que plus il y a de contributeurs, plus le risque de se marcher sur les pieds est grand. À quoi sert une expérience développeur remaniée si votre code est écrasé lors d’une mauvaise fusion ? Si nous procédons vraiment à une refonte holistique de notre pile, alors nous devons également examiner comment nous pouvons améliorer notre processus de déploiement. Voici les défis les plus urgents :
- Évolutivité : Ce serait une mauvaise idée de reconstruire TOUT le code du monorepo pour les plus petits changements, disons, corriger une faute de frappe dans un texte. C’est exactement ce que nous faisions dans notre ancien système. Le nouveau processus tirerait parti des capacités intégrées de NX pour évaluer et reconstruire uniquement les fichiers affectés par la modification, au niveau le plus granulaire.
- Déploiement : Nous avons conservé la plupart des éléments de notre pipeline de déploiement. Une fois l’image construite, elle atterrit dans le référentiel de conteneurs GitHub. Notre équipe SRE a peaufiné notre système de déploiement lors de la migration d’AWS vers GCP. Nous pouvons facilement déployer n’importe quelle branche depuis notre canal de déploiement Slack, avec de meilleurs rapports grâce à un ajout récent que nous avons obtenu pendant la migration.
- CI/CD : Notre ancien système utilisait une très ancienne version de Jenkins pour exécuter les tests et construire/distribuer les images de notre application derrière un VPN. Lorsque le build échouait, nous avions besoin d’un développeur senior haut placé, familiarisé avec les anciennes méthodes, pour le corriger. Dans notre nouveau système, nous avons prévu de passer à GitHub Actions, où les configurations sont accessibles, déclaratives et bien documentées.
Défis absurdes et solutions excellentes
Les projets de toutes sortes comportent des défis, certains attendus et d’autres surprenants. Nous savions que nous devions planifier pour surmonter certains problèmes et que la nouvelle version de l’application devait se concentrer sur les performances sous de multiples angles.
| Problèmes identifiés | Solutions proposées |
|---|---|
| Conventions de code peu claires Les conventions de code font référence à l’ensemble des styles de codage, des solutions et des modèles réutilisables au sein d’une base de code. Imaginez une équipe de charpentiers travaillant sur un projet. S’ils gardent 1 000 outils spécialisés sur le chantier, il est probable qu’un travailleur choisisse le mauvais outil pour la tâche ou l’utilise de manière inappropriée. Et contrairement aux outils physiques, les dépendances logicielles peuvent devenir obsolètes, empêchant ainsi les futures mises à jour de la base de code. | Guide de style de code Le maintien de guides de style de codage explicites et d’un ensemble minimal de modèles simples et puissants aide à réduire la redondance, les incohérences et la complexité, et peut même entraîner une amélioration des performances. La réutilisation d’un petit ensemble de solutions et de modèles généralisés rend le code plus facile à comprendre et réduit la probabilité de bugs difficiles à trouver. |
| État surchargé Nous avons utilisé Redux comme solution universelle pour l’état global de l’application. Cependant, 90 % de notre store Redux n’était qu’une simple mise en cache réseau glorifiée. Redux nécessite de multiples couches de surcharge pour chaque membre de l’état. Cela a entraîné de nombreuses redondances et une complexité inutile. | Séparer le cache réseau et l’état de l’application La séparation des fonctionnalités du cache réseau de l’état de l’application nous permet de conserver une empreinte d’état beaucoup plus petite. L’état peut être mis en œuvre de manière plus locale. La réduction de la surcharge se traduit par une augmentation des performances et donne aux développeurs plus de contrôle sur la façon dont les données sont utilisées. Nous avons choisi React Query comme interface avec nos API backend, ce qui nous permet de gérer facilement les données de manière asynchrone et de réduire la surcharge réseau. |
| Architecture réseau multicouche Notre application s’est développée de manière organique, passant d’un panneau de contrôle de base centré sur l’API à la suite multi-produits d’outils client que nous avons aujourd’hui. Initialement, le client était construit sur un outillage Python commun partagé par plusieurs de nos backends d’API. Cela conduit à avoir de nombreuses couches de middleware nécessaires pour le moindre appel API, même le plus simple. Non seulement cela rendait difficile la compréhension de l’origine d’une donnée donnée, mais cela rendait également l’identification des bugs dans la pile réseau très chronophage. | L’API publique d’abord Plutôt que de faire passer les requêtes via de multiples proxys, couches d’authentification et interfaces Python, nous avons décidé très tôt que, dans tous les cas possibles, nous collecterions les données nécessaires directement depuis le client en utilisant les API publiquement disponibles. Cela a non seulement considérablement réduit nos frais généraux et notre complexité, mais cela nous a également aidés à mieux percevoir nos services API du point de vue de nos clients. Nous avons l’intention de pousser cette initiative plus loin dans un avenir proche, en rendant davantage de nos API publiques. |
Ne pas jeter le bébé avec l’eau du bain
Nous avons écrit des centaines de milliers de lignes de code au cours de la dernière décennie. Avec l’ampleur et la portée de ce nouveau projet, il faudrait presque autant d’années pour porter et réécrire nos anciennes fonctionnalités dans le nouveau paradigme. Ce serait un échec d’emblée. Nous devions trouver un moyen de continuer à intégrer ces fonctionnalités existantes sans compromettre nos objectifs pour la nouvelle pile. Nous avons trouvé une solution dans une technologie appelée fédération de modules (module federation).
Grâce au plugin de fédération de modules de Webpack, nous avons pu créer un nouveau build de notre ancienne application avec des modifications minimes, ce qui permettrait d’intégrer les fonctionnalités existantes dans la nouvelle pile Portail. Nous aurions toujours pour objectif à long terme de porter le code désormais hérité vers des normes plus récentes, mais cela nous permettrait de commencer à écrire immédiatement de nouvelles fonctionnalités avec les nouveaux avantages.
Nous avons un plan, mais pouvons-nous le concrétiser ?
Nous savions que consolider nos plateformes tout en développant continuellement les fonctionnalités et les ensembles de produits nécessiterait un effort massif, et notre planification a influencé ce que nous voulions pour notre nouvelle pile front-end, ainsi que la manière dont nous allions gérer l’héritage persistant des systèmes de style CSS obsolètes, des modèles de composants de classe dépréciés et d’une gestion maladroite de l’état de l’application.
Parce que nous repartions de zéro – en particulier avec le passage d’AWS à Google Cloud – la meilleure solution était une rupture nette. Un nouveau référentiel avec de meilleures normes plus élevées, une fédération de modules, des bibliothèques modulaires et partagées, et une documentation interne et orientée utilisateur plus complète.

Ce fut une étape de préparation et de planification intense. Comme pour tous les grands projets, la génération des idées et de la preuve de concept a été une entreprise technique et créative qui n’a impliqué que quelques personnes sélectionnées.
Phase 2 ? Transformer l’idée en réalité – et cela a impliqué beaucoup plus d’équipes. Développeurs, je crois que notre aventure est sur le point de prendre une tournure des plus intéressantes.
Comment notre framework hérité a freiné notre évolutivité