FARFETCH — moderniser une application e-commerce en production
Problème
FARFETCH est une marketplace de mode de luxe, et son application Android reçoit des commandes de clients du monde entier. En 2021, la base de code charriait des années de décisions accumulées : du MVC dans les écrans les plus anciens, du MVVM dans les plus récents, des dépendances câblées à la main d'un bout à l'autre. Rien de tout cela n'était faux au moment de son écriture, et tout cela rendait le changement suivant plus lent que le précédent.
Le pipeline d'intégration continue prenait quinze minutes par exécution. Quinze minutes, c'est au-delà du seuil où les ingénieurs cessent de le lancer et se mettent à grouper leurs changements — et c'est ainsi que les problèmes d'intégration se découvrent tard, et se paient cher.
Contrainte
Rien ne pouvait s'arrêter. L'application était en production et générait du chiffre d'affaires, sur un train de livraison qui ne s'interrompait pas pour des travaux d'architecture. Chaque changement devait arriver de façon incrémentale, et les anciens comme les nouveaux patterns devaient cohabiter dans la même base de code aussi longtemps que durerait la migration — c'est-à-dire des années, pas des sprints.
Décisions
- Jetpack Compose adopté écran par écran, derrière la navigation existante. Les nouveaux écrans étaient écrits en Compose ; les anciens migraient lorsqu'on les ouvrait de toute façon pour d'autres raisons. Pas de sprint de migration, pas de gel des fonctionnalités, pas de branche qui vit six mois.
- Inversion de contrôle via l'injection de dépendances. C'est le changement qui a rendu tout le reste possible. Séparer la construction du comportement est ce qui a permis à un écran de migrer seul, sans traîner ses dépendances derrière lui.
- La modularisation d'abord, le cache ensuite — dans cet ordre. C'est le découpage du monolithe en modules Gradle qui a rendu possible la mise en cache du build, tout court. Avec un module unique, presque n'importe quel changement invalide la totalité du build, et un cache n'apporte alors presque rien ; une fois les frontières posées, un changement ne touche que quelques modules et le reste est servi depuis le cache.
- Ensuite les deux caches Gradle, contre deux coûts distincts. Le build cache réutilise les sorties de tâches d'un build à l'autre et d'une machine à l'autre : la CI cesse de recalculer ce qu'une autre exécution a déjà produit. Le configuration cache retire la phase de configuration des exécutions suivantes — le prix fixe que payait auparavant chaque build, même lorsque presque rien n'avait changé.
Résultat
Le temps de build du pipeline est passé de 15 minutes à 2,5 minutes en moyenne — de nouveau sous le seuil où les ingénieurs le lancent à chaque changement plutôt que par lots.
Compose et l'injection de dépendances sont devenus la norme pour tout nouveau développement, et non des expérimentations menées en parallèle. C'est le seul résultat durable qu'une migration incrémentale puisse produire : la nouvelle manière de faire doit être la plus facile, sinon l'ancienne l'emporte par inertie.
Ce que je ferais autrement
Je modulariserais avant de migrer, et non en même temps. Nous avons mené les deux de front, alors que l'argument que j'avance pour le build cache vaut aussi pour la migration : ce sont les frontières entre modules qui rendent un changement incrémental peu coûteux. Sans elles, chaque écran passé à Compose traînait encore tout le graphe de dépendances derrière lui, et la migration a payé un surcoût qui aurait pu être évité. Le travail sur le pipeline l'a démontré — je l'ai simplement démontré dans le mauvais ordre.