Les Bloaters : ces code smells qui signalent un code devenu trop gros
Du code facile à changer, épisode 2
Personne ne décide un matin d’écrire une méthode de trois cents lignes.
Au départ, elle en faisait vingt.
Puis il y a eu un cas particulier à gérer, un if de plus, une urgence la veille d’une mise en production.
Pris tout seul, chaque ajout était raisonnable.
C’est comme ça que naissent les Bloaters : du code qui a grossi petit à petit, jusqu’à devenir pénible à manipuler. C’est une famille de code smells que je vois bien trop souvent, aussi bien auprès de mes étudiants que dans les entreprises.
Dans l’épisode précédent, j’ai proposé une grille de lecture : un smell est un symptôme. Il révèle un problème de conception, et il annonce un coût potentiel le jour où il faudra changer le code.
Je vais donc te présenter chaque Bloater de la même façon : ce qu’on voit, le problème de conception en dessous, et ce que ça risque de coûter.
La famille compte cinq membres. Les deux premiers sont faciles à repérer, parce qu’ils se mesurent en nombre de lignes. Les trois suivants sont plus discrets, et je pense que ce sont ceux qu’il est le plus utile de savoir repérer.
Long Method : le parchemin
Une Long Method, c’est une méthode qu’on ne peut pas lire sans faire défiler l’écran.
Essaie de te souvenir, dans ta carrière c’est quoi la méthode la plus longue que tu aies eu à lire ?
De mon côté c’est facile, celle-là je ne risque pas de l’oublier !
Imagine une méthode qui prend plus de 10 paramètres, avec des noms qui indiquent le type de chacun, par exemple pd_price, pi32_quantity, ps_nom, ps_email (p pour paramètre, d pour décimal, i32 pour int32, s pour string),
et qui mélange la validation, le calcul, la mise à jour du stock et l’envoi d’emails.
Je ne souhaite pas pointer qui que ce soit du doigt donc je vais rester vague, mais la méthode à laquelle je pense faisait exactement ça. Une série de traitements métier, avec des instanciations de connexion à la BDD, des envois d’email, la totale.
Il y avait même un traitement métier dont la moindre erreur autorisait les clients à demander le remboursement de ce qu’ils avaient payé à l’entreprise ! Un jour je te raconterai comment j’ai extrait cette partie du traitement métier pour la rendre testable, et comment ça a simplifié la maintenance du code.
Évidemment, aucun test !
Pour la longueur, cette méthode était tellement longue que ça faisait ramer Visual Studio de l’ouvrir. 24k LoC. Je te le remets en lettres pour pas que tu ne croies que c’est une typo : vingt-quatre mille lignes de code !
Le plus bel exemple du smell Long Method que j’aie jamais vu.
Pour expliquer pourquoi c’est un problème, imagine que tu vas devoir comprendre cette méthode pour corriger un bug ou ajouter une fonctionnalité. Chaque modification devient un risque, et chaque test un cauchemar.
class OrderService {
constructor(private db: Database, private mailer: Mailer) {}
placeOrder(cart: Cart, customer: Customer): Receipt {
// Validation
if (cart.items.length === 0) throw new Error('Panier vide');
if (!customer.email.includes('@')) throw new Error('Email invalide');
// Calcul du prix
let total = 0;
for (const item of cart.items) total += item.price * item.quantity;
if (customer.orders > 10) total *= 0.9;
if (cart.coupon === 'RENTREE') total -= 5;
if (total < 0) total = 0;
const vat = total * 0.2;
// Mise à jour du stock
for (const item of cart.items) {
this.db.decrementStock(item.sku, item.quantity);
}
// Confirmation au client
this.mailer.send(customer.email, `Merci ! Total : ${(total + vat).toFixed(2)} €`);
return { total, vat };
}
// Et dans la même classe : refund(), exportOrdersCsv(), sendReminders(),
// computeLoyaltyPoints(), generateInvoicePdf()...
}public class OrderService(Database db, Mailer mailer)
{
public Receipt PlaceOrder(Cart cart, Customer customer)
{
// Validation
if (cart.Items.Count == 0) throw new InvalidOperationException("Panier vide");
if (!customer.Email.Contains('@')) throw new ArgumentException("Email invalide");
// Calcul du prix
decimal total = 0;
foreach (var item in cart.Items) total += item.Price * item.Quantity;
if (customer.Orders > 10) total *= 0.9m;
if (cart.Coupon == "RENTREE") total -= 5;
if (total < 0) total = 0;
var vat = total * 0.2m;
// Mise à jour du stock
foreach (var item in cart.Items)
db.DecrementStock(item.Sku, item.Quantity);
// Confirmation au client
mailer.Send(customer.Email, $"Merci ! Total : {total + vat:F2} €");
return new Receipt(total, vat);
}
// Et dans la même classe : Refund(), ExportOrdersCsv(), SendReminders(),
// ComputeLoyaltyPoints(), GenerateInvoicePdf()...
}J’ai raccourci l’exemple pour qu’il tienne à l’écran. Dans un vrai projet, chacun de ces blocs peut facilement faire trente lignes.
Un signe qui trompe rarement, ce sont les commentaires qui servent de titres : // Validation, // Calcul du prix.
Mon astuce dans ces cas-là ? Quand j’ai envie de donner un nom à un bloc de code,
c’est souvent le signe qu’il mérite sa propre méthode. Et au lieu d’un commentaire, j’exprime cette intention dans le nom de la méthode.
Le métier demande : “les livres passent à une TVA de 5,5 %”.
La modification concerne une seule ligne. Mais pour la trouver, il faut lire toute la méthode. Et pour vérifier le résultat, il faut une base de données et un serveur de mail, parce que le calcul du prix est coincé entre la mise à jour du stock et l’envoi de la confirmation.
Avoir besoin d’un serveur de mail pour tester un taux de TVA, c’est lent, coûteux à chaque appel et franchement pénible !
Le problème de conception : quatre responsabilités dans la même méthode. Valider, calculer, mettre à jour le stock, notifier. (Dans l’exemple, j’ai associé la longueur à un mélange de responsabilités dans la même méthode, parce que de mon expérience, plus une méthode est longue, plus elle a de chances de cumuler les responsabilités.)
Le coût potentiel : une compréhension difficile, et un risque de régressions, puisqu’on ne peut rien tester séparément.
Le remède s’appelle Extract Method : on donne un nom à chaque bloc.
placeOrder(cart: Cart, customer: Customer): Receipt {
this.validate(cart, customer);
const receipt = priceOf(cart, customer);
this.reserveStock(cart);
this.confirm(customer, receipt);
return receipt;
}
// pricing.ts : aucune dépendance, testable sans base ni mail
export function priceOf(cart: Cart, customer: Customer): Receipt {
let total = 0;
for (const item of cart.items) total += item.price * item.quantity;
if (customer.orders > 10) total *= 0.9;
if (cart.coupon === 'RENTREE') total -= 5;
if (total < 0) total = 0;
return { total, vat: total * 0.2 };
}public Receipt PlaceOrder(Cart cart, Customer customer)
{
Validate(cart, customer);
var receipt = Pricing.PriceOf(cart, customer);
ReserveStock(cart);
Confirm(customer, receipt);
return receipt;
}
// Pricing.cs : aucune dépendance, testable sans base ni mail
public static class Pricing
{
public static Receipt PriceOf(Cart cart, Customer customer)
{
decimal total = 0;
foreach (var item in cart.Items) total += item.Price * item.Quantity;
if (customer.Orders > 10) total *= 0.9m;
if (cart.Coupon == "RENTREE") total -= 5;
if (total < 0) total = 0;
return new Receipt(total, total * 0.2m);
}
}La méthode principale se fait maintenant en cinq lignes. Et le calcul du prix ne dépend plus de rien : on peut tester la TVA des livres en une ligne, sans base de données et sans serveur de mail.
Large Class : le couteau suisse
Une Large Class, c’est la même histoire un étage au-dessus : une classe qui fait trop de choses.
Regarde le commentaire à la fin du premier exemple.
OrderService passe des commandes, mais elle rembourse aussi, exporte en CSV, envoie des relances, calcule des points de fidélité et génère des factures en PDF.
Quelques signes qui trompent rarement :
- Le nom se termine par
Service,ManagerouUtils. Un nom aussi vague accepte n’importe quelle méthode. - Certains champs ne servent qu’à une partie des méthodes. Le
mailerne sert qu’à la confirmation et aux relances. - Tout le monde modifie ce fichier, pour des raisons qui n’ont rien à voir entre elles. C’est en général le champion des conflits de merge, et des bugs inattendus.
Le problème de conception : plusieurs responsabilités sans rapport dans la même classe. C’est un problème de cohésion, et j’y ai consacré un article entier.
Le coût potentiel : une compréhension difficile. Pour modifier le remboursement, il faut d’abord trier ce qui le concerne parmi mille lignes qui parlent d’autre chose.
Le remède s’appelle Extract Class.
On vient d’en faire la première étape sans le dire : le calcul du prix a quitté OrderService pour vivre dans son propre fichier.
On continue de la même façon, une responsabilité à la fois : l’export, les relances, les factures.
Détecter ces classes problématiques c’est d’ailleurs une des “killer-features” de Barad-Dur.
Long Parameter List : la liste de courses
Une Long Parameter List, c’est une fonction qui demande tellement d’arguments qu’on ne sait plus lequel est lequel.
function createShipment(
email: string,
street: string,
postalCode: string,
city: string,
country: string,
weight: number,
price: number,
currency: string,
express: boolean,
): Shipment {
if (!/^\d{5}$/.test(postalCode)) throw new Error('Code postal invalide');
// ...
}
createShipment('marie@exemple.fr', '12 rue des Lilas', '67000', 'Strasbourg', 'FR', 2.5, 49.9, 'EUR', false);public Shipment CreateShipment(
string email,
string street,
string postalCode,
string city,
string country,
decimal weight,
decimal price,
string currency,
bool express)
{
if (!Regex.IsMatch(postalCode, @"^\d{5}$"))
throw new ArgumentException("Code postal invalide");
// ...
}
CreateShipment("marie@exemple.fr", "12 rue des Lilas", "67000", "Strasbourg", "FR", 2.5m, 49.9m, "EUR", false);Regarde seulement l’appel, sur la dernière ligne.
Que vaut 2.5 ? Et 49.9 ? Et ce false tout au bout, il désactive quoi ?
Voici maintenant le genre de modification qui passe en revue de code sans que personne ne bronche.
createShipment('marie@exemple.fr', '12 rue des Lilas', '67000', 'Strasbourg', 'FR', 49.9, 2.5, 'EUR', false);CreateShipment("marie@exemple.fr", "12 rue des Lilas", "67000", "Strasbourg", "FR", 49.9m, 2.5m, "EUR", false);Un colis de 49,9 kg, facturé 2,50 €. Le compilateur n’a rien dit, puisque ce sont deux nombres.
Le problème de conception : la fonction reçoit des valeurs en vrac, alors qu’elles forment des groupes qui ont un sens pour le métier.
Le coût potentiel : un risque de régressions à chaque appel, et il y en a rarement un seul.
Le remède dépend de ce que cache la liste.
Pour le booléen final, c’est simple : on le remplace par une valeur qui dit ce qu’elle fait, 'standard' ou 'express'.
Pour le reste, il faut regarder les deux smells suivants, parce qu’ils montrent pourquoi la liste est si longue.
Data Clumps : les inséparables
Des Data Clumps, ce sont des données qui se déplacent toujours en groupe.
Dans la signature précédente, street, postalCode, city et country ne voyagent jamais l’un sans l’autre.
On les retrouve à l’identique dans printLabel, dans computeFee et dans le formulaire de commande.
Même chose pour price et currency.
Martin Fowler propose un test très simple : si on retire une des valeurs du groupe, est-ce que les autres ont encore un sens ? Un code postal sans son pays, non. Un montant sans sa devise, non plus.
Le problème de conception : un concept du métier existe dans la tête de tout le monde, mais pas dans le code. Ici, une adresse et un montant.
Le coût potentiel : de multiples changements. Le jour où une adresse gagne un champ, il faut modifier toutes les signatures qui transportent le groupe. Si on veut comparer deux adresses, il faut comparer tous leurs champs à chaque fois. Et cette vérification va probablement se répéter partout, et devenir source d’erreurs.
Le remède s’appelle Introduce Parameter Object : on donne un type au groupe.
type Address = { street: string; postalCode: string; city: string; country: string };
type Money = { amount: number; currency: string };
function createShipment(
email: string,
destination: Address,
weight: number,
price: Money,
service: 'standard' | 'express',
): Shipment { /* ... */ }public sealed record Address(string Street, string PostalCode, string City, string Country);
public sealed record Money(decimal Amount, string Currency);
public Shipment CreateShipment(
string email,
Address destination,
decimal weight,
Money price,
ShippingService service) { /* ... */ }Neuf paramètres sont devenus cinq.
Et au passage, l’inversion du poids et du prix ne compile plus : l’un est un nombre, l’autre un Money.
Primitive Obsession : quand tout est un string
La Primitive Obsession, c’est l’habitude de représenter un concept du métier par un type de base : un string pour un email, un nombre pour un poids, un prix, un âge…
C’est le code smell dont je parle le plus souvent, car il est extrêmement fréquent, et j’ai pu observer qu’apprendre à le corriger a de nombreux bénéfices. (Dis-le-moi si tu veux un article dédié ;) )
L’adresse a maintenant son type.
Mais regardons ce qu’elle contient : le code postal est toujours un string.
Sa règle de validation, elle, traîne dans createShipment.
Et aussi dans le formulaire de commande, dans l’import CSV des clients et dans l’impression des étiquettes.
Le métier annonce : “on livre en Belgique le mois prochain”.
Les codes postaux belges ont quatre chiffres.
Il faut donc retrouver toutes les copies de la règle.
Certaines sont une expression régulière, d’autres un length === 5, et il y en a toujours une qu’on oublie.
Ce jour-là, les clients belges peuvent commander, mais leurs étiquettes ne s’impriment pas.
Le poids pose le même genre de question : 2.5, ce sont des kilos ou des grammes ?
Rien ne le dit, et chaque fonction qui le reçoit doit le deviner.
Un exemple célèbre de bug lié à Primitive Obsession, c’est la sonde spatiale Mars Climate Orbiter, qui s’est écrasée parce que des unités de mesure ont été confondues : le logiciel de navigation utilisait des livres-force alors que le reste du système attendait des newtons.
Le problème de conception : la règle d’un concept n’est écrite à aucun endroit précis. Elle est donc recopiée partout où la valeur arrive.
Le coût potentiel : de multiples changements, et un risque de régressions.
Le remède s’appelle Replace Primitive with Object : le concept reçoit son propre type, et ce type emporte sa règle avec lui.
class PostalCode {
private constructor(private readonly value: string) {}
static of(value: string, country: Country): PostalCode {
const format = country === 'BE' ? /^\d{4}$/ : /^\d{5}$/;
if (!format.test(value)) throw new Error(`Code postal invalide : ${value}`);
return new PostalCode(value);
}
toString(): string {
return this.value;
}
}
class Weight {
private constructor(readonly grams: number) {}
static kilograms(value: number): Weight {
return new Weight(Math.round(value * 1000));
}
}
function createShipment(
recipient: Email,
destination: Address,
parcel: Weight,
price: Money,
service: 'standard' | 'express',
): Shipment { /* ... */ }public sealed record PostalCode
{
private readonly string _value;
private PostalCode(string value) => _value = value;
public static PostalCode Of(string value, Country country)
{
var format = country == Country.BE ? @"^\d{4}$" : @"^\d{5}$";
if (!Regex.IsMatch(value, format))
throw new ArgumentException($"Code postal invalide : {value}");
return new PostalCode(value);
}
public override string ToString() => _value;
}
public sealed record Weight
{
public int Grams { get; }
private Weight(int grams) => Grams = grams;
public static Weight Kilograms(decimal value) => new((int)Math.Round(value * 1000));
}
public Shipment CreateShipment(
Email recipient,
Address destination,
Weight parcel,
Money price,
ShippingService service) { /* ... */ }L’Email est construit sur le même modèle que le PostalCode, et l’Address contient maintenant un PostalCode.
L’arrivée de la Belgique se règle à un seul endroit. Un poids dit dans quelle unité il est exprimé. Et on ne peut plus passer une chaîne quelconque là où un code postal est attendu : le seul moyen d’en obtenir un, c’est de passer par la validation.
Un piège si tu écris du TypeScript : type Email = string ne protège de rien.
C’est un simple alias, et le compilateur accepte n’importe quelle chaîne à sa place.
Deux classes qui exposent les mêmes champs publics sont elles aussi interchangeables : un PostalCode passerait pour un Email sans la moindre erreur.
C’est le champ privé qui rend le type unique, et c’est pour ça que value l’est dans l’exemple.
En C#, la question ne se pose pas : deux types qui portent des noms différents sont toujours différents.
C’est la même idée que dans l’article sur le typestate : on fait porter une règle par un type, pour que le compilateur la vérifie à notre place.
Tu manipules des objets qui ont un sens pour le métier, comme des Email, des PostalCode, des numéros de sécurité sociale ?
Alors crée des types pour eux ! Tu gagneras en sécurité et en clarté, tu n’auras plus besoin de vérifier plusieurs fois la validité de l’objet et le compilateur t’aidera à ne plus les confondre.
Que du bénef !
Attention aux faux positifs
Maintenant, j’aimerais apporter un peu de nuance, car tous les smells ne sont pas forcément des problèmes à corriger immédiatement.
Un smell signale un problème potentiel. Voici quelques cas où ça sent fort, alors que tout va bien, et quelques remèdes qui sont pires que le mal.
- La méthode longue mais simple. Soixante lignes qui recopient quarante champs d’un objet vers un autre, c’est long, mais ça ne fait qu’une seule chose. La découper n’apporterait rien. (Par contre, peut-être qu’il y a un intérêt à découper les objets en question en sous-objets plus cohésifs.)
- Le découpage pour le découpage. Dix fonctions de trois lignes réparties dans dix fichiers : le smell a disparu, mais la difficulté de compréhension est toujours là. Elle a même augmenté, puisqu’il faut maintenant sauter d’un fichier à l’autre.
- L’objet fourre-tout. Ranger les neuf paramètres dans un objet
optionsraccourcit la signature sans rien régler. Le groupe n’a toujours aucun sens pour le métier. - Le type pour le type. Un commentaire libre ou un libellé peuvent très bien rester des chaînes de caractères. Je crée un type quand il y a une règle, une unité, ou un risque de confondre deux valeurs.
- Les frontières. Un JSON reçu d’une API ou une ligne de base de données contiennent forcément des types de base. C’est normal. On les convertit à l’entrée, une fois. Idéalement avec la technique “Parse, don’t validate”.
Le deuxième et le troisième cas ont un point commun : on a fait disparaître le symptôme sans s’occuper du problème.
La question à se poser n’est donc pas “est-ce que cette méthode est trop longue ?” mais “quel est le prochain changement probable ici, et qu’est-ce qu’il me coûterait ?”.
Et avec un agent ?
Dans l’épisode précédent, j’expliquais pourquoi le développement agentique amplifie ces coûts. Les Bloaters en sont un bon exemple.
Une méthode de trois cents lignes consomme le contexte d’un agent, exactement comme elle consomme l’attention d’un humain. Et un agent reproduit ce qu’il trouve dans son contexte : dans une classe de mille lignes, il ajoutera sa méthode à la suite des autres.
À l’inverse, un type comme PostalCode lui donne un retour immédiat.
S’il inverse deux arguments, le compilateur le lui signale et il corrige.
Avec neuf string, personne ne lui signale rien.
En résumé
| Smell | Le problème de conception | Le coût potentiel | Le remède principal |
|---|---|---|---|
| Long Method | Plusieurs responsabilités dans une méthode | Compréhension difficile | Extract Method |
| Large Class | Plusieurs responsabilités dans une classe | Compréhension difficile | Extract Class |
| Long Parameter List | Des valeurs en vrac au lieu de groupes | Risque de régressions | Parameter Object, valeurs explicites |
| Data Clumps | Un concept du métier sans type | Multiples changements | Introduce Parameter Object |
| Primitive Obsession | Une règle sans endroit où être écrite | Multiples changements, risque de régressions | Replace Primitive with Object |
Dans l’épisode précédent, j’avais rangé toute la famille du côté de la compréhension difficile. Tu vois que c’est plus nuancé : c’est vrai pour les deux premiers, beaucoup moins pour les trois autres.
Le classement par famille est une convention pratique pour s’y retrouver, mais ce n’est pas le plus important.
Ce qui compte, c’est les valeurs le coût que chaque smell annonce.
La suite
Dans le prochain épisode, je vais m’attaquer aux Change Preventers : ces smells qu’on découvre le jour où une petite modification oblige à ouvrir dix fichiers.
Tu as envie de lire la suite ? Abonne-toi à mon flux RSS pour être tenu au courant en direct de mes publications !
Tu veux creuser ces sujets avec ton équipe ? J’interviens en Brown Bag Lunch dans vos locaux, ou par mail à edouard@lacrafterie.tech.