Architecture & Performance

Clean code ou sur-ingénierie : jusqu'où faut-il préparer le futur ?

Un code propre doit faciliter les évolutions, pas préparer l'application à tous les futurs imaginables. Entre dette technique et abstraction prématurée, le bon choix dépend souvent du coût réel du changement.

Le clean code part d'une intention difficile à contester : rendre un logiciel plus simple à comprendre, à modifier et à maintenir.

Le refactoring poursuit généralement le même objectif.

Pourtant, il existe un point où vouloir préparer parfaitement le futur peut commencer à compliquer inutilement le présent.

Une nouvelle abstraction apparaît parce qu'elle pourrait servir plus tard. Une table supplémentaire rend le modèle plus générique. Une interface permet théoriquement de remplacer une implémentation qui n'a probablement jamais besoin de l'être.

Chaque décision prise séparément peut sembler raisonnable.

Accumulez-les, et une équipe de deux développeurs peut finir par maintenir une architecture pensée pour des problèmes qu'elle ne rencontrera peut-être jamais.

L'extrême inverse n'est pas meilleur.

Un projet construit uniquement pour aller vite peut finir dans un état où ajouter deux colonnes, modifier une règle ou déplacer une fonctionnalité devient une opération risquée.

Le problème n'est donc pas de choisir entre code propre et code rapide.

La vraie question est plutôt :

Combien de complexité faut-il accepter aujourd'hui pour rendre les changements de demain suffisamment simples ?

Le clean code répond à un vrai problème

Il est facile de critiquer les abstractions lorsqu'on regarde une architecture trop complexe.

Il ne faut pas oublier pourquoi ces pratiques existent.

J'ai déjà rencontré une application où une grande partie du fonctionnement était concentrée dans quatre ou cinq fichiers.

À première vue, cela pouvait donner l'impression d'une architecture simple : peu de fichiers, peu de couches, peu de concepts.

En réalité, cette simplicité était seulement visuelle.

Ajouter deux colonnes pouvait nécessiter de comprendre plusieurs centaines de lignes, de retrouver une logique dupliquée à différents endroits et de vérifier que la modification ne cassait pas un comportement sans rapport apparent.

C'est un bon indicateur d'un problème de conception.

Le pragmatisme ne consiste pas à mettre le maximum de code au même endroit.

Un code réellement simple doit rester relativement simple à modifier.

Si une évolution triviale demande plusieurs heures d'investigation parce que les responsabilités sont mélangées, la dette technique a déjà un coût concret.

Le clean code, la séparation des responsabilités et le refactoring répondent précisément à ce problème.

Mais "plus propre" ne signifie pas automatiquement "meilleur"

L'erreur inverse consiste à traiter chaque possibilité future comme une exigence actuelle.

On ajoute alors :

  • une couche d'abstraction supplémentaire ;
  • une interface pour une implémentation unique ;
  • une hiérarchie de classes pour trois comportements proches ;
  • plusieurs tables pour rendre un concept entièrement dynamique ;
  • un système de configuration pour une valeur qui change une fois tous les cinq ans ;
  • une infrastructure capable d'absorber une charge que l'application n'atteindra probablement jamais.

L'architecture devient plus flexible.

Mais elle devient aussi plus coûteuse à comprendre.

Et cette charge cognitive est un coût réel.

Une fonctionnalité ne coûte pas seulement le temps nécessaire pour écrire son code.

Elle coûte également le temps passé à comprendre :

  • où effectuer la modification ;
  • quelles abstractions traverser ;
  • quelles relations entre modèles sont impliquées ;
  • quels effets de bord sont possibles ;
  • quelles conventions internes doivent être respectées.

Une architecture plus générique peut donc être techniquement élégante tout en ralentissant l'équipe qui doit réellement travailler dessus.

Le problème n'est pas l'abstraction.

Le problème est l'abstraction qui coûte davantage qu'elle ne simplifie.

Le contexte du projet fait partie de l'architecture

Une décision technique ne peut pas être évaluée indépendamment du projet dans lequel elle est prise.

Le nombre de développeurs compte.

Les délais comptent.

La stabilité du besoin compte.

Le niveau d'incertitude métier compte.

L'état du produit compte également.

Sur un projet qui avait accumulé plusieurs années de retard, l'objectif initial n'était pas uniquement de construire immédiatement l'architecture la plus élégante possible.

Il fallait aussi obtenir rapidement quelque chose qui fonctionne.

Après une longue période sans résultat visible, livrer une fonctionnalité pouvait avoir une autre valeur : redonner confiance aux utilisateurs métier et permettre au projet de repartir.

Dans ce contexte, accepter temporairement davantage de logique dans une vue peut être un compromis raisonnable si l'alternative consiste à repousser encore la livraison de plusieurs semaines.

Ce n'est pas nécessairement :

"Je ne sais pas comment mieux structurer ce code."

Cela peut être :

"Je vois une meilleure architecture possible, mais son coût aujourd'hui est supérieur au bénéfice qu'elle apporte maintenant."

Cette distinction est importante.

Dette technique consciente ou négligence ?

Toutes les imperfections ne se valent pas.

Une dette technique consciente possède normalement quatre caractéristiques :

  1. le compromis est identifié ;
  2. la raison de ce compromis est connue ;
  3. ses conséquences sont suffisamment comprises ;
  4. il existe un moment ou un signal permettant de réévaluer la décision.

La négligence ressemble davantage à :

"Ça fonctionne, donc on ne touche plus jamais à rien."

La dette technique consciente ressemble plutôt à :

"Cette logique devrait probablement être extraite si elle grossit ou si elle doit être réutilisée. Pour l'instant, elle reste localisée et le coût d'une abstraction supplémentaire n'est pas justifié."

La différence n'est donc pas seulement visible dans le code.

Elle existe aussi dans le raisonnement qui a conduit à ce code.

Un compromis documenté et maîtrisé peut être une décision d'ingénierie parfaitement rationnelle.

Une accumulation de raccourcis que personne ne comprend plus ne l'est pas.

Un exemple : faut-il vraiment généraliser ce modèle de données ?

Prenons un exemple volontairement simplifié et anonymisé.

Une application doit suivre quatre catégories fixes.

Pour chacune, deux informations doivent être enregistrées :

  • la catégorie est-elle actuellement présente ?
  • est-elle prévue dans le futur ?

Une première modélisation pourrait placer directement ces informations sur l'entité principale :

Entity
├── category_a_present
├── category_a_planned
├── category_b_present
├── category_b_planned
├── category_c_present
├── category_c_planned
├── category_d_present
└── category_d_planned

Ce modèle possède plusieurs défauts évidents.

Ajouter une cinquième catégorie nécessite une migration de schéma.

Le modèle connaît directement toutes les catégories possibles.

Une logique qui doit parcourir les catégories devient moins naturelle.

On pourrait donc construire quelque chose de plus générique :

Entity
   │
   ▼
EntityCategory
   │
   ▼
Category

La relation pourrait porter les informations :

EntityCategory
├── entity_id
├── category_id
├── present
└── planned

D'un point de vue extensibilité, la deuxième solution est nettement plus flexible.

Ajouter une catégorie ne nécessite plus d'ajouter deux colonnes au modèle principal.

On peut gérer dynamiquement les catégories.

Le modèle représente mieux une relation entre deux concepts.

Alors pourquoi ne pas systématiquement choisir cette solution ?

Parce que cette flexibilité a elle aussi un coût.

Il faut désormais gérer :

  • une table supplémentaire ;
  • des relations supplémentaires ;
  • la création et la suppression des associations ;
  • des requêtes potentiellement plus complexes ;
  • davantage de cas à expliquer à quelqu'un qui découvre le projet.

La question intéressante n'est donc pas :

Quelle modélisation est la plus générique ?

Mais :

Quelle est la probabilité que cette généricité soit réellement utile ?

Si les quatre catégories sont définies par un domaine extrêmement stable, qu'elles n'ont pratiquement pas évolué depuis des années et que leur modification nécessiterait de toute façon une évolution métier importante, la solution la plus simple peut être parfaitement défendable.

En revanche, si les utilisateurs doivent prochainement créer eux-mêmes des catégories ou si leur nombre varie régulièrement, le raisonnement change complètement.

Le point essentiel est que la solution simple repose ici sur une hypothèse métier explicite.

Si l'hypothèse est fausse, le problème ne vient pas seulement du modèle de données.

Il vient aussi de notre compréhension du besoin.

Une possibilité future n'est pas automatiquement une exigence actuelle

Cette distinction évite beaucoup de sur-ingénierie.

Il existe une différence entre :

"Cette application devra gérer plusieurs langues l'année prochaine."

et :

"Peut-être qu'un jour quelqu'un demandera plusieurs langues."

Dans le premier cas, nous possédons une information sur la trajectoire du produit.

Dans le deuxième, nous imaginons un futur possible.

Les deux situations ne méritent pas nécessairement le même investissement.

Une abstraction ajoutée aujourd'hui a un coût immédiat et certain :

  • elle doit être développée ;
  • testée ;
  • comprise ;
  • maintenue.

Le besoin futur qu'elle anticipe peut, lui, ne jamais exister.

Cela ne signifie pas qu'il faut ignorer le futur.

Il faut plutôt essayer d'évaluer sa probabilité et son impact.

Le critère souvent oublié : combien coûte le retour en arrière ?

Toutes les décisions simples ne présentent pas le même risque.

Supposons deux options.

Option A

Une implémentation simple demande deux jours.

Si le besoin évolue dans deux ans, la refactorer demandera probablement trois ou quatre jours.

Option B

Une architecture générique demande immédiatement huit jours.

Elle évitera éventuellement ce futur refactoring.

Si l'évolution reste hypothétique, payer huit jours immédiatement est difficile à justifier.

Mais changeons les chiffres.

Une décision d'architecture prend deux semaines aujourd'hui, alors qu'une migration future nécessiterait six mois, une interruption de service et la transformation de millions de lignes de données.

Le calcul devient très différent.

C'est pourquoi un critère me semble particulièrement utile :

Quel est le coût de réversibilité de cette décision ?

Certaines décisions sont faciles à changer :

  • extraire une fonction ;
  • introduire un service ;
  • séparer deux responsabilités ;
  • remplacer une implémentation locale.

D'autres deviennent rapidement structurelles :

  • le format d'un identifiant public utilisé par des clients ;
  • un modèle de données contenant un volume important ;
  • une API consommée par plusieurs systèmes ;
  • une architecture distribuée entre plusieurs applications.

Plus une décision est difficile à inverser, plus il est pertinent d'investir dans sa conception en amont.

À l'inverse, lorsqu'une décision reste facilement réversible, il peut être rationnel de commencer plus simplement.

Refactorer lorsque la structure commence à coûter

Le code n'a pas besoin d'être parfait pour mériter d'exister.

Mais certaines situations indiquent clairement qu'un refactoring commence à devenir rentable.

Par exemple :

  • une modification simple devient régulièrement difficile ;
  • la même règle doit être modifiée à plusieurs endroits ;
  • les effets de bord deviennent difficiles à prévoir ;
  • une fonction ou une classe cumule plusieurs responsabilités ;
  • comprendre une fonctionnalité nécessite de parcourir une grande partie du projet ;
  • un nouveau développeur aurait beaucoup de mal à déterminer où intervenir ;
  • chaque nouvelle fonctionnalité demande davantage de contournements que la précédente.

Le dernier point est particulièrement intéressant.

Une architecture peut contenir de la dette sans réellement gêner le projet.

À ce moment-là, la corriger possède essentiellement un bénéfice théorique.

Mais lorsque chaque nouvelle modification coûte davantage à cause de cette structure, la dette commence à produire des intérêts.

Le calcul change.

Le bon moment pour refactorer n'est pas forcément celui où le code pourrait être plus élégant. C'est souvent celui où sa structure commence à ralentir les changements.

DRY peut lui aussi devenir une caricature

La duplication est souvent présentée comme quelque chose qu'il faut éliminer.

Et dans beaucoup de cas, c'est une bonne intuition.

Si une même règle métier existe dans cinq endroits, une modification future risque d'en oublier un.

Mais deux morceaux de code qui se ressemblent ne représentent pas nécessairement le même concept.

Imaginons deux fonctions initialement très proches.

On décide de les fusionner.

Puis leurs besoins commencent légèrement à diverger.

La fonction commune évolue progressivement vers :

def process_item(
    item,
    mode,
    special_case=False,
    use_legacy_behavior=False,
    skip_validation=False,
):
    ...

La duplication a disparu.

Mais la compréhension du code n'a pas nécessairement progressé.

Deux fonctions explicites auraient parfois été plus lisibles :

def process_imported_item(item):
    ...


def process_manual_item(item):
    ...

La question n'est pas seulement de savoir si deux blocs possèdent les mêmes lignes aujourd'hui.

Il faut aussi se demander s'ils représentent la même raison de changer.

S'ils évoluent pour des raisons différentes, les fusionner prématurément peut créer une abstraction plus fragile que la duplication initiale.

La duplication possède un coût. Une mauvaise abstraction aussi.

Parfois, accepter quelques lignes répétées pendant quelque temps permet de comprendre le véritable pattern avant de créer l'abstraction.

Une réécriture from scratch n'est pas un bouton Reset

Lorsque la dette technique devient importante, une idée apparaît souvent :

"On devrait refaire une V2 propre."

La proposition est séduisante.

La nouvelle version n'aura plus les anciennes décisions.

On pourra repartir avec une architecture cohérente, de meilleures conventions et une compréhension plus claire du domaine.

Mais une réécriture possède un problème fondamental : pendant que la nouvelle version est développée, l'ancienne continue généralement de vivre.

V1
├── correctifs
├── nouvelles fonctionnalités
├── changements métier
└── demandes urgentes
          │
          ▼
V2 doit continuellement rattraper la V1

Plus la réécriture dure, plus cet écart risque de grandir.

La V2 doit reproduire le comportement existant tout en intégrant les changements qui continuent d'arriver dans la V1.

Certaines réécritures finissent ainsi dans une situation paradoxale : le système censé remplacer l'ancien devient lui-même difficile à terminer parce qu'il doit poursuivre une cible en mouvement.

Cela ne signifie pas qu'une réécriture complète soit toujours une erreur.

Certaines architectures deviennent effectivement trop coûteuses à faire évoluer progressivement.

Mais "le code actuel est moche" n'est probablement pas une raison suffisante.

Une réécriture doit résoudre des problèmes suffisamment importants pour justifier :

  • son coût ;
  • le maintien temporaire de deux systèmes ;
  • la migration des données ;
  • le risque de réintroduire d'anciens bugs ;
  • le rattrapage permanent des évolutions métier ;
  • le délai avant que la nouvelle version produise réellement de la valeur.

Dans beaucoup de situations, un refactoring progressif permet de récupérer une grande partie des bénéfices sans arrêter l'évolution du produit.

Une architecture doit aussi être adaptée à l'équipe

On parle souvent d'architecture comme si elle dépendait uniquement du logiciel.

Elle dépend également des personnes qui vont le maintenir.

Une architecture avec plusieurs services, des événements asynchrones, de nombreuses abstractions et plusieurs couches peut être parfaitement cohérente pour une organisation disposant de plusieurs équipes spécialisées.

La même architecture peut devenir une charge importante pour deux développeurs qui doivent gérer simultanément :

  • les fonctionnalités ;
  • les bugs ;
  • les déploiements ;
  • la base de données ;
  • le monitoring ;
  • les demandes métier.

La sophistication technique n'est pas gratuite.

Chaque nouveau composant devient quelque chose qu'il faut comprendre, surveiller, mettre à jour et dépanner.

Cela ne signifie pas qu'une petite équipe doit construire des logiciels mal structurés.

Cela signifie simplement que la capacité opérationnelle de l'équipe fait partie des contraintes d'architecture.

Quelques questions avant d'ajouter une abstraction

Avant de rendre une architecture plus générique, j'aime revenir à quelques questions simples.

Quel problème réel suis-je en train de résoudre ?

Cette application va-t-elle beaucoup évoluer sur ce point précis ?

Le code sera-t-il réellement réutilisé ?

Le besoin futur que j'anticipe est-il probable ou simplement possible ?

Combien coûte la solution simple aujourd'hui ?

Combien coûterait son refactoring plus tard ?

Cette décision sera-t-elle facile à inverser ?

Combien coûte l'architecture plus générique dès maintenant ?

Quelles sont les contraintes de temps du projet ?

Quelle est la taille de l'équipe qui devra maintenir cette solution ?

Cette abstraction rendra-t-elle vraiment le changement plus simple ?

Un développeur qui découvre le projet comprendra-t-il mieux le code grâce à elle ?

Toutes ces questions ne produisent pas une formule mathématique.

Elles obligent simplement à comparer le bénéfice attendu de l'architecture à son coût réel.

Et surtout, elles évitent une erreur fréquente : considérer la flexibilité comme une valeur positive indépendamment de son prix.

Le clean code doit faciliter le changement, pas prédire le futur

Le clean code n'est pas l'ennemi du pragmatisme.

Il devrait en être une conséquence.

Un code lisible, des responsabilités suffisamment claires et des abstractions utiles permettent précisément de faire évoluer une application sans transformer chaque modification en chantier.

Mais il existe une différence entre préparer raisonnablement le changement et essayer de prévoir tous les futurs possibles.

Une architecture trop rigide peut bloquer un produit.

Une architecture trop générale peut le ralentir avant même que les problèmes qu'elle cherche à résoudre existent.

L'objectif n'est donc ni d'écrire le moins de code possible, ni de construire l'architecture la plus élégante possible.

Il est de maintenir un rapport raisonnable entre :

  • la complexité du problème actuel ;
  • la probabilité des évolutions futures ;
  • le coût d'un changement ultérieur ;
  • les contraintes de l'équipe ;
  • et le temps disponible aujourd'hui.

Le but du clean code n'est pas de préparer le logiciel à tous les futurs imaginables.

Il est de rendre son présent compréhensible et ses évolutions raisonnablement simples.

Pour aller plus loin