Rule of Three : pourquoi factoriser au premier doublon est souvent une mauvaise idée
Tu as appris à ne pas te répéter. Tu factorises dès que tu vois deux lignes similaires. Et six mois plus tard, ton abstraction te coûte plus cher que la duplication qu'elle remplaçait. La Rule of Three est ce qui sépare une bonne factorisation d'une fausse économie.
Le Briefing Dev - les ressources et actus de la semaine, droit dans ta boîte chaque vendredi gratuitement.
La plupart des développeurs juniors apprennent DRY avant tout autre principe. Don't Repeat Yourself. Trois mots qui sonnent comme un commandement. Le résultat : dès qu'un bout de code ressemble à un autre, on extrait une fonction, on crée une classe parente, on invente un paramètre pour gérer les deux cas. On a l'impression de bien faire.
Toi, tu as peut-être déjà vécu la suite. Six mois plus tard, le code "factorisé" doit gérer un troisième cas qui ne rentre pas dans le moule. Tu ajoutes un flag. Puis un autre. La fonction devient illisible. Le test qui devait être simple se met à échouer pour des raisons que personne ne comprend.
Le blocage n'est pas DRY. Le blocage, c'est de l'avoir appliqué trop tôt, sur des cas qui se ressemblaient en surface mais qui répondaient à des besoins différents. La Rule of Three est le garde-fou qui empêche cette erreur. Elle dit : attends d'avoir vu la même chose trois fois avant de l'abstraire.
Ce n'est pas un permis de copier-coller à l'infini. C'est une discipline qui te force à reconnaître la différence entre une coïncidence et un vrai pattern.
Le problème concret que tu rencontres
Imagine. Tu écris un module qui envoie un email de bienvenue à un nouvel inscrit. Quelques jours plus tard, on te demande d'envoyer aussi un email de réinitialisation de mot de passe. Le code se ressemble. Tu factorises dans une fonction sendEmail().
Trois semaines après, on ajoute un troisième email : une facture. Mais cette fois, il faut un PDF en pièce jointe. Tu ajoutes un paramètre attachment optionnel.
Puis arrive un quatrième cas : un email de notification interne qui doit logger l'événement dans un système d'audit. Tu ajoutes un paramètre auditLog. Puis un cinquième cas où le destinataire n'est pas un email mais un webhook Slack. Tu commences à comprendre que ta fonction sendEmail() n'envoie plus seulement des emails.
Ton abstraction est devenue un patchwork. Le bug récent dans l'envoi de factures vient d'une condition mal placée dans la fonction "générique" qui ne devrait même pas exister. Si tu avais attendu, tu aurais vu qu'il s'agissait de cinq cas distincts qui partagent trois lignes de code, pas d'un seul concept.
Le principe : trois passages avant d'abstraire
La Rule of Three vient de Martin Fowler, dans son livre Refactoring. La formulation tient en une phrase. La première fois que tu écris quelque chose, tu l'écris. La deuxième fois que tu écris quelque chose de similaire, tu acceptes la duplication. La troisième fois, tu refactorises.
L'idée derrière est statistique. Avec deux occurrences, tu n'as pas assez d'information pour savoir ce qui est vraiment commun entre elles. Tu vois deux blocs qui se ressemblent visuellement. Mais la ressemblance visuelle n'est pas une preuve de ressemblance conceptuelle.
Avec trois occurrences, tu peux comparer. Tu vois ce qui varie d'un cas à l'autre. Tu vois ce qui reste stable. Tu vois quels paramètres ton abstraction va devoir accepter. Tu vois aussi, parfois, que les trois cas ne devraient pas être factorisés du tout.
Le principe va plus loin que la simple duplication de code. Il s'applique à toute généralisation. Une classe abstraite, une configuration paramétrable, un système de plugins. Tant que tu n'as pas trois cas concrets, tu construis sur des suppositions. C'est exactement ce que YAGNI reproche aux ingénieurs qui anticipent des besoins qui n'arriveront jamais.
Pourquoi la duplication est parfois moins chère que l'abstraction
Sandi Metz, une figure reconnue du monde Ruby, le formule autrement : duplication is far cheaper than the wrong abstraction. Une mauvaise abstraction coûte plus cher qu'une duplication assumée.
La raison est mécanique. Quand deux blocs de code dupliqués divergent dans le futur, tu les modifies indépendamment, sans conséquence sur l'autre. Quand deux usages partagent une abstraction qui ne devrait pas exister, chaque modification de l'un casse l'autre. Tu ajoutes des conditions, des flags, des paramètres optionnels. La fonction grossit. Elle devient un point de friction permanent.
Pire, supprimer une mauvaise abstraction est rarement fait. Une fois que dix endroits dépendent d'une fonction mutualisée, démêler ce qui est vraiment partagé de ce qui est devenu spécifique demande un travail que personne n'a le temps de faire. La fausse économie initiale se transforme en dette technique permanente.
Le bon réflexe au début d'un projet est donc plutôt l'inverse de ce qu'on apprend. Duplique, observe, attends. La factorisation arrive quand le pattern est devenu évident, pas quand tu le devines.
Un exemple en JavaScript
Tu travailles sur une application qui doit valider des formulaires. Premier formulaire : inscription. Deuxième formulaire : édition du profil. Les deux vérifient que l'email est valide et que le mot de passe fait au moins 8 caractères.
Premier réflexe DRY : créer une fonction validateUserForm(). Mauvais réflexe. Tu n'as que deux cas. Garde la duplication.
// Inscription
function validateSignup(data) {
if (!data.email.includes('@')) return 'Email invalide'
if (data.password.length < 8) return 'Mot de passe trop court'
if (!data.terms) return 'Conditions non acceptees'
return null
}
// Edition de profil
function validateProfile(data) {
if (!data.email.includes('@')) return 'Email invalide'
if (data.password.length < 8) return 'Mot de passe trop court'
return null
}
Trois mois plus tard, un troisième formulaire apparaît : la connexion. Lui aussi a besoin de valider email et mot de passe. Maintenant tu as trois cas. La règle s'applique : tu peux factoriser.
function validateCredentials(data) {
if (!data.email.includes('@')) return 'Email invalide'
if (data.password.length < 8) return 'Mot de passe trop court'
return null
}
function validateSignup(data) {
const error = validateCredentials(data)
if (error) return error
if (!data.terms) return 'Conditions non acceptees'
return null
}
La factorisation est claire parce que tu as vu trois cas réels. Tu sais exactement ce qui est commun (email + mot de passe), ce qui ne l'est pas (les conditions générales, propres à l'inscription). Si tu avais factorisé au deuxième cas, tu aurais probablement inclus la vérification des conditions générales dans la fonction commune, ou ajouté un paramètre checkTerms qui aurait pollué les autres usages.
Un exemple en Python
Tu écris un script qui exporte des données vers un fichier CSV. La semaine suivante, un autre besoin arrive : exporter ces mêmes données vers du JSON. La tentation est de créer immédiatement une classe Exporter abstraite avec deux implémentations.
def export_to_csv(data, path):
with open(path, 'w') as f:
for row in data:
f.write(','.join(str(v) for v in row.values()) + '\n')
def export_to_json(data, path):
import json
with open(path, 'w') as f:
json.dump(data, f)
Garde ça. Ne crée pas de classe abstraite. Tu n'as pas assez d'information sur ce qui va vraiment varier entre les formats. Le CSV fait du streaming ligne par ligne, le JSON sérialise en bloc. Les modes d'écriture sont différents, les gestions d'erreur seront différentes.
Quand un troisième format apparaît, par exemple XML ou Excel, tu pourras regarder les trois fonctions ensemble et décider si une abstraction a du sens, et laquelle. Souvent tu découvriras qu'il vaut mieux extraire seulement la partie "ouvrir un fichier et écrire dedans", pas tout le processus d'export. Ce genre de découverte n'est possible qu'en présence de trois cas concrets.
Les pièges à éviter
Confondre similarité visuelle et similarité conceptuelle
Deux blocs de code qui se ressemblent peuvent répondre à des règles métier complètement différentes. Le calcul de TVA pour un produit et le calcul d'une remise client peuvent avoir la même structure (un pourcentage appliqué à un montant), mais ils évoluent indépendamment. Les factoriser revient à coupler deux concepts qui n'auraient jamais dû l'être.
Compter le nombre de lignes au lieu du nombre de cas
Si tu as cinquante lignes dupliquées entre deux fichiers, ça reste deux cas. La Rule of Three compte les contextes d'usage, pas les caractères copiés. Une grosse duplication entre deux endroits peut être plus saine qu'une mini-abstraction prématurée entre trois endroits qui n'ont rien à voir.
Empiler les paramètres pour faire rentrer un nouveau cas
Si tu as une fonction qui prend déjà six paramètres et qu'un septième cas arrive, ne lui ajoute pas un huitième flag. C'est le signal que ton abstraction initiale était mauvaise. Soit tu la divises, soit tu reviens à de la duplication. Un drapeau booléen optionnel dans une fonction est presque toujours le symptôme d'une factorisation prématurée.
Utiliser la règle comme excuse pour ne jamais refactoriser
La Rule of Three n'est pas un permis de laisser pourrir le code. Une fois que tu as trois cas, tu refactorises. Ne pas le faire revient à transformer ton code en collection de variations qui finiront par diverger silencieusement, avec les bugs qui vont avec. Le principe Boy Scout Rule reste valable : quand tu passes dans un endroit dupliqué pour la troisième fois, c'est le moment d'agir.
Ignorer le contexte du projet
Dans certains contextes, la duplication coûte cher dès la deuxième occurrence. Une règle de sécurité, un calcul fiscal, une logique de paiement. Si une duplication peut diverger silencieusement avec des conséquences graves, mieux vaut factoriser tôt. La Rule of Three est un guide par défaut, pas une loi absolue.
Comment intégrer la règle dans ton quotidien
La règle s'applique mécaniquement. Quand tu écris du code qui ressemble à un code déjà écrit, marque le mentalement. Tu peux laisser un commentaire // duplication possible avec X pour ne pas oublier.
Quand tu écris la même chose une troisième fois, fais une pause. Regarde les trois implémentations côte à côte. Pose-toi trois questions. Qu'est-ce qui est strictement identique ? Qu'est-ce qui varie ? Est-ce que les trois cas évoluent vraiment ensemble dans le futur ?
Si les réponses sont claires, factorise. Sinon, garde la duplication un cycle de plus. Mieux vaut une abstraction qui arrive en retard qu'une abstraction qui arrive trop tôt. Cette mécanique se travaille en équipe et fait partie des automatismes qu'on développe dans nos parcours, notamment dans la formation PHP orienté objet et dans le cursus développement backend, où la question "quand factoriser" revient à chaque exercice de conception.
La Rule of Three face aux autres principes
La Rule of Three n'annule pas DRY. Elle en règle le tempo. DRY te dit que la duplication est un risque. La Rule of Three te dit à partir de quel moment ce risque devient supérieur à celui d'une mauvaise abstraction.
Elle complète aussi KISS. Garder une duplication temporaire est souvent plus simple que créer une abstraction qui rajoute un niveau d'indirection. Tant qu'il n'est pas justifié, ce niveau d'indirection rend le code plus difficile à lire pour le prochain développeur qui débarque.
Et elle s'aligne avec Composition over Inheritance sur un point fondamental. Beaucoup de hiérarchies de classes héritées de la POO classique ont été conçues sur la base de deux exemples seulement. Le troisième cas a ensuite forcé à empiler des sous-classes, des overrides, des hooks. Une simple composition aurait suffi, mais l'héritage avait déjà été choisi.
À retenir
La duplication n'est pas un péché. Une mauvaise abstraction l'est davantage. La Rule of Three te donne un seuil simple : trois cas avant d'abstraire. Avant ça, tu n'as pas assez d'information pour faire un choix solide.
Cette discipline t'évite l'un des pièges les plus coûteux du développement : créer une généralisation sur deux exemples, puis passer les années suivantes à la maintenir en vie à coup de paramètres et de conditions. Quand tu factorises au bon moment, ton code se simplifie. Quand tu factorises trop tôt, il se rigidifie.