Clean Code
Chapitre 11 / 14 · 14 min de lecture

Les systèmes

Séparer la construction de l'utilisation : injection de dépendances, fabriques et préoccupations transverses.

Comment construiriez-vous une ville ? Vous ne pourriez pas en gérer chaque détail vous-même. Pourtant, les villes fonctionnent — la plupart du temps. Elles fonctionnent parce que des équipes prennent en charge des pans entiers : l'eau, l'électricité, la circulation, la sécurité, l'urbanisme. Certains pensent la vue d'ensemble, d'autres s'occupent des détails. Surtout, les villes ont développé des niveaux d'abstraction et de modularité qui permettent à chaque composant de fonctionner efficacement, sans que personne n'ait besoin de comprendre le tout.

Les chapitres précédents nous ont appris à rester propres aux bas niveaux d'abstraction — noms, fonctions, classes. Ce chapitre monte d'un cran : il s'intéresse à la propreté à l'échelle du système. Et la première grande idée, la plus ancienne et la plus importante de notre métier, tient en une phrase : séparer les préoccupations.

Séparer la construction de l'utilisation

Construire un système et l'utiliser sont deux processus radicalement différents. Pensez à un hôtel en chantier : grue, monte-charge, ouvriers en casque. Un an plus tard, la grue a disparu, le bâtiment est habillé de verre, et les clients qui y séjournent n'ont plus rien à voir avec les ouvriers du début.

Un système logiciel devrait séparer le processus de démarrage — où les objets sont construits et les dépendances « câblées » entre elles — de la logique d'exécution qui prend le relais une fois le démarrage terminé.

Or, la plupart des applications ne séparent pas cette préoccupation. Le code de démarrage est bricolé au fil de l'eau et mêlé à la logique métier. L'exemple typique est l'initialisation paresseuse :

// ❌ Avant : construction mêlée à l'utilisation
class CommandeService {
	private paiement?: PaiementGateway;

	getPaiement(): PaiementGateway {
		if (!this.paiement) {
			// Bon défaut « pour la plupart des cas » ?
			this.paiement = new StripeGateway(/* ... */);
		}
		return this.paiement;
	}
}

Cet idiome a des mérites : on n'instancie l'objet que si on l'utilise réellement, et on garantit qu'on ne renvoie jamais null. Mais les défauts l'emportent vite.

D'abord, on a maintenant une dépendance codée en dur vers StripeGateway et tout ce que réclame son constructeur. Impossible de compiler sans résoudre ces dépendances, même si l'objet n'est jamais utilisé à l'exécution. Ensuite, les tests deviennent pénibles : si StripeGateway est un objet lourd, il faut s'assurer qu'un double de test ou un mock est injecté dans le champ avant l'appel. Comme la logique de construction est mêlée au traitement normal, il faut tester tous les chemins (le cas null et son bloc).

Cette méthode fait donc plus d'une chose : elle viole, à petite échelle, le principe de responsabilité unique. Et pire encore : qui dit que StripeGateway est le bon objet dans tous les contextes ? Pourquoi cette classe devrait-elle connaître le contexte global de l'application ?

Attention

Une seule initialisation paresseuse n'est pas grave. Mais ces petits idiomes se répètent partout. La stratégie globale de configuration finit alors éparpillée dans toute l'application, sans modularité et souvent avec une duplication massive.

Séparer le main

La façon la plus simple de séparer la construction de l'utilisation est de déplacer tous les aspects de la construction dans le main (ou dans des modules appelés par le main), puis de concevoir le reste du système en partant du principe que tous les objets ont déjà été construits et câblés.

// ✅ Après : le main construit, l'application utilise
// main.ts — la « composition root »
function main(): void {
	const paiement = new StripeGateway(config.stripeKey);
	const stock = new PostgresStockRepository(db);
	const service = new CommandeService(paiement, stock);

	demarrerServeur(service);
}
// commande-service.ts — ne connaît rien du main
class CommandeService {
	constructor(
		private readonly paiement: PaiementGateway,
		private readonly stock: StockRepository,
	) {}

	// ... utilise simplement les dépendances reçues
}

Le flot de contrôle devient limpide : le main construit les objets nécessaires, puis les passe à l'application, qui se contente de les utiliser. Toutes les flèches de dépendance traversent la frontière dans une seule direction, en s'éloignant du main. L'application n'a aucune connaissance du main ni du processus de construction. Cet endroit unique où l'on assemble le graphe d'objets s'appelle la composition root.

Les fabriques

Parfois, l'application doit garder le contrôle du moment où un objet est créé. Dans un système de traitement de commandes, par exemple, l'application doit créer les LigneCommande à ajouter à une Commande — au fur et à mesure des choix de l'utilisateur. On utilise alors le patron Abstract Factory pour donner ce contrôle à l'application, tout en gardant les détails de construction de l'autre côté de la frontière.

// Interface du côté application (elle décide du « quand »)
interface LigneCommandeFactory {
	creer(produit: Produit, quantite: number): LigneCommande;
}
// Implémentation du côté main (elle connaît le « comment »)
class LigneCommandeFactoryImpl implements LigneCommandeFactory {
	creer(produit: Produit, quantite: number): LigneCommande {
		return new LigneCommande(produit, quantite, new Date());
	}
}
// L'application contrôle QUAND, sans savoir COMMENT
class TraitementCommande {
	constructor(private readonly fabrique: LigneCommandeFactory) {}

	ajouter(cmd: Commande, produit: Produit, qte: number): void {
		cmd.lignes.push(this.fabrique.creer(produit, qte));
	}
}

À nouveau, toutes les dépendances pointent du main vers l'application. Celle-ci est découplée des détails de construction d'une LigneCommande — ce savoir vit dans LigneCommandeFactoryImpl, côté main —, mais elle garde la maîtrise complète du moment de création et peut même fournir des arguments spécifiques.

L'injection de dépendances

Le mécanisme le plus puissant pour séparer construction et utilisation est l'injection de dépendances (DI), application de l'inversion de contrôle (IoC) à la gestion des dépendances.

L'inversion de contrôle déplace les responsabilités secondaires d'un objet vers d'autres objets dédiés à cette tâche — ce qui sert directement le principe de responsabilité unique. Appliqué aux dépendances, cela signifie qu'un objet ne doit pas instancier ses propres dépendances. Il délègue cette responsabilité à un mécanisme « faisant autorité » : le plus souvent le main, ou un conteneur dédié.

Attention à la nuance avec un simple service locator (l'équivalent des recherches JNDI en Java) :

// Recherche active : l'objet résout encore sa dépendance
const paiement = container.resolve<PaiementGateway>("paiement");

Ici, l'objet appelant ne contrôle pas le type concret renvoyé, mais il résout activement sa dépendance — il connaît le conteneur. La vraie injection de dépendances va plus loin : la classe ne fait aucune démarche pour résoudre ses dépendances. Elle est totalement passive. Elle expose des arguments de constructeur (ou des setters) que l'on utilise pour lui injecter ses dépendances.

// ✅ Classe passive : elle ne va rien chercher elle-même
class FactureService {
	// Les dépendances arrivent par le constructeur
	constructor(
		private readonly paiement: PaiementGateway,
		private readonly emails: EmailSender,
	) {}
}

En Java, le conteneur DI le plus connu est Spring, configuré par fichier XML ou annotations. En TypeScript, on retrouve la même idée avec des conteneurs comme InversifyJS, tsyringe ou le système de modules de NestJS — mais souvent, une simple composition manuelle dans le main suffit et reste la plus lisible.

Astuce

Avant d'introduire un conteneur DI à base de réflexion et de décorateurs, demandez-vous si un assemblage manuel dans votre composition root ne ferait pas le travail. Le câblage explicite est plus verbeux mais infiniment plus facile à suivre, à déboguer et à tester.

Et l'initialisation paresseuse ? Elle reste parfois utile : la plupart des conteneurs ne construisent un objet qu'à la demande, et fournissent des mécanismes de fabriques ou de proxys pour différer l'évaluation. N'oubliez pas, toutefois, que la paresse n'est qu'une optimisation — et peut-être prématurée.

Monter en échelle

Les villes naissent de villages, qui naissent de hameaux. Au début, les routes sont étroites, puis pavées, puis élargies. Les services (eau, électricité, réseau) sont ajoutés à mesure que la densité augmente. Cette croissance n'est pas indolore — qui n'a jamais pesté dans un embouteillage de travaux en se demandant : « pourquoi n'ont-ils pas fait assez large dès le départ ? »

Mais cela n'aurait pas pu se passer autrement. Qui justifierait le coût d'une autoroute à six voies au milieu d'un village ?

C'est un mythe de croire qu'on peut obtenir un système « bon du premier coup ». Implémentez seulement les histoires d'aujourd'hui, puis refactorez et étendez le système pour celles de demain.

C'est l'essence de l'agilité itérative et incrémentale. TDD, refactoring et code propre la rendent possible au niveau du code. Mais au niveau de l'architecture ? Bonne nouvelle :

Les systèmes logiciels sont uniques par rapport aux systèmes physiques : leurs architectures peuvent croître de façon incrémentale, si l'on maintient une bonne séparation des préoccupations.

Un contre-exemple : l'architecture envahissante

Martin illustre l'échec par les anciennes architectures EJB (Enterprise JavaBeans) de Java. Pour un simple objet Banque persistant, il fallait hériter de types du conteneur, implémenter une foule de méthodes de cycle de vie souvent vides, et rédiger des descripteurs de déploiement XML. La logique métier était étroitement couplée au conteneur.

// ❌ Avant : la logique métier noyée dans l'infrastructure
class Banque extends EntityBeanConteneur {
	private comptes: Compte[] = [];

	ajouterCompte(dto: CompteDTO): void {
		// L'objet métier va lui-même chercher l'infrastructure
		const ctx = new InitialContext();
		const home = ctx.lookup("CompteHome");
		this.comptes.push(home.create(dto));
	}

	// ... plus une dizaine de méthodes de cycle de vie vides
	setEntityContext(ctx: EntityContext): void {}
	ejbActivate(): void {}
	ejbLoad(): void {}
	ejbStore(): void {}
}

Conséquences : les tests unitaires isolés deviennent très difficiles (il faut simuler le conteneur), la réutilisation hors de l'architecture est quasi impossible, et même la programmation orientée objet est compromise. La logique métier — ce qui a vraiment de la valeur — disparaît sous l'infrastructure.

L'objectif inverse est le POJO (Plain Old Java Object), ou son équivalent : un objet de domaine pur, sans dépendance à un framework d'entreprise.

// ✅ Après : un objet de domaine pur (POJO)
class Banque {
	private readonly comptes: Compte[] = [];

	ajouterCompte(compte: Compte): void {
		compte.rattacherA(this);
		this.comptes.push(compte);
	}

	getComptes(): readonly Compte[] {
		return this.comptes;
	}
}

Cet objet est concentré sur son domaine, sans aucune dépendance à l'infrastructure. Il est donc plus simple, plus facile à piloter par les tests, à maintenir et à faire évoluer.

Les préoccupations transverses

Reste un problème : certaines préoccupations traversent les frontières naturelles des objets du domaine. La persistance, par exemple, doit suivre la même stratégie pour tous les objets (même SGBD, mêmes conventions de nommage, même sémantique transactionnelle). C'est aussi le cas de la sécurité, des transactions, du cache ou de la journalisation.

En théorie, on peut raisonner sur la persistance de façon modulaire. En pratique, on doit disperser sensiblement le même code dans des dizaines d'objets. On parle de préoccupations transverses (cross-cutting concerns). Le framework de persistance peut être modulaire, le domaine peut l'être aussi — le problème naît de leur intersection fine.

La réponse historique est la programmation orientée aspect (AOP) : des modules appelés aspects déclarent quels points du système doivent voir leur comportement modifié de façon cohérente. En Java, on l'implémente via des proxys (JDK, CGLIB), des frameworks comme Spring AOP, ou le langage AspectJ. La verbosité des proxys « faits main » est d'ailleurs l'un de leurs principaux défauts — du code complexe qui nuit à la propreté.

En TypeScript, on retrouve les mêmes idées sous des formes plus légères : décorateurs, intercepteurs et middlewares. L'esprit est identique : appliquer une préoccupation transverse de manière non invasive, sans polluer la logique métier.

// Un middleware applique la préoccupation « transaction »
// de façon transverse, sans toucher au métier.
function avecTransaction(handler: Handler): Handler {
	return async (req, res) => {
		const tx = await db.beginTransaction();
		try {
			await handler(req, res);
			await tx.commit();
		} catch (erreur) {
			await tx.rollback();
			throw erreur;
		}
	};
}
// Un décorateur applique la préoccupation « cache »
// sans modifier la méthode décorée.
function enCache(ttlMs: number) {
	return function (_cible: object, _cle: string, descripteur: PropertyDescriptor) {
		const original = descripteur.value;
		const memo = new Map<string, { exp: number; val: unknown }>();
		descripteur.value = function (...args: unknown[]) {
			const cle = JSON.stringify(args);
			const hit = memo.get(cle);
			if (hit && hit.exp > Date.now()) return hit.val;
			const val = original.apply(this, args);
			memo.set(cle, { exp: Date.now() + ttlMs, val });
			return val;
		};
	};
}
class CatalogueService {
	// La logique métier reste pure ; le cache est « ajouté »
	@enCache(60_000)
	rechercher(terme: string): Produit[] {
		return this.repo.chercher(terme);
	}
}

Note

C'est le principe des « poupées russes » de décorateurs : le client croit appeler rechercher() sur un objet de domaine, mais il dialogue en réalité avec l'enveloppe la plus externe d'un jeu d'objets imbriqués (cache, transaction, sécurité…) qui étendent le comportement de l'objet de base.

L'architecture idéale se résume ainsi : des domaines de préoccupation modularisés, chacun implémenté avec des objets de domaine purs (POJO), intégrés entre eux par des aspects (ou outils similaires) minimalement invasifs.

Piloter l'architecture par les tests

Si vous écrivez votre logique de domaine avec des objets purs, découplés de toute préoccupation d'architecture au niveau du code, alors vous pouvez piloter votre architecture par les tests. Vous la faites évoluer du simple au sophistiqué, en adoptant de nouvelles technologies à la demande.

Il n'est donc pas nécessaire de tout concevoir d'avance (Big Design Up Front, BDUF). Le BDUF est même nuisible : il inhibe l'adaptation au changement, à cause de la résistance psychologique à jeter le travail déjà fait et de la manière dont les premiers choix d'architecture orientent toute la réflexion qui suit.

À retenir

Contrairement à un architecte du bâtiment, qui ne peut pas modifier radicalement une structure physique une fois le chantier lancé, il est économiquement faisable de changer radicalement un logiciel — à condition que sa structure sépare efficacement ses préoccupations.

On peut donc démarrer un projet avec une architecture « naïvement simple » mais bien découplée, livrer rapidement des histoires utiles, puis ajouter l'infrastructure au fil de la montée en charge. Cela ne signifie pas avancer « sans gouvernail » : on garde des attentes sur la portée, les objectifs et la structure générale du système. Mais on conserve la capacité de changer de cap face à l'évolution des circonstances.

Une bonne API devrait d'ailleurs disparaître de la vue la plupart du temps, pour que l'équipe consacre l'essentiel de son énergie créative aux histoires des utilisateurs, et non aux contraintes du framework.

Optimiser la prise de décision

La modularité et la séparation des préoccupations rendent possible une prise de décision décentralisée. Dans un système assez grand — une ville comme un projet logiciel — personne ne peut tout décider seul.

Nous savons qu'il vaut mieux confier chaque responsabilité à la personne la plus qualifiée. Nous oublions souvent qu'il vaut aussi mieux reporter les décisions au dernier moment responsable.

Une décision prématurée est une décision prise avec une connaissance sous-optimale.

Ce n'est ni de la paresse ni de l'irresponsabilité : c'est se donner les moyens de choisir avec la meilleure information possible. Plus on décide tôt, moins on dispose de retours clients, de recul sur le projet et d'expérience concrète avec nos choix d'implémentation. Un système d'objets purs et modularisé nous offre l'agilité de prendre ces décisions juste à temps, et en réduit la complexité.

Utiliser les standards à bon escient

Beaucoup d'équipes ont adopté EJB2 parce que c'était un standard, là où un design plus léger aurait suffi. Martin a vu des équipes s'obnubiler sur des standards survendus et perdre de vue la valeur livrée au client.

Les standards aident à……mais attention quand
Réutiliser idées et composantsLe standard est plus lourd que le besoin réel
Recruter des profils déjà formésLe « buzz » fait oublier la valeur client
Encapsuler de bonnes idéesLe processus de normalisation a pris trop de temps
Câbler des composants ensembleLe standard a perdu le contact avec les besoins

La règle : n'adoptez un standard que lorsqu'il apporte une valeur démontrable.

Les systèmes ont besoin de DSL

Le bâtiment, comme la plupart des domaines, a développé un vocabulaire riche — idiomes et patrons — qui transmet l'essentiel clairement et de façon concise. En logiciel, l'équivalent est le langage dédié au domaine (Domain-Specific Language, DSL) : un petit langage de script, ou une API dans un langage standard, qui permet d'écrire du code se lisant comme une prose qu'un expert du domaine pourrait rédiger.

// Une API « fluide » qui se lit comme l'énoncé métier
const commande = nouvelleCommande()
	.pourClient("alice@exemple.fr")
	.avecArticle("CAFE-500G", 2)
	.avecArticle("THE-VERT", 1)
	.livraisonExpress()
	.appliquerCodePromo("BIENVENUE10");

Un bon DSL réduit l'écart de communication entre un concept du domaine et le code qui l'implémente. Implémenter la logique de domaine dans le même langage que celui de l'expert métier diminue le risque de traduire incorrectement le domaine. Bien utilisés, les DSL élèvent le niveau d'abstraction au-dessus des idiomes de code et des patrons de conception, et révèlent l'intention au bon niveau.

À retenir

  • Séparez la construction de l'utilisation : assemblez le graphe d'objets dans une composition root (main), et que l'application l'ignore complètement.
  • Inversez le contrôle : un objet ne construit pas ses dépendances, on les lui injecte par le constructeur. Une fabrique sert quand l'application doit maîtriser le moment de la création.
  • Écrivez la logique métier en objets de domaine purs (POJO) ; intègrez les préoccupations transverses (persistance, sécurité, transactions) de façon non invasive — décorateurs, middlewares, intercepteurs.
  • Les architectures logicielles croissent de façon incrémentale : pilotez-les par les tests, fuyez le Big Design Up Front.
  • Reportez les décisions au dernier moment responsable, pour décider avec la meilleure information.
  • N'adoptez un standard que pour une valeur démontrable, et offrez à votre domaine un DSL quand il rapproche le code du langage métier. Et surtout : utilisez la chose la plus simple qui puisse fonctionner.