Accueil / Articles / Duplication contre couplage : décider quand un code partagé doit exister

Duplication contre couplage : décider quand un code partagé doit exister

Découvrez pourquoi fusionner des composants React ou des services NestJS similaires peut coûter plus cher que de les dupliquer, et utilisez trois questions pour déterminer quand une abstraction mérite d’être adoptée.

2806 mots

La plupart des développeurs sont formés à considérer le code répétitif comme un défaut : ils identifient deux composants similaires, en extraient un commun et passent à autre chose. Ce réflexe est souvent justifié, mais il cache un coût qui ne se manifeste que des mois plus tard, lorsque la partie commune doit servir des utilisateurs dont les exigences ont évolué. En présentant la décision comme un compromis entre deux coûts différents, à savoir la duplication et le couplage, on dispose d’un petit ensemble de questions pour déterminer lequel accepter dans React, React Native et le code backend.

L’idée principale est simple mais facile à mal interpréter : la répétition n’est pas une vertu, mais dans certaines situations la duplication est moins coûteuse que le couplage. Ce qui suit est un guide pour reconnaître ces situations.

Comment un composant partagé bien organisé se transforme en langage de configuration

Commencez par deux composants qui affichent l’image d’une personne. L’un est destiné aux utilisateurs ordinaires :

<UserAvatar user={user} />

L’autre est destiné aux formateurs :

<TrainerAvatar trainer={trainer} />

Dès le premier jour, ils sont presque indiscernables. Chacun affiche :

  • une image de profil
  • un contenu de remplacement en l’absence de l’image
  • des dimensions identiques
  • un état de chargement

La conclusion évidente est qu’un seul composant devrait assumer ces deux fonctions, d’où l’apparition d’un avatar générique :

<Avatar
  imageUrl={...}
  fallback={...}
  size="medium"
/>

Cela semble être une victoire évidente : moins de code, un seul endroit pour corriger les bugs. Puis le produit évolue. Les utilisateurs ont besoin d’un élément de remplacement respectant les paramètres de confidentialité. Les formateurs ont besoin d’une vignette de vérification. Les utilisateurs peuvent se contenter des initiales. Les formateurs indiquent s’ils sont actuellement disponibles. Chaque requête est traitée par le composant partagé sous forme d’une autre propriété :

<Avatar
  imageUrl={...}
  fallback={...}
  size="medium"
  showInitials={...}
  showVerification={...}
  showAvailability={...}
  privacyMode={...}
/>

D’autres exigences continuent d’apparaître, et chacune devient un problème à gérer. Finalement, l’avatar « générique » doit tenir compte des utilisateurs, des formateurs, des règles de confidentialité, des procédures de vérification, de la disponibilité ainsi que d’une multitude de logiques métier qui n’ont rien à voir avec le simple dessin d’un cercle contenant une image. Il reste techniquement réutilisable, mais il n’est plus simple. La complexité n’a jamais été éliminée ; elle a simplement été regroupée dans un seul fichier, de sorte que tout élément qui l’utilise dépend désormais de l’ensemble. Si ce phénomène d’explosion des propriétés vous semble familier, l’article intitulé quand les composants React réutilisables ont des effets indésirables examine plus en détail le refactoring de tels composants.

Partager du code, c’est partager l’avenir

Le point qui est rarement abordé, c’est que l’extraction de code partagé n’est pas seulement une décision d’implémentation : elle lie les destins des utilisateurs. Lorsque le composant A et le composant B dépendent tous deux d’une même abstraction, toute modification apportée à A peut désormais endommager ou modifier le comportement de B. La réutilisation crée des relations, et les relations représentent un couplage.

Cela change la question pertinente à se poser. « Ces deux éléments peuvent-ils partager du code ? » peut presque toujours être répondu par oui. La meilleure question est : ces deux éléments devraient-ils partager un avenir ?

Deux blocs de code peuvent être textuellement identiques aujourd’hui et appartenir néanmoins à des parties non liées du domaine. Leur implémentation coïncide ; leurs raisons de modification ne le sont pas nécessairement. Cette différence permet de prédire les coûts de maintenance bien mieux qu’un simple comptage des lignes dupliquées. Elle est étroitement liée au principe de responsabilité unique, généralement formulé comme « un module doit avoir une seule raison de changer », où une raison de modification désigne un groupe de personnes dont les demandes en déterminent le changement.

Lorsque le marquage dupliqué assure l’indépendance

Considérons une carte d’utilisateur :

<UserCard />

et une carte de paiement :

<PaymentCard />

Actuellement, les deux affichent la même structure :

<div className="card">
  <h3>{title}</h3>
  <p>{description}</p>
</div>

Une carte partagée représente la prochaine étape naturelle :

<Card />

Supposons maintenant que la carte destinée à l’utilisateur soit redessinée fréquemment, tandis que la carte de paiement est soumise à des exigences de produit et de conformité complètement distinctes. La correspondance entre les balises est une coïncidence ; la signification métier n’est pas partagée. Conserver les deux séparément entraîne quelques lignes JSX redondantes. En contrepartie, chaque concept dispose d’un espace où il peut évoluer sans négociation. Cette redondance offre un avantage concret : la possibilité de modifier l’un sans devoir coordonner avec les responsables de l’autre. Vu sous cet angle, deux composants séparés ne représentent pas une architecture négligente. Ils peuvent constituer une frontière délibérée.

Il existe une nuance importante spécifiquement pour React. Un shell purement visuel, un conteneur stylisé dépourvu de signification métier, peut souvent être partagé en toute sécurité, car sa raison d’évolution provient du système de design et non d’une fonctionnalité produit. Le piège se présente lorsque la partie partagée commence à intégrer des décisions métier qui relèvent d’un seul utilisateur.

Demandez pourquoi deux éléments sont identiques, pas comment les fusionner

Lorsque l’on observe une répétition, on a tendance à poser d’abord la première question qui nous vient à l’esprit. Au début, l’instinct est de se demander « comment puis-je abstraire cela ? ». Une séquence plus utile est la suivante :

  • Pourquoi ces deux éléments sont-ils identiques pour l’instant ?
  • Resteront-ils identiques, et pour les mêmes raisons ?

La deuxième question est plus difficile, car elle vous oblige à cesser de comparer la syntaxe pour commencer à comparer l’intention.

Des implémentations identiques peuvent modéliser des concepts différents

Prenez deux outils de mise en forme, un pour les utilisateurs :

formatUserName(user)

et un pour les formateurs :

formatTrainerName(trainer)

Supposons que tous deux contiennent actuellement le même contenu :

return `${firstName} ${lastName}`;

Ils sont alors fusionnés en un seul outil :

formatFullName(person)

Cela semble inoffensif tant que les règles sont identiques. Les noms d’utilisateurs peuvent nécessiter de prendre en compte :

  • les noms préférés
  • les paramètres de confidentialité
  • les règles de localisation

Les noms de formateurs peuvent nécessiter :

  • les titres professionnels
  • les certifications
  • des politiques spécifiques de nom d’affichage

Les deux fonctions originales auraient pu évoluer indépendamment l’une de l’autre sans heurts. L’outil générique, en revanche, suppose que les utilisateurs et les formateurs appartiennent au même type de « personne » aux fins de nommage, ce qui n’est plus vrai. C’est le risque moins évident de l’abstraction :

Une abstraction ne se contente pas de partager du code ; elle encode une affirmation sur la manière dont le domaine est structuré.

Lorsque cette affirmation est fausse, l’abstraction induit délibérément en erreur le développeur suivant, qui suppose à juste titre que tout ce qui porte le nom de formatFullName s’applique à chaque personne du système.

Les conséquences d’une abstraction prématurée apparaissent plus tard

L’abstraction prématurée est attrayante car tous les indicateurs visibles s’améliorent dès le jour de son introduction. Le fichier diminue de taille, passant d’un format comme :

100 lines

à :

60 lines

La duplication disparaît, la demande de fusion semble plus propre, et l’abstraction paraît élégante. Le coût est différé. Un an plus tard, quelqu’un doit modifier le comportement pour un utilisateur et découvre que ce composant partagé est utilisé par sept autres. Plutôt que de risquer de les endommager, ils créent une branche :

if (variant === "A") ...
else if (variant === "B") ...
else if (variant === "C") ...

Puis apparaît une autre propriété, un autre indicateur, un chemin de compatibilité pour un écran plus ancien. L’abstraction persiste, mais sa simplicité conceptuelle disparaît. C’est ainsi que les bases de code finissent par contenir des composants dont l’interface ressemble à ceci :

<UniversalThing
  mode="..."
  variant="..."
  type="..."
  compact
  showHeader
  showFooter
  enableSomething
  disableSomethingElse
/>

À ce stade, le composant cesse d’être une abstraction pour devenir un petit langage de configuration destiné à plusieurs cas d’usage non liés. Chaque combinaison de ces propriétés représente un état dont quelqu’un doit tenir compte, et la plupart de ces combinaisons n’ont jamais été testées.

La réutilisation peut rendre les changements plus difficiles, pas plus faciles

L’ironie réside dans le fait que du code partagé est créé pour rendre les changements moins coûteux, alors que le partage excessif en fait souvent le contraire. La raison en est le rayon d’impact : l’ensemble des éléments que peut affecter un seul changement.

Au préalable, chaque fonctionnalité disposait de son propre composant :

Feature A → Component A
Feature B → Component B

Par la suite, chaque fonctionnalité passe par un composant partagé :

Feature A ─┐
Feature B ─┼→ Shared Component
Feature C ─┘

Le deuxième diagramme présente moins de code dupliqué, mais un réseau de dépendances plus complexe. Ainsi, la comparaison pertinente n’est pas « quelle version comporte le moins de doublons ? », mais plutôt « quelle version rend le coût des modifications futures plus prévisible ? ». Lorsqu’une modification apportée à la fonctionnalité B doit faire l’objet de tests de régression par rapport aux fonctionnalités A et C, le composant partagé rend ces modifications moins prévisibles, même s’il réduit la longueur du code.

Pourquoi React incite à un partage prématuré

React rend l’ extraction de composants presque sans friction. Un bouton apparaît :

<Button />

et il devient réutilisable. Ensuite, une carte :

<Card />

Puis un modal :

Modal />

Ensuite, un champ de formulaire :

FormField />

Puis un hook personnalisé :

useSomething()

Bientôt, une bibliothèque de composants interne est utilisée dans toute l’application. Une grande partie d’elle est véritablement précieuse. Certaines éléments doivent clairement être partagés :

  • un bouton qui reflète de manière cohérente le système de conception de l’application
  • des primitives d’accessibilité de bas niveau, telles que la gestion du focus ou des étiquettes accessibles
  • des concepts de domaine qui sont réellement stables

Le réutilisement en soi n’est pas le problème. C’est plutôt le fait de réutiliser des éléments uniquement parce qu’ils ressemblent les uns aux autres qui pose problème. Les primitives UI partagées fonctionnent bien lorsqu’elles sont dépourvues de règles métier, un point également abordé dans la conception de composants en fonction des responsabilités plutôt que du réutilisement.

React Native multiplie les états

Les applications mobiles ajoutent une autre dimension. Deux écrans peuvent ressembler à s’y méprendre tout en ayant des besoins très différents en termes de cycle de vie. Un composant qui fonctionne bien sur un écran peut ensuite devoir faire face à :

  • le déplacement de l’application en arrière-plan
  • le comportement du clavier
  • la perte de connexion réseau
  • les différences entre les plateformes iOS et Android
  • les permissions
  • les liens profonds
  • les dimensions variables des appareils
  • l’état de navigation
  • le mode hors ligne

Si tout est conçu de manière générique dès le départ, le composant partagé finit par connaître chaque environnement dans lequel il pourrait être exécuté. Il devient « flexible », mais la flexibilité a un prix : chaque nouvelle option multiplie le nombre d’états possibles. Chacun de ces états est quelque chose que quelqu’un doit finalement comprendre, tester et maintenir. Deux options créent quatre combinaisons ; cinq en créent trente-deux.

Les services backend tombent dans le même piège

Ce n’est pas un problème exclusif au frontend. Imaginez deux services NestJS :

UserService
TrainerService

Initialement, ils peuvent exposer les mêmes opérations :

create()
findById()
update()
delete()

Une classe de base générique semble attrayante :

BaseService<T>

Parfois cela fonctionne et permet d’économiser du code. Mais dès que les règles métier pour les utilisateurs et les formateurs divergent, la classe de base commence à accumuler des exceptions. D’abord une vérification de type :

if (entityType === "user") ...

puis un crochet pouvant être surchargé avant les mises à jour :

protected beforeUpdate(...)

puis un autre après la création :

protected afterCreate(...)

Et progressivement, un ensemble complet de points d’extension dont le seul but est de faire en sorte qu’un service générique se comporte différemment selon le domaine. Cela substitue une complexité conditionnelle à du code dupliqué, ce qui est généralement beaucoup plus difficile à comprendre, car comprendre le comportement d’une entité signifie désormais lire la classe de base, ses points d’ancrage ainsi que toutes les surcharges ensemble. Si cela vous semble familier, la discussion sur la structuration des domaines NestJS avec les règles DDD offre une perspective complémentaire sur l’endroit où se situent les limites.

Ce n’est pas un argument en faveur du copier-coller

En conclure que la duplication est bénéfique serait tout aussi simpliste. La duplication a des coûts réels :

  • Si dix systèmes mettent en œuvre la même règle métier de manière indépendante, une correction de bug peut nécessiter dix modifications, et en omettre une entraîne un comportement incohérent.
  • Si vingt composants implémentent chacun le même comportement d’accessibilité, il devient très difficile de les maintenir cohérents.
  • Si plusieurs applications reposent sur le même contrat stable, partager ce contrat est extrêmement précieux.

Le point essentiel est plus restreint : la duplication et le couplage sont deux types différents de coûts. Une bonne ingénierie consiste à choisir le coût adapté au problème, plutôt qu’à minimiser systématiquement l’un d’eux.

Trois questions avant d’extraire une abstraction

Lorsque deux morceaux de code se ressemblent, prenez le temps d’examiner ces points avant de les fusionner.

Changent-ils pour la même raison ?

C’est le point le plus important. Si les exigences du produit ont tendance à changer ensemble pour A et B, le partage semble alors judicieux. Si A change en raison d’un facteur métier et B en raison d’un autre, l’implémentation identique actuelle n’est qu’une faible preuve qu’ils doivent être regroupés.

Signifient-ils la même chose ?

Le code peut être identique tout en ayant des sémantiques différentes, et c’est la sémantique qui évolue. Un prix et un solde de compte balance peuvent être des nombres simples, mais cela ne justifie pas pour autant de les fusionner en un seul alias partout :

type Amount = number;

La représentation est similaire, mais le sens ne l’est pas. Un prix peut intégrer des règles de change et fiscales, tandis qu’un solde comporte des limites de découvert. En les considérant comme des concepts distincts, même s’ils sont tous deux des nombres aujourd’hui, on maintient cette différence à un coût faible.

S’agit-il d’éliminer la duplication ou simplement de supprimer des lignes ?

Ces sont des résultats différents. Une bonne abstraction élimine les concepts redondants. Une mauvaise abstraction se contente de raccourcir les fichiers. Extraire 30 lignes de deux composants pour les placer dans une fonction d’aide de 40 lignes comportant huit propriétés n’améliore pas nécessairement quoi que ce soit ; la complexité s’est simplement déplacée et il faut maintenant gérer une interface supplémentaire.

La duplication délibérée est un choix légitime

Il est raisonnable de laisser deux morceaux de code séparés lorsqu’ils sont :

  • petits
  • simples
  • propres à évoluer de manière indépendante
  • ne protégeant pas une invariante critique
  • n’étant pas partie d’un concept de domaine partagé

La raison de les laisser tranquilles n’est pas un manque de compétence en matière d’abstractions ; c’est plutôt une compréhension du coût que représenterait une abstraction. Être capable d’examiner une répétition et de décider « pas encore » est un signe de maturité, non de paresse. Toute répétition n’est pas une dette technique. Parfois, c’est simplement de la répétition.

Lorsque la duplication devient un signe d’alerte

L’autre aspect est tout aussi important. Certaines duplications sont un signal clair qu’il convient de consolider :

  • la même règle métier complexe copiée en plusieurs endroits
  • trois applications chacune implémentant le même flux d’authentification
  • plusieurs équipes qui doivent respecter un même contrat API
  • un changement de règle qui exige de se souvenir de dix emplacements distincts

Dans de tels cas, la bonne question est : quelles connaissances sont dupliquées ? Cela importe bien plus que le nombre de lignes qui se répètent, car ce qu’il faut éviter de dupliquer, c’est la connaissance, et non le texte.

DRY concerne toujours les connaissances

« Don’t Repeat Yourself » est souvent interprété comme « ne jamais écrire deux fois le même code ». La formulation originale dans The Pragmatic Programmer porte sur les connaissances : chaque élément de connaissance doit disposer d’une représentation unique et officielle au sein d’un système. Ce sont des règles différentes.

Deux composants peuvent contenir du JSX similaire sans dupliquer de connaissances métier. En même temps, deux fonctions qui ne se ressemblent pas du tout peuvent chacune encoder la même règle métier, par exemple un seuil de remise codé en dur tant dans un composant de paiement que dans un validateur backend. Le deuxième cas est le plus dangereux, car il entraîne des écarts silencieux. Plutôt que de se demander si quelque chose peut être réutilisé, il faut se demander où ces connaissances devraient être stockées.

Laissez l’abstraction se définir d’elle-même

Il n’est pas nécessaire de concevoir une abstraction dès l’apparition de la duplication. Laisser la répétition exister temporairement et observer comment les copies évoluent constitue une stratégie valable. Si les deuxième et troisième cas d’usage continuent d’évoluer dans la même direction, la forme appropriée devient évidente. Le comportement partagé est alors découvert plutôt que inventé.

Cette différence est significative. Une abstraction extraite de trois cas d’usage véritablement similaires est généralement bien plus solide qu’une abstraction conçue à partir d’un seul cas d’usage afin de prendre en compte deux cas futurs hypothétiques. C’est la raison qui sous-tend l’heuristique bien connue du « règle des trois ». En d’autres termes :

Une abstraction basée sur ce que vous comprenez actuellement du code, et non sur ce que vous imaginez qu’il pourrait devenir.

L’objectif est un design adaptable, pas du code réutilisable

La réutilisation n’est pas en soi une mesure utile de la qualité architecturale. De meilleurs indicateurs sont :

  • la facilité avec laquelle le code est compréhensible
  • le degré d’isolement d’un changement typique
  • la prévisibilité de l’impact d’un changement
  • le fait que les concepts métier restent clairement séparés
  • le fait que les limites se situent en endroits pertinents
  • si le système peut évoluer sans crainte
  • Parfois, ces critères mènent à une abstraction partagée élégante. D’autres fois, ils conduisent à deux composants presque identiques côte à côte, et ce peut être une meilleure conception, car les deux n’ont jamais été la même chose. Ils se ressemblent simplement aujourd’hui.

    Points clés

    • Le partage de code crée une dépendance entre les utilisateurs ; considérez chaque extraction comme une décision indiquant que leurs futurs sont liés.
    • Jugez les candidats à l’abstraction en fonction de leurs raisons de changement et de leur signification, et non en fonction de leur similitude textuelle.
    • Les flags Prop, les branches variantes et les hooks pouvant être surchargés sont des signes qu’un élément partagé sert à des domaines non liés.
    • Dupliquez librement du code petit, simple et évoluant de manière indépendante ; consolidez plutôt les connaissances dupliquées telles que les règles métier, les contrats et les flux de sécurité.
    • Préférer les abstractions découvertes à partir de plusieurs cas d’usage réels plutôt que celles conçues pour des scénarios imaginaires.
    • Au moment de fusionner deux éléments similaires, demandez-vous si vous créez du réutilisement ou une relation, et si cette relation doit survivre aux lignes de code que vous sauvegardez.

    Lectures complémentaires