Back home

Masquage et transfert de complexité dans « Optimisation de la maintenabilité »

Lorsque la complexité est simplement déplacée des grandes fonctions vers la hiérarchie des classes, la configuration et les chaînes d'appels, le système n'est généralement pas plus facile à maintenir.

Lorsque de nombreuses équipes effectuent une « optimisation de la maintenabilité », la première étape consiste à démonter le code.

Une fonction de 300 lignes a été divisée en 12 classes ; un processus avec de nombreuses branches a été modifié en « stratégie + usine + configuration » ; une logique qui pouvait être comprise en suivant l’appel a été transformée en événements, abonnés, tables de règles et plusieurs répertoires épurés.

Le code est en effet moins chargé, et un seul fichier est plus court. Lors de la révision, cela donne même aux gens le sentiment que « c’est avancé ».

Mais mon jugement est le suivant : ** De nombreuses optimisations dites de maintenabilité ne réduisent pas la complexité, mais changent seulement la complexité de partiellement visible à dispersée, instable et cachée. ** Le résultat le plus courant de ce type de changement est que le problème est plus difficile à localiser, le changement est plus difficile à évaluer et il est plus difficile à comprendre pour les nouvelles personnes.

Le cœur de la maintenabilité a toujours été : **Lorsque les exigences changent, que des erreurs en ligne se produisent et que des conditions limites apparaissent, l’équipe peut-elle rapidement voir les contraintes réelles et apporter des modifications en toute sécurité dans un cadre limité ? **

La complexité ne disparaîtra pas à cause du fractionnement, elle restera simplement ailleurs.

Une situation courante est que l’intuition de la « maintenabilité » est trop visuelle.

Ils se sentent mal à l’aise lorsqu’ils voient de grandes fonctions, ils se sentent en retard lorsqu’ils voient beaucoup de if/else, et ils veulent instinctivement les déchirer lorsqu’ils voient plusieurs jugements commerciaux intégrés dans une classe. Ainsi, une logique complexe a été divisée en de nombreux fichiers légers, les branches conditionnelles ont été traduites en niveaux d’objet, les règles métier ont été déplacées dans les configurations, et une petite interface et un nom ont été ajoutés, et la surface du code était immédiatement plus propre.

Le problème est que même si les 300 lignes de code originales sont laides, la complexité est au moins répartie sur le bureau. En lisant de haut en bas, vous pouvez voir comment les conditions de branche, l’état partagé, la gestion des exceptions et les résultats finaux sont connectés.

Une fois la complexité décomposée, la situation change :

  • Vous devez parcourir 7 fichiers tout au long de la chaîne d’appel pour savoir où un champ a finalement été modifié ;
  • Vous devez comprendre à la fois l’interface, la classe d’implémentation, la logique d’enregistrement et l’assemblage d’exécution pour confirmer quelle branche vous prenez ;
  • En apparence, il semble que les règles métier soient dans le code et que les résultats soient pour moitié en YAML, pour moitié dans la base de données et pour moitié dans une table de mappage générée au démarrage.

La complexité n’a pas diminué, mais est passée d’« un peu fatiguant lors de la lecture » à « beaucoup plus lente lors de la localisation des problèmes ».

Le coût de maintenance est généralement réglé trois mois plus tard lorsque quelqu’un corrige la mauvaise logique, échoue en ligne et dépanne la liaison.

L’erreur de jugement la plus courante parmi les équipes est de confondre « propreté partielle » et « maintenabilité globale »

Ce type d’erreur de jugement est courant car de nombreux avantages du refactoring semblent réels à court terme.

Par exemple, une fonction qui contient plusieurs branches de traitement des commandes peut être modifiée selon la structure suivante :

Handler h = handlerFactory.get(order.type());
h.validate(order);
h.price(order);
h.persist(order);
h.notify(order);

Ce code semble certainement plus propre qu’une longue liste de branches.

Mais la vraie question est :

  1. Comment décider quelle implémentation utiliser pour handlerFactory ;
  2. Existe-t-il une condition préalable partagée entre validate/price/persist/notify ;
  3. Si une dérive comportementale est autorisée entre les différentes mises en œuvre ;
  4. Une certaine modification d’exigence doit-elle être apportée à un endroit, quatre endroits ou une douzaine d’endroits ?

Si ces problèmes ne sont pas limités, alors ce type de « structure élégante » ne fait souvent que réécrire les différences commerciales initialement explicitement écrites dans if/else en différences implicites dispersées dans la hiérarchie des classes.

Du point de vue de la révision, cela devient plus propre ; du point de vue de la maintenance, cela devient plus dépendant du contexte.

**La maintenabilité fait référence à la question de savoir s’il est plus facile de répondre à l’ensemble du système : « Où ce changement affectera-t-il ? » **

Ce qui détermine réellement les coûts de maintenance, ce sont généralement quatre éléments

Je préfère utiliser les quatre questions suivantes pour juger si une refactorisation rend le système plus maintenable.

1. Lorsque le problème survient, le chemin de localisation est-il plus court ?

Lorsqu’il signale en ligne un problème selon lequel “certains types de commandes émettent occasionnellement des coupons en double”, le plus important est de savoir si l’ingénieur peut trouver rapidement : où se trouvent les conditions de jugement, où se trouve la protection idempotente et où les effets secondaires se déclenchent.

Si après la division, le chemin de dépannage passe de « examiner une fonction » à « examiner la définition de l’interface, trouver la classe d’implémentation, vérifier l’assembly, suivre les événements et inverser la configuration », alors le coût de maintenance augmentera en fait.

2. Lorsque les exigences changent, la portée de la modification est-elle plus convergente ?

Une bonne abstraction permet de concentrer les changements. Une mauvaise abstraction permet au changement de se propager.

Le pire type de refactoring consiste à diviser la logique en plusieurs responsabilités en surface. En fait, chaque fois que les exigences changent, elles doivent être modifiées simultanément : définition des règles, enregistrement en usine, configuration par défaut, échantillons de test et points de surveillance. Le fichier est devenu plus petit, mais la zone de modification est devenue plus grande.

Ce genre de système a l’air modulaire, mais il est en réalité plus fragile, car à chaque fois que vous effectuez un changement, il faut parier que vous n’avez raté aucun virage.

3. Les contraintes deviennent-elles plus visibles plutôt que plus cachées ?

La raison pour laquelle une grande partie de la logique métier est difficile est qu’à première vue, le code semble moche, mais en fait il en est plus proche et comporte de nombreuses conditions préalables :

  • Cet état ne peut aller que de A à B, pas directement à C ;
  • Ce champ n’est modifiable que par certains types de clients ;
  • Cette action doit réussir avec un autre effet secondaire.

Si après refactoring, ces contraintes n’apparaissent plus au même endroit, mais sont dispersées dans plusieurs classes, annotations, configurations ou auditeurs, alors il existe un risque d’amnésie.

4. Le retour du test est-il plus proche du comportement réel ?

De nombreuses « optimisations de maintenabilité » conduiront commodément à un ensemble de tests uniques faciles à écrire car chaque classe est plus petite et les dépendances sont moquées.

Toutefois, l’augmentation du nombre de tests uniques ne signifie pas que le système soit plus facile à améliorer.

Si le test peut seulement prouver que « cette classe renverra la valeur attendue dans le monde fictif » mais ne peut pas couvrir les relations d’assemblage, l’état partagé et les contraintes de temps dans le processus réel, alors il s’agit davantage de protéger la structure que de protéger le comportement.

Un malentendu courant : afin d’éliminer if/else, réécrivez les différences métier dans un système de types

Bien sûr, if/else peut être mal écrit, mais « éliminer if/else » n’est pas un objectif en soi.

J’ai vu de nombreux systèmes qui, à l’origine, n’avaient que deux ou trois branches claires et une sémantique métier très stable. Cependant, afin de suivre le modèle de conception correct, ils ont été divisés en interfaces politiques, classes de base abstraites, centres d’enregistrement et points d’extension. Six mois plus tard, le nombre de types est passé de 3 à 9, mais il est devenu de plus en plus difficile pour les appelants de déterminer quelles différences étaient de véritables différences commerciales et lesquelles n’étaient que des différences structurelles héritées de l’évolution historique.

Dans de nombreux cas, avoir de nombreuses branches ne signifie pas que le modèle objet doit être adopté ; cela signifie simplement qu’il y a ici un jugement commercial. La première chose à faire est de distinguer lesquels de ces jugements sont des axes de changement stables et lesquels ne sont que des bifurcations conditionnelles dans un même processus.

S’il ne s’agit que de quelques jugements conditionnels dans un processus, les forcer à être « orientés objet » ne fera probablement que réécrire les conditions visibles d’un coup d’œil en plusieurs couches de répartition des méthodes.

** Cacher la condition dans le polymorphisme ne fera pas disparaître la condition, cela fera seulement réaliser au lecteur son existence plus tard. **

Un autre malentendu courant : traiter la configuration comme une corbeille de complexité

Une autre approche qu’il est particulièrement facile de confondre avec « plus maintenable » consiste à configurer les règles métier autant que possible.

Les raisons sont généralement très bonnes : il n’est pas nécessaire de modifier le code à l’avenir, le fonctionnement est configurable et l’extension est plus flexible.

Mais la configuration n’est pas intrinsèquement moins chère, elle déplace simplement la complexité du moment de la compilation vers le moment de l’exécution.

Une fois qu’une configuration de règles commence à assumer trop de responsabilités, les problèmes suivants peuvent rapidement survenir :

  • Il existe une relation de priorité et de couverture entre les configurations, mais il n’y a aucun endroit dans le système où elles peuvent être entièrement visibles ;
  • Les scénarios concernés par un changement ne peuvent être vérifiés qu’en ligne ;
  • Les valeurs de configuration légales ne signifient pas une sémantique correcte, des erreurs seront exposées au moment de l’exécution ;
  • La révision du code devient “Je ne comprends pas ce que signifie ce JSON.”

Si une règle change fréquemment, mais que la modification nécessite toujours un jugement technique, des tests de liaison et des plans de restauration, il s’agit alors essentiellement d’un problème de code et ne deviendra pas soudainement un élément de maintenance à faible coût simplement parce qu’il est écrit dans la configuration.

L’un des coûts courants de la surconfiguration est que « personne n’ose plus toucher au système ».

Contre-exemple : Certaines abstractions vont effectivement rendre le système plus maintenable

On ne peut pas non plus dire : « Ne faites pas d’abstraction, ne divisez pas ».

Il existe des situations où l’abstraction est non seulement utile, mais nécessaire.

Par exemple :

  • Faire face à des axes de changement stables et clairs, tels que différents backends de stockage, différents canaux de paiement et différents protocoles de sérialisation ;
  • Ces modifications doivent réellement être remplacées au moment de l’exécution, plutôt que de simples « extensions possibles ultérieures » imaginaires ;
  • Chaque implémentation peut se conformer au même ensemble de contraintes fortes, plutôt que d’avoir apparemment la même interface mais d’avoir en réalité une sémantique différente ;
  • Les limites des équipes suivent également des limites abstraites, et différents modules peuvent être développés et testés indépendamment.

La valeur de l’abstraction à ce stade est qu’elle réduit réellement les frictions liées aux changements futurs.

De même, si une fonction longue est responsable de la vérification des paramètres, de la prise de décision commerciale, de l’orchestration des effets secondaires et de la compensation des exceptions, il est généralement judicieux de la diviser en plusieurs étapes avec des limites claires. Le principe est qu’après le démontage, l’épine dorsale du processus est toujours visible et les contraintes clés ne sont pas cachées.

La question a donc toujours été : une fois la démolition terminée, la complexité est contenue, ou est-elle simplement transférée vers d’autres coins cognitifs. **

Une méthode de jugement plus pratique : regardez d’abord les modifications les plus courantes dans le futur, ne regardez pas d’abord la propreté structurelle d’aujourd’hui

Si je soupçonne qu’un “refactor de maintenabilité” n’est qu’un peaufinage structurel, je commence généralement par poser trois questions très pratiques :

  1. La prochaine fois que le produit modifiera cette exigence, quels seront les changements les plus probables pour les ingénieurs ?
  2. Si quelque chose ne va pas en ligne la prochaine fois, quel lien la personne de service doit-elle consulter en premier ?
  3. Si une nouvelle personne prend la relève, elle doit d’abord comprendre les règles métier ou la structure du cadre.

Si les réponses à ces trois questions deviennent plus compliquées, il y a alors de fortes chances que cette refactorisation n’améliore pas la maintenabilité.

La maintenabilité concerne les coûts de modification futurs, pas les captures d’écran de code d’aujourd’hui.

Résumé

La raison pour laquelle de nombreuses “optimisations de maintenabilité” sont dangereuses est qu’en apparence elles semblent complètement inutiles, mais en fait il est beaucoup plus proche d’elles qu’elles soient trop faciles à paraître partiellement correctes.

Il y a plus de classes, les fonctions sont devenues plus courtes, le répertoire est devenu plus soigné et le processus de révision est devenu plus fluide. Mais le véritable coût de maintenance vient de la compréhension, du positionnement, de la modification et de la vérification, et non de la propreté visuelle.

Ma suggestion est donc très simple : **Ne démantelez pas la logique complexe jusqu’à ce qu’elle soit invisible, démontez d’abord la logique complexe jusqu’à ce qu’elle puisse être modifiée. **

Si une refactorisation déplace simplement la complexité du fichier actuel vers la chaîne d’appels, la couche de configuration et la couche d’abstraction, elle n’améliore généralement pas la maintenabilité, mais retarde simplement la difficulté du prochain dépannage.