Une classe de 4 854 lignes en production : moderniser un monolithe PHP sans tout réécrire

osTicket : 4 854 lignes dans une seule classe. Comment le Strangler Fig modernise un monolithe PHP en production, sans réécriture big-bang. Cas réel, chiffres réels.

Il y a, dans beaucoup de vieux projets PHP, un fichier qu'on n'ouvre plus qu'en apnée. Une classe centrale, des milliers de lignes, branchée sur à peu près tout. Elle tourne en prod depuis des années, elle marche, et c'est précisément le problème : on ne peut pas l'arrêter pour la refaire, et on n'ose plus rien empiler dessus de peur de tout faire tomber. L'impasse.

Le réflexe qui vient, c'est le grand soir. On réécrit tout, proprement, à côté, et le jour J on bascule. C'est net dans la tête. C'est aussi le pari le plus risqué qui soit.

Il existe une autre voie. On ne rase pas le monolithe, on l'étrangle. Pièce par pièce, en production, sans jamais rien arrêter. Ça porte un nom, emprunté à un arbre tropical : le Strangler Fig. On va voir à quoi ça ressemble sur du concret. Pas un exemple inventé pour la démo, mais du code public que vous pouvez ouvrir vous-même pendant qu'on en parle.

Le monolithe sur la table

Pour parler de concret, j'ai pris un projet open-source que beaucoup connaissent : osTicket. Un système de gestion de tickets, écrit en PHP, encore maintenu, déployé par des milliers de structures depuis une quinzaine d'années. Je ne l'ai pas choisi pour taper dessus. C'est même l'inverse : c'est exactement ce que produit un logiciel qui a du succès et qui dure. On livre, on corrige, on rajoute, pendant quinze ans. La masse legacy, c'est le sédiment du succès.

J'ai cloné le dépôt fin juin. Voici ce que la pince à mesurer renvoie, et vous pouvez vérifier chaque chiffre sur GitHub :

  • class.ticket.php : 4 854 lignes, 201 méthodes, dans une seule classe.
  • class.forms.php : 6 228 lignes, le moteur de formulaires maison, encore plus gros.
  • class.orm.php : 3 662 lignes, un ORM maison baptisé VerySimpleModel.
  • Zéro namespace dans include/ : tout vit dans l'espace global, chargé à coups de require.
  • Du SQL brut planté au milieu de la logique. Une méthode de modèle finit littéralement par return db_fetch_array(db_query($sql));.

Le point important, ce n'est pas la taille. C'est que ce n'est pas du travail bâclé. Il y a un ORM maison, un moteur de formulaires maison : quelqu'un a pris le temps d'abstraire. Tout est juste home-grown et entremêlé, tissé ensemble couche après couche sur quinze ans. Et c'est ça, le piège du monolithe pur : il n'y a pas de framework à remplacer. Pas de Laravel à faire monter de version, pas de Symfony à migrer. Le legacy, ici, c'est la maison elle-même.

Détail qui en dit long : sur leur propre forum, les mainteneurs d'osTicket décrivent cette classe comme la plus importante du projet, avec un avertissement en substance, ne jouez pas avec le feu. Quand les gens qui connaissent le code le mieux posent eux-mêmes un panneau danger dessus, c'est que le sujet est mûr.

Réécrire : le piège qui tue les projets

Face à ça, l'instinct est unanime : "ce serait quand même plus simple de tout refaire à neuf, proprement." Je l'ai eu, vous l'avez eu. Et il est presque toujours faux.

Trois raisons, et aucune n'est théorique.

D'abord, vous allez passer un an ou deux à reconstruire ce qui marchait déjà. Pendant ce temps, le client ne voit rien arriver de neuf. Vous brûlez du budget pour atterrir, au mieux, là où vous étiez parti.

Ensuite, le legacy est plein de règles métier invisibles. Ces if bizarres qui n'ont aucun sens à la lecture ? Ce sont des bugs corrigés un par un, au fil des années, chacun payé par un ticket client. Réécrire from scratch, c'est jeter cette mémoire, et se condamner à recorriger les mêmes bugs, dans l'ordre.

Enfin, pendant que vous réécrivez, la prod continue de vivre. On corrige des urgences, on ajoute la fonctionnalité que le commercial a promise. La cible bouge. Votre version neuve court derrière un train qui ne s'arrête jamais : c'est le second-system effect dans toute sa splendeur.

Dit autrement, et sans dramatiser : le rewrite, c'est un gros chèque signé d'avance sur une promesse, sans aucune livraison intermédiaire. C'est le devis maximal. Or le coût d'un monolithe se chiffre bien avant d'en arriver à cet extrême, justement pour ne pas avoir à signer ce chèque-là.

Le Strangler Fig : étrangler, pas raser

Le nom vient de Martin Fowler, qui l'a emprunté à un arbre. Le figuier étrangleur pousse autour de son hôte, l'enveloppe peu à peu, vit à ses dépens, et le jour où l'hôte meurt, le figuier tient debout tout seul, à sa place exacte. L'ancien disparaît, la forme reste. Voilà l'idée, transposée au code.

Concrètement, ça tient en quatre temps :

  1. Intercepter : poser un point de passage devant le vieux code, là où on voudra le remplacer.
  2. Router : envoyer une fonctionnalité, une seule, vers du neuf ; tout le reste continue vers l'ancien.
  3. Remplacer : recommencer, incrément par incrément, en grignotant le vieux code morceau par morceau.
  4. Supprimer : quand plus rien n'appelle l'ancien chemin, on l'efface pour de bon.

Le point qui change tout est là : ça se fait en production, sans gel. À chaque étape, l'appli marche, vous livrez, le client utilise. Vous ne pariez jamais gros : le risque est découpé en miettes, et chaque miette est réversible. On est à l'exact opposé du big-bang où tout se joue le jour de la bascule.

Un pré-requis, par contre, et il n'est pas négociable. Pour déplacer un comportement sans le casser, il faut d'abord savoir ce qu'il fait vraiment. La doc ne le dira pas : il faut l'observer tourner. Donc un filet : des tests de caractérisation qui figent le comportement actuel, et l'analyse statique poussée dans ses niveaux pour cartographier ce qui touche à quoi. Sans filet, vous n'étranglez pas le monolithe. Vous jouez à la roulette avec.

Choisir la première couture

Maintenant, le vrai geste, celui qui décide de tout le reste. La mécanique, elle, on y vient juste après. La question : où couper en premier, dans 4 854 lignes ?

Se tromper de première couture, c'est saboter le chantier avant de l'avoir commencé. Trop ambitieuse, on touche au coeur transactionnel et on casse la prod. Trop anecdotique, on ne prouve rien et l'équipe se lasse. La bonne première couture coche quatre cases :

  • Bornée : une capacité qu'on peut nommer en trois mots, à l'opposé du coeur métier qui infuse partout.
  • À effet de bord plutôt que donnée critique : si ça rate, on n'a pas corrompu la base.
  • Un point d'entrée clair : un endroit évident où poser une interface.
  • De la valeur : un truc qu'on devra de toute façon faire évoluer un jour.

Sur osTicket, un candidat coche les quatre cases : la notification.

En ouvrant class.ticket.php, on tombe sur des méthodes comme onNewTicket(), onOverdue(), markOverdue(). Leur boulot : prévenir quelqu'un. Et la façon dont elles le font, c'est en construisant et en envoyant des e-mails directement dans le corps de la méthode, au milieu de la logique de ticket, un $email->sendAutoReply(...) glissé entre deux règles métier.

C'est la couture rêvée. Bornée : "prévenir quelqu'un", ça tient en deux mots. À effet de bord : un mail d'alerte raté, c'est ennuyeux, mais ça ne perd pas le ticket. Appelée d'un peu partout : donc une interface unique rapporte gros. Et tout le monde finit un jour par vouloir changer ses notifications, passer à Slack, taper un webhook, lisser le débit d'alertes. La valeur est là d'avance.

Ce qu'on ne touche surtout pas en premier, c'est create(), la méthode statique qui démarre vers la ligne 4055, le coeur transactionnel qui fabrique un ticket. Ni l'ORM maison, ces 3 662 lignes qui irriguent tout le projet. Le coeur, on le garde pour la fin : quand on aura de l'expérience sur ce code et un filet vraiment solide.

Et soyons clairs sur ce que je fais ici : je n'ai pas réécrit osTicket. J'ai ouvert le capot pour montrer comment je choisirais la première couture sur ce code précis. Le raisonnement est le livrable. Aucun pull request fantôme à faire passer pour une migration finie.

La mécanique : façade, bascule, suppression

La couture est choisie. Voici comment on l'exécute, étape par étape. Le code qui suit est illustratif : il montre la forme du pattern, sans prétendre être un diff d'osTicket prêt à compiler. C'est l'intention qui compte ici, la syntaxe exacte reste secondaire.

Étape 1, poser l'interface. On décrit l'intention, jamais l'implémentation. Un contrat propre qui dit quoi faire, sans dicter comment :

interface TicketNotifier
{
    public function notifyNewTicket(Ticket $ticket): void;
    public function notifyOverdue(Ticket $ticket): void;
}

Rien n'envoie encore le moindre e-mail. On a juste posé une prise au mur.

Étape 2, adapter l'ancien. Une première implémentation qui ne fait rien de neuf : elle appelle le vieux code d'envoi existant, tel quel.

final class LegacyTicketNotifier implements TicketNotifier
{
    public function notifyNewTicket(Ticket $ticket): void
    {
        // on enrobe simplement l'envoi inline qui vivait
        // déjà dans onNewTicket() : rien de neuf pour l'instant
        $ticket->onNewTicket(/* le message courant */);
    }

    public function notifyOverdue(Ticket $ticket): void
    {
        $ticket->onOverdue();
    }
}

Pour l'utilisateur, rien ne change. Le mail part comme avant. On a seulement créé un point de passage.

Étape 3, router les appels. Call-site par call-site, on remplace les envois inline par un appel à l'interface. À ce stade, le comportement est strictement identique, mais le couplage est cassé. Le reste du code ne sait plus comment on notifie. Juste qu'on notifie.

Étape 4, remplacer pour de vrai. Maintenant, et seulement maintenant, on écrit une implémentation moderne derrière la même interface : typée, testée, en PHP 8, qui pousse peut-être les envois dans une vraie file d'attente. On bascule par un feature flag. Si ça part de travers en prod, on rebascule sur LegacyTicketNotifier en changeant une ligne de config. Pas un rollback de déploiement. Une ligne.

Étape 5, supprimer. Quand plus aucun call-site n'emprunte le vieux chemin, on efface le code d'envoi inline. La classe de 4 854 lignes vient d'en perdre une centaine, pour de bon. Pas commentées "au cas où". Supprimées.

C'est la suite directe de l'histoire que Rector avait commencée sur ce genre de legacy : lui modernise la syntaxe et les types, automatiquement ; le Strangler Fig, lui, déplace l'architecture, à la main et en conscience. La forme d'abord, le fond ensuite.

Le piège du strangler raté

Le pattern a un mode d'échec, et il est plus fréquent qu'on aimerait. Il a même son nom dans la communauté, l'équipe de Rector en a fait un article entier, sous l'angle de l'anti-pattern. C'est le monolithe à moitié étranglé.

Le scénario est toujours le même. On pose deux ou trois interfaces, on bascule une fonctionnalité, tout le monde est content. Puis une urgence tombe. Un client qui hurle, une deadline, un bug en prod. On laisse la couture en plan, "on finira plus tard". Plus tard n'arrive jamais.

Résultat : vous avez maintenant deux systèmes en parallèle. L'ancien chemin et le neuf, à moitié branchés l'un sur l'autre. Plus de surface, plus de cas à tester, plus de charge mentale qu'avant de commencer. Vous avez ajouté de la complexité en croyant en retirer.

La discipline qui sauve tient en une règle : on finit chaque couture avant d'en ouvrir une autre. Une couture, c'est poser, router, remplacer, puis supprimer. Tant que la suppression n'est pas faite, la couture n'est pas finie. Pas de demi-couture qui traîne sur le bord du chantier.

Un monolithe, c'est déjà bien assez à porter. Deux, c'est se punir.

On ne tue pas un monolithe d'un coup

Moderniser un monolithe, ce n'est pas un saut de la foi. Le saut de la foi, c'est le rewrite : on ferme les yeux, on saute, et on espère qu'il y a un filet en bas. Le Strangler Fig, c'est l'inverse, une suite de petits paris réversibles, où chaque couture livrée est un risque retiré du tas, définitivement.

Bref : on n'étrangle pas un monolithe en une nuit. On le fait couture après couture, en gardant la prod debout du début à la fin. Sur le papier, c'est plus lent qu'un grand rewrite tout propre. Dans la vraie vie, c'est surtout le seul plan qui arrive au bout.