12 - Abstractions prematurees vs tardives
Ce que tu vas apprendre
- Pourquoi une mauvaise abstraction coûte plus cher que la duplication
- La regle des trois en pratique avec des exemples concrets
- Les signes qu'une abstraction est prematuree ou tardive
- Le piège du dossier
utils/ - Comment refactorer vers une abstraction au bon moment
Prerequisites
Cet article fait suite a 11 - Complexite cyclomatique. Les principes de DRY, KISS, YAGNI sont directement lies a ce sujet.
J'ai hérité d'un projet qui avait un fichier utils/helpers.ts de 2400 lignes. Il contenait 87 fonctions. Certaines etaient utilisees partout. D'autres, par un seul fichier. Quelques-unes par personne. Ce fichier etait la poubelle du projet -- l'endroit ou on mettait tout ce qui n'avait pas de maison. C'est le symptome classique de l'abstraction prematuree : on créé un conteneur générique avant de comprendre ce qu'on y met.
Le coût réel d'une mauvaise abstraction
Sandi Metz l'a formule de facon limpide : le coût d'une mauvaise abstraction est supérieur au coût de la duplication. Pourquoi ? Parce que la duplication est explicite et locale. Tu la vois. Tu sais ou elle est. Tu peux la corriger quand tu veux.
Une mauvaise abstraction, elle, se propage. Chaque nouveau consommateur depend d'elle. Chaque modification affecte tous les consommateurs. Et la defaire demande de retrouver toutes les raisons pour lesquelles elle existe.
typescript// Abstraction prematuree : un "generique" qui ne generalise rien
function fetchAndProcess<T>(
url: string,
processor: (data: unknown) => T,
options?: {
retry?: boolean;
cache?: boolean;
transform?: boolean;
validate?: boolean;
logLevel?: "debug" | "info" | "warn";
}
): Promise<T> {
// 80 lignes de code qui gerent toutes les combinaisons d'options
}
// Utilisation reelle : tous les appelants passent les memes options
const users = await fetchAndProcess("/api/users", parseUsers, {
retry: true, cache: false, validate: true
});
const products = await fetchAndProcess("/api/products", parseProducts, {
retry: true, cache: false, validate: true
});
Les options cache, transform, et logLevel ont ete ajoutees "au cas ou". Personne ne les utilise. Elles compliquent le code et les tests pour rien.
La regle des trois, en vrai
On en a parle dans l'article sur DRY. Voyons comment ca se passe dans un vrai projet.
typescript// Sprint 1 : le service de commande envoie un email
async function createOrder(order: Order): Promise<void> {
await saveOrder(order);
await sendEmail({
to: order.userEmail,
subject: "Commande confirmee",
body: `Votre commande #${order.id} est confirmee.`,
});
}
// Sprint 3 : le service d'inscription envoie aussi un email
async function registerUser(user: User): Promise<void> {
await saveUser(user);
await sendEmail({
to: user.email,
subject: "Bienvenue",
body: `Bonjour ${user.name}, bienvenue sur la plateforme.`,
});
}
Deux cas. Je vois la ressemblance mais les sujets sont différents, les corps sont différents, les contextes métier sont différents. Je duplique. Pas de helper.
typescript// Sprint 5 : le service de paiement envoie un email de recu
async function processPayment(payment: Payment): Promise<void> {
await chargeCard(payment);
await sendEmail({
to: payment.userEmail,
subject: "Recu de paiement",
body: `Paiement de ${payment.amount}EUR confirme.`,
});
}
Troisieme occurrence. Le pattern est clair : envoyer un email transactionnel apres une action. Maintenant j'abstrait.
typescript// L'abstraction qui emerge naturellement
interface TransactionalEmail {
to: string;
subject: string;
body: string;
}
async function sendTransactionalEmail(email: TransactionalEmail): Promise<void> {
await sendEmail(email);
await logEmailSent(email.to, email.subject);
}
L'abstraction est petite, ciblee, et justifiee par trois cas réels. Elle n'essaie pas d'anticiper des besoins futurs.
Le piège du dossier utils/
Le dossier utils/ est ou les abstractions vont mourir. Il attire les fonctions orphelines comme un trou noir.
Le problème : utils/ n'a pas de semantique. Ca veut dire "tout et n'importe quoi". Un fichier utils/format.ts qui contient formatDate, formatCurrency, et formatPhoneNumber melange trois domaines différents.
// Structure typique d'un projet qui a deraille
src/
utils/
helpers.ts // 2400 lignes, 87 fonctions
formatters.ts // 600 lignes, dates + monnaie + texte
validators.ts // 400 lignes, email + phone + address + order
misc.ts // le fichier de la honte
La solution : chaque fonction "utilitaire" appartient a un domaine. formatDate va dans un module de dates ou dans le domaine qui l'utilise. validateEmail va dans le module utilisateur ou dans un module de validation d'email.
// Structure orientee domaine
src/
orders/
order.service.ts
order.validation.ts // valide les commandes
users/
user.service.ts
user.validation.ts // valide les utilisateurs
shared/
date.ts // formatDate, parseDate (utilise partout)
money.ts // formatCurrency, convertCurrency
Si une fonction est utilisee par un seul module, elle va dans ce module. Si elle est utilisee par trois modules ou plus, elle va dans shared/ avec un nom qui dit ce qu'elle fait.
Signes d'une abstraction prematuree
Tu as probablement une abstraction prematuree quand :
- La fonction a des paramètres booleens pour "désactiver" des parties
- L'interface a des méthodes que certains implementeurs laissent vides
- Tu as besoin de lire l'implementation pour comprendre le contrat
- Un seul endroit du code utilise cette abstraction
- Tu as écrit l'abstraction avant le deuxieme cas d'utilisation
typescript// Signe : parametre booleen qui desactive une partie
function processData(data: Data, skipValidation = false): Result {
if (!skipValidation) {
validate(data);
}
// ... le reste
}
// Pourquoi ce parametre existe ? Parce que l'abstraction ne colle pas
// a tous les cas. Deux fonctions seraient plus claires.
Signes d'une abstraction tardive
L'abstraction tardive est moins dangereuse mais a un coût aussi :
- Tu corriges le meme bug a trois endroits différents
- Un changement de regle métier te force a modifier cinq fichiers
- Des blocs de code de 20+ lignes sont copies-colles avec des variations mineures
- L'équipe se trompe régulièrement sur "quel est le bon endroit" pour un changement
typescript// Trois fichiers, trois calculs de prix TTC legerement differents
// Chacun a ete corrige independamment, et maintenant ils divergent
// order.service.ts
const ttc = ht * 1.20; // TVA 20%
// invoice.service.ts
const ttc = ht * 1.196; // quelqu'un a "corrige" un arrondi
// quote.service.ts
const ttc = ht + (ht * 0.2); // meme calcul, formulation differente
Le bon moment pour extraire etait quand le deuxieme fichier a copie le calcul. Maintenant, il faut retrouver tous les endroits, comprendre les divergences, et unifier.
Refactorer vers une abstraction
Le processus que je suis est en quatre étapes.
Première étape : identifier les occurrences. Je cherche les patterns repetes dans le code. Les outils comme SonarQube detectent les blocs dupliques, mais mon cerveau est souvent meilleur pour identifier les duplications de connaissance (pas juste de texte).
Deuxieme étape : lister les variations. Chaque occurrence fait-elle exactement la meme chose ? Quelles sont les différences ? Les différences sont-elles accidentelles (bugs) ou intentionnelles (besoins différents) ?
Troisieme étape : définir le contrat minimal. L'abstraction ne doit couvrir que ce qui est commun. Les variations deviennent des paramètres ou des stratégies.
Quatrieme étape : remplacer progressivement. Un consommateur a la fois. Pas un big bang. On en reparle en détail dans l'article sur les techniques de refactoring.
Plus de retours d'experience sur les erreurs d'abstraction en projet réel sur paltemps.fr.
Résumé
- Une mauvaise abstraction coûte plus cher que la duplication
- La regle des trois : duplique deux fois, abstrait a la troisieme occurrence
- Le dossier
utils/est un piège -- chaque fonction appartient a un domaine - Les paramètres booleens qui desactivent du code sont un signe d'abstraction forcee
- L'abstraction tardive se réparé ; l'abstraction prematuree se subit
- Refactorer vers une abstraction se fait par étapes, un consommateur a la fois
Article précédent : 11 - Complexite cyclomatique
Article suivant : 13 - Code smells