Clean Code
Chapitre 10 / 14 · 12 min de lecture

Les classes

Petites classes à responsabilité unique, cohésion forte, et conception ouverte au changement (SRP, DIP).

Jusqu'ici, nous avons travaillé sur les briques : des noms qui révèlent l'intention, des fonctions courtes, des expressions lisibles. Mais ces briques ne suffisent pas. On peut écrire des lignes impeccables et les empiler dans une classe tentaculaire que personne n'ose plus toucher. Le code propre se joue aussi à l'échelle supérieure : celle des classes et de leur organisation.

Robert C. Martin pose ici une règle simple à énoncer, redoutable à appliquer : une classe doit être petite. Non pas petite au sens du nombre de lignes, mais petite au sens du nombre de responsabilités. Tout le chapitre découle de cette idée et la décline en principes éprouvés de la conception orientée objet : la responsabilité unique (SRP), le principe ouvert-fermé (OCP), l'inversion des dépendances (DIP). Les exemples du livre sont en Java ; nous les traduisons ici en TypeScript idiomatique.

Organiser une classe

Avant de parler de taille, il faut parler d'ordre. Une classe propre se lit comme un article de journal : les éléments les plus importants d'abord, les détails ensuite. L'ordre conventionnel est le suivant :

  1. Les constantes publiques (static readonly), si elles existent.
  2. Les variables statiques privées.
  3. Les variables d'instance privées.
  4. Les fonctions publiques, chacune suivie immédiatement des utilitaires privés qu'elle appelle.

Ce dernier point applique la règle de la descente (stepdown rule) vue pour les fonctions : on lit du général au particulier, sans avoir à sauter d'un bout à l'autre du fichier.

class PanierAchat {
	static readonly TVA = 0.2; // 1. constante publique

	private static prochainId = 1; // 2. statique privée

	private readonly id: number; // 3. variables d'instance
	private lignes: LignePanier[] = [];

	// 4. fonction publique...
	ajouter(produit: Produit, quantite: number): void {
		this.verifierStock(produit, quantite);
		this.lignes.push({ produit, quantite });
	}

	// ...suivie de l'utilitaire privé qu'elle appelle
	private verifierStock(produit: Produit, quantite: number): void {
		if (produit.stock < quantite) {
			throw new Error("Stock insuffisant");
		}
	}
}

Encapsulation : garder les choses privées

La règle par défaut est l'encapsulation : variables et fonctions utilitaires restent privées. Il y a rarement une bonne raison d'exposer une variable publique.

Mais Martin n'est pas dogmatique. Parfois, un test du même module a besoin d'accéder à une variable ou une méthode interne. Dans ce cas, on peut relâcher la visibilité — passer en protected ou en portée de paquet. Les tests font loi. Toutefois, c'est un dernier recours : on cherche d'abord à préserver la confidentialité.

Astuce

Avant d'exposer un détail interne pour le bien d'un test, demandez-vous si le test ne devrait pas plutôt passer par l'interface publique. Si c'est impossible, c'est souvent le signe qu'une responsabilité veut sortir de la classe.

Les classes doivent être petites !

La première règle des classes est qu'elles doivent être petites. La seconde règle est qu'elles doivent être plus petites encore.

Pour les fonctions, on mesurait la taille en lignes physiques. Pour les classes, on change d'unité : on compte les responsabilités.

Le contre-exemple emblématique du livre est SuperDashboard, une « classe-dieu » (God class) qui expose près de 70 méthodes publiques. Personne ne conteste qu'elle est trop grosse. Mais le point subtil est le suivant : même réduite à cinq méthodes, elle reste mauvaise.

// ❌ Avant : cinq méthodes seulement, mais deux responsabilités
class SuperDashboard extends JFrame implements MetaDataUser {
	getLastFocusedComponent(): Component { /* ... */ }
	setLastFocused(c: Component): void { /* ... */ }

	getMajorVersionNumber(): number { /* ... */ }
	getMinorVersionNumber(): number { /* ... */ }
	getBuildNumber(): number { /* ... */ }
}

Cinq méthodes, est-ce trop ? Ici, oui. Pas à cause du nombre, mais parce que SuperDashboard mélange deux préoccupations : gérer le composant graphique qui détient le focus, et suivre les numéros de version.

Le nom de la classe trahit ses responsabilités

Le premier outil pour jauger la taille d'une classe, c'est… son nom. Si l'on n'arrive pas à lui donner un nom concis, c'est qu'elle en fait trop. Plus le nom est ambigu, plus la classe agrège des responsabilités disparates.

Attention

Méfiez-vous des mots fourre-tout : Processor, Manager, Super — et plus généralement Helper, Util… Ce sont des « mots-belette » (weasel words) qui trahissent presque toujours une agrégation malheureuse de responsabilités.

Autre test : devez-vous être capable de décrire la classe en 25 mots environ, sans employer « si », « et », « ou » ni « mais ». Décrivons SuperDashboard : « Le SuperDashboard donne accès au composant qui a détenu le focus en dernier, et permet aussi de suivre les numéros de version et de build. » Ce premier « et » est l'aveu : deux responsabilités cohabitent.

Le principe de responsabilité unique (SRP)

Le Single Responsibility Principle énonce qu'une classe ou un module ne doit avoir qu'une seule raison de changer. Ce principe nous donne à la fois une définition de la responsabilité et une jauge de taille.

Reprenons SuperDashboard. Elle a deux raisons de changer :

Raison de changerDéclencheur
Suivi de versionÀ chaque livraison du logiciel
Gestion des composants SwingÀ chaque évolution de l'interface

Ces deux axes évoluent indépendamment : on peut changer la version sans toucher à l'interface, et inversement. Deux raisons de changer = violation du SRP. La solution est d'extraire les trois méthodes de version dans une classe dédiée — qui, en prime, devient hautement réutilisable.

// ✅ Après : une seule responsabilité, un seul motif de changement
class Version {
	constructor(
		private readonly major: number,
		private readonly minor: number,
		private readonly build: number,
	) {}

	getMajorVersionNumber(): number {
		return this.major;
	}

	getMinorVersionNumber(): number {
		return this.minor;
	}

	getBuildNumber(): number {
		return this.build;
	}
}

Pourquoi ce principe est-il si souvent bafoué ?

Le SRP est l'un des concepts les plus simples de la conception objet — et pourtant l'un des plus violés. La raison est humaine : faire marcher un programme et le rendre propre sont deux activités distinctes. Notre tête a une capacité limitée ; on se concentre d'abord sur « ça fonctionne », ce qui est légitime. Le problème, c'est qu'on s'arrête là. Une fois le programme opérationnel, on file vers le problème suivant au lieu de revenir découper la classe surchargée.

Beaucoup craignent qu'une multitude de petites classes rende le système plus difficile à appréhender. C'est une illusion. Un système composé de nombreuses petites classes n'a pas plus de pièces mobiles qu'un système composé de quelques grosses classes : il y a autant à apprendre dans les deux cas.

Note

L'image de Martin : préférez-vous une boîte à outils avec de nombreux petits tiroirs étiquetés, chacun contenant un composant bien défini ? Ou quelques grands tiroirs où l'on jette tout en vrac ? Dans les deux cas, vous avez le même nombre d'outils ; seule l'organisation diffère.

L'objectif en gérant la complexité est qu'un développeur sache où chercher et n'ait à comprendre, à un instant donné, que la complexité directement concernée. Nous voulons des systèmes faits de nombreuses petites classes, pas de quelques grosses. Chacune encapsule une responsabilité, a une seule raison de changer, et collabore avec quelques autres.

La cohésion

Une classe devrait avoir un petit nombre de variables d'instance, et chacune de ses méthodes devrait en manipuler une ou plusieurs. Plus une méthode utilise de variables d'instance, plus elle est cohésive avec sa classe. Une classe où chaque variable est utilisée par chaque méthode atteint la cohésion maximale.

En pratique, une cohésion maximale n'est ni atteignable ni souhaitable, mais on vise une cohésion élevée. Une cohésion forte signifie que méthodes et variables sont interdépendantes et forment un tout logique soudé.

// Pile très cohésive : presque toutes les méthodes
// touchent les deux variables d'instance.
class Pile {
	private sommet = 0;
	private elements: number[] = [];

	taille(): number {
		return this.sommet;
	}

	empiler(element: number): void {
		this.sommet++;
		this.elements.push(element);
	}

	depiler(): number {
		if (this.sommet === 0) {
			throw new Error("Pile vide");
		}
		const element = this.elements[--this.sommet];
		this.elements.pop();
		return element;
	}
}

Seule taille() n'utilise pas les deux variables : la classe reste très cohésive.

Maintenir la cohésion produit beaucoup de petites classes

Voici l'enchaînement le plus puissant du chapitre. La stratégie « fonctions courtes, listes de paramètres réduites » pousse à transformer des variables locales en variables d'instance partagées. Pourquoi ? Imaginez une grosse fonction avec de nombreuses variables locales. Vous voulez en extraire un morceau qui utilise quatre de ces variables. Faut-il les passer toutes les quatre en arguments ?

Pas du tout. Si l'on promeut ces quatre variables en variables d'instance, l'extraction se fait sans passer aucun argument. Le découpage devient trivial.

Mais cette promotion a un effet de bord : la classe accumule des variables d'instance utilisées seulement par quelques fonctions, et perd en cohésion. Or, s'il existe un groupe de fonctions qui partagent un groupe de variables… cela ne forme-t-il pas une classe à part entière ?

À retenir

Quand une classe perd en cohésion, scindez-la. Découper une grosse fonction en petites fonctions révèle souvent l'opportunité d'en extraire plusieurs petites classes.

Martin illustre avec le programme PrintPrimes de Knuth : une unique fonction profondément imbriquée, truffée de variables cryptiques (P, J, K, JPRIME, ORD…). Après refactoring — sans réécriture, par petites étapes validées par des tests — la responsabilité se scinde en trois classes :

// Responsabilité 1 : orchestrer l'exécution
class PrimePrinter {
	static main(): void {
		const NOMBRE_DE_PREMIERS = 1000;
		const premiers = PrimeGenerator.generate(NOMBRE_DE_PREMIERS);

		const imprimante = new RowColumnPagePrinter(
			50, // lignes par page
			4, // colonnes par page
			`Les ${NOMBRE_DE_PREMIERS} premiers nombres premiers`,
		);
		imprimante.print(premiers);
	}
}
// Responsabilité 2 : générer les nombres premiers
class PrimeGenerator {
	private static premiers: number[] = [];
	private static multiples: number[] = [];

	static generate(n: number): number[] {
		this.premiers = new Array(n);
		this.multiples = [];
		this.poserDeuxCommePremier();
		this.examinerLesImpairs();
		return this.premiers;
	}

	// ... poserDeuxCommePremier(), examinerLesImpairs(),
	//     estPremier(candidat), etc. — tous privés.
}

La troisième classe, RowColumnPagePrinter, sait tout de la mise en page en lignes et colonnes. Chaque classe a désormais une raison de changer bien identifiée : l'environnement d'exécution, l'algorithme de calcul, ou le formatage de la sortie. Le programme est plus long qu'avant — noms descriptifs, déclarations qui servent de commentaire, espacement aéré — mais infiniment plus clair.

Organiser pour le changement

Dans la plupart des systèmes, le changement est permanent. Chaque modification fait courir le risque de casser le reste. Une classe propre est organisée pour réduire ce risque.

Prenons une classe Sql qui génère des requêtes SQL bien formées. Tant qu'elle regroupe tous les types de requêtes, elle a (au moins) deux raisons de changer : quand on ajoute un nouveau type de requête, et quand on modifie un type existant (par exemple ajouter le support des sous-requêtes à select). Double violation du SRP. Le symptôme se repère à l'œil nu : des méthodes privées comme selectWithCriteria ne servent qu'à un seul type de requête.

// ❌ Avant : une classe qu'il faut "ouvrir" à chaque évolution
class Sql {
	create(): string { /* ... */ }
	insert(fields: unknown[]): string { /* ... */ }
	selectAll(): string { /* ... */ }
	select(column: Column, pattern: string): string { /* ... */ }
	private selectWithCriteria(criteria: string): string { /* ... */ }
	// ... toute évolution force à rouvrir ce fichier
}

Ouvrir une classe pour la modifier introduit un risque : toute retouche peut casser le code voisin, et il faut tout retester. La parade : une classe abstraite Sql et une sous-classe par type de requête. Les méthodes privées migrent là où elles sont réellement utilisées ; le comportement commun est isolé dans des classes utilitaires (Where, ColumnList).

// ✅ Après : un ensemble de classes "fermées"
abstract class Sql {
	constructor(
		protected table: string,
		protected columns: Column[],
	) {}

	abstract generate(): string;
}

class CreateSql extends Sql {
	generate(): string {
		return `CREATE TABLE ${this.table} (...)`;
	}
}

class SelectWithCriteriaSql extends Sql {
	constructor(table: string, columns: Column[], private criteria: Criteria) {
		super(table, columns);
	}
	generate(): string {
		return `SELECT ... WHERE ${this.criteria}`;
	}
}

// Nouveau besoin ? On ajoute une classe, on ne touche à rien.
class UpdateSql extends Sql {
	generate(): string {
		return `UPDATE ${this.table} SET ...`;
	}
}

Le code de chaque classe devient d'une simplicité extrême, le temps de compréhension tombe à presque rien, et le risque qu'une fonction en casse une autre devient infime. Surtout : quand vient le besoin d'UPDATE, aucune classe existante ne change. On dépose simplement UpdateSql.

C'est l'Open-Closed Principle (OCP) : une classe doit être ouverte à l'extension, fermée à la modification. Notre Sql restructurée est ouverte (on étend par sous-classe) tout en gardant tout le reste fermé. L'idéal est d'incorporer les nouvelles fonctionnalités en étendant le système, pas en modifiant le code existant.

Isoler du changement

Les besoins changent, donc le code change. La POO distingue les classes concrètes, qui portent les détails d'implémentation, et les abstractions (interfaces, classes abstraites), qui ne représentent que des concepts. Une classe cliente qui dépend de détails concrets est en danger dès que ces détails évoluent.

L'exemple : une classe Portfolio qui calcule la valeur d'un portefeuille en interrogeant directement une API TokyoStockExchange. Tester devient un cauchemar — le cours change toutes les cinq minutes, donc le résultat aussi. Comment écrire une assertion stable sur une valeur volatile ?

La solution est d'introduire une interface entre le client et le détail concret, puis d'injecter la dépendance par le constructeur.

// L'abstraction : "demander le cours d'un symbole"
interface StockExchange {
	currentPrice(symbol: string): Money;
}

// Le détail concret implémente l'abstraction
class TokyoStockExchange implements StockExchange {
	currentPrice(symbol: string): Money {
		throw new Error("appel réseau réel"); // récupère le cours du symbole
	}
}

class Portfolio {
	// Portfolio dépend de l'abstraction, pas du détail
	constructor(private readonly exchange: StockExchange) {}

	value(): Money {
		throw new Error("utilise this.exchange.currentPrice(...)");
	}
	add(shares: number, symbol: string): void { /* ... */ }
}

Le test peut alors fournir une implémentation contrôlée de StockExchange qui fige le cours. Si Microsoft est fixé à 100, cinq actions valent forcément 500 — et l'assertion devient déterministe.

class FixedStockExchangeStub implements StockExchange {
	private prices = new Map<string, number>();

	fix(symbol: string, price: number): void {
		this.prices.set(symbol, price);
	}

	currentPrice(symbol: string): Money {
		return new Money(this.prices.get(symbol) ?? 0);
	}
}

describe("Portfolio", () => {
	it("vaut 500 pour 5 actions MSFT à 100", () => {
		const exchange = new FixedStockExchangeStub();
		exchange.fix("MSFT", 100);
		const portfolio = new Portfolio(exchange);

		portfolio.add(5, "MSFT");

		expect(portfolio.value()).toEqual(new Money(500));
	});
});

Un système assez découplé pour être testé ainsi est, par construction, plus souple et plus réutilisable. Cette isolation respecte le Dependency Inversion Principle (DIP) : nos classes doivent dépendre d'abstractions, jamais de détails concrets. Portfolio ne dépend plus de TokyoStockExchange mais de StockExchange, l'idée abstraite de « demander un cours » — peu importe d'où vient le prix.

Astuce

Le critère de testabilité est un excellent révélateur de conception : si une classe est pénible à tester sans contacter une base, un réseau ou une horloge, c'est qu'elle dépend d'un détail concret qui mériterait d'être caché derrière une interface.

À retenir

  • Une classe se mesure en responsabilités, pas en lignes. Petite veut dire focalisée.
  • SRP : une classe = une seule raison de changer. Un nom flou ou un « et » dans sa description trahissent l'excès de responsabilités.
  • Visez une cohésion forte : chaque méthode manipule les variables d'instance. Quand la cohésion baisse, scindez la classe.
  • Préférez beaucoup de petites classes à quelques grosses : même quantité de logique, bien meilleure organisation.
  • OCP : organisez le code pour l'étendre (nouvelle sous-classe) plutôt que le modifier ; gardez les classes existantes fermées.
  • DIP : dépendez d'interfaces, injectez les dépendances. Le découplage rend le code testable, flexible et réutilisable.