Clean Code
Chapitre 8 / 14 · 10 min de lecture

Les frontières du code tiers

Intégrer proprement le code tiers : encapsuler les frontières, écrire des tests d'apprentissage, coder contre des interfaces.

Nous écrivons rarement un système de bout en bout. Nous achetons des bibliothèques, nous intégrons des composants open source, nous nous appuyons sur les services d'autres équipes. À chacun de ces points de jonction se trouve une frontière : un endroit où notre code rencontre du code que nous ne contrôlons pas. Ces frontières sont des zones de tension, et c'est précisément là que les ennuis commencent.

La tension est structurelle. Le fournisseur d'une bibliothèque cherche la généralité : il veut que son code marche dans le plus grand nombre d'environnements possible, pour séduire le public le plus large. Vous, utilisateur, voulez exactement l'inverse : une interface focalisée sur votre besoin précis. Ce désaccord ne se résout jamais complètement — mais on peut l'isoler. Ce chapitre montre comment garder les frontières propres pour que le changement, inévitable de l'autre côté, ne ruine pas votre code.

Le danger d'une interface trop ouverte

Prenons un exemple emblématique du livre (en Java, l'auteur cite java.util.Map) : une structure de données générique. En TypeScript, l'équivalent naturel est Map<K, V>. Ces conteneurs sont puissants et flexibles — et c'est justement leur défaut.

Imaginez que votre application gère un ensemble de capteurs (Sensor). Vous construisez une Map, puis vous la promenez partout dans le système :

// ❌ Avant : on fait circuler la Map brute
const sensors = new Map<string, Sensor>();

// ... ailleurs, encore et encore ...
const s = sensors.get(sensorId);

Plusieurs problèmes se cachent ici. D'abord, n'importe quel destinataire de la Map peut appeler clear() et vider l'ensemble, alors que votre intention était que personne n'y touche. Une Map expose tout son arsenal : set, delete, clear, l'itération complète. Vous offrez à chaque client bien plus de pouvoir que vous ne le souhaitez.

Ensuite, dans des langages moins typés (le livre évoque le Java pré-générique), c'est le client qui porte la responsabilité de récupérer un objet et de le caster vers le bon type. Cette ligne de cast se répète partout, et elle ne raconte pas bien son histoire.

Attention

Faire circuler une interface de frontière (Map, un client HTTP, un SDK…) dans tout le système, c'est multiplier les endroits à corriger le jour où cette interface change. Martin rappelle que Map a justement changé lors de l'arrivée des génériques en Java 5 : certains systèmes en étaient devenus si dépendants qu'ils ne pouvaient plus migrer.

La parade : encapsuler la frontière

La solution est de cacher l'interface de frontière derrière une classe maison, taillée pour votre besoin. Aucun utilisateur de cette classe ne devrait savoir si, en interne, vous utilisez une Map, un objet, ou autre chose. Ce choix doit devenir — et rester — un détail d'implémentation.

// ✅ Après : la Map est confinée dans une classe dédiée
class Sensors {
	private readonly sensors = new Map<string, Sensor>();

	getById(id: string): Sensor | undefined {
		return this.sensors.get(id);
	}

	register(sensor: Sensor): void {
		this.sensors.set(sensor.id, sensor);
	}

	// pas de clear() exposé : on ne donne que ce qui est utile
}

Observez ce que l'on gagne. L'interface de frontière (Map) est invisible de l'extérieur. Elle peut évoluer avec un impact minimal sur le reste de l'application : la gestion du typage et la sémantique sont gérées à l'intérieur de Sensors. Mieux, l'interface est désormais adaptée et restreinte au besoin métier. Le résultat est un code plus facile à comprendre et plus difficile à mal utiliser : on ne peut plus vider l'ensemble par accident, et Sensors peut faire respecter ses propres règles de conception et de gestion.

À retenir

Il ne s'agit pas d'envelopper chaque Map. Le conseil est : ne faites pas circuler une interface de frontière dans tout le système. Gardez-la à l'intérieur de la classe — ou d'une petite famille de classes — qui l'utilise. Évitez de la retourner depuis une API publique ou de l'accepter en argument.

Explorer et apprendre une frontière

Le code tiers nous fait livrer plus de fonctionnalités en moins de temps. Mais par où commencer quand on découvre une nouvelle bibliothèque ?

La tentation naturelle est de lire la documentation un jour ou deux, puis d'écrire directement le code de production qui l'utilise, et de croiser les doigts. On se retrouve alors souvent embourbé dans de longues sessions de débogage, à se demander si le bug vient de notre code ou du leur.

Le problème, c'est qu'apprendre une bibliothèque est difficile, l'intégrer l'est aussi, et faire les deux en même temps est doublement difficile. D'où une approche différente : au lieu d'expérimenter dans le code de production, écrivons de petits tests pour explorer notre compréhension de l'API. Jim Newkirk les appelle des tests d'apprentissage (learning tests).

Dans un test d'apprentissage, on appelle l'API tierce exactement comme on compte l'utiliser en production. Ce sont des expériences contrôlées qui vérifient notre compréhension.

Un cas concret : apprivoiser un logger

Suivons l'exemple du livre, transposé à une bibliothèque de logging. On veut afficher « hello » dans la console. Premier essai, naïf :

import { describe, it } from "vitest";
import { getLogger, ConsoleAppender, PatternLayout } from "some-logging-lib";

describe("logger : apprentissage", () => {
	it("écrit un message simple", () => {
		const logger = getLogger("MonLogger");
		logger.info("hello"); // ❓ rien ne s'affiche...
	});
});

On lance, et rien ne sort : l'API réclame un appender (une destination de sortie). On lit un peu, on découvre un ConsoleAppender, on l'ajoute :

it("ajoute un appender console", () => {
	const logger = getLogger("MonLogger");
	const appender = new ConsoleAppender();
	logger.addAppender(appender); // ❓ toujours une erreur de flux
	logger.info("hello");
});

Nouvelle surprise : l'appender n'a pas de flux de sortie. Après quelques recherches, on finit par comprendre qu'il faut lui fournir explicitement un format et une cible :

it("ajoute un appender avec flux", () => {
	const logger = getLogger("MonLogger");
	logger.removeAllAppenders();
	logger.addAppender(
		new ConsoleAppender(
			new PatternLayout("%p %t %m%n"),
			ConsoleAppender.SYSTEM_OUT,
		),
	);
	logger.info("hello"); // ✅ enfin, "hello" s'affiche
});

Au fil de ces petites expériences, on a découvert un comportement subtil : retirer l'argument SYSTEM_OUT n'empêche pas l'affichage, mais retirer le PatternLayout fait réapparaître l'erreur de flux. Le constructeur par défaut est « non configuré » — peu intuitif, voire un peu bogué. L'essentiel : tout ce savoir, durement acquis, est maintenant figé dans une poignée de tests unitaires simples.

On peut alors regrouper cette connaissance et, surtout, encapsuler le logger derrière notre propre classe, exactement comme nous l'avons fait pour Sensors :

class AppLogger {
	private readonly logger = getLogger("app");

	constructor() {
		this.logger.removeAllAppenders();
		this.logger.addAppender(
			new ConsoleAppender(
				new PatternLayout("%p %t %m%n"),
				ConsoleAppender.SYSTEM_OUT,
			),
		);
	}

	info(message: string): void {
		this.logger.info(message);
	}
}

Le reste de l'application est désormais isolé de la frontière de la bibliothèque : elle ne connaît que AppLogger.info().

Pourquoi ces tests sont « mieux que gratuits »

On pourrait croire que ces tests d'apprentissage coûtent du temps. C'est faux : il fallait apprendre l'API de toute façon, et les écrire était un moyen simple et isolé d'acquérir ce savoir. Ces expériences précises ont accéléré notre compréhension. Leur coût net est donc nul.

Mais ils ont mieux qu'un coût nul : un retour sur investissement positif. Une fois la bibliothèque intégrée, rien ne garantit qu'elle restera compatible avec vos besoins. Ses auteurs subiront des pressions pour faire évoluer leur code, corriger des bugs, ajouter des fonctionnalités. Chaque nouvelle version apporte son lot de risques.

Sans tests de frontièreAvec tests de frontière
La montée de version est une plongée à l'aveugle.On relance les tests : les écarts de comportement sautent aux yeux.
On reste bloqué sur une vieille version « qui marche ».On migre sereinement, dès que c'est sûr.
Un changement incompatible se découvre en production.Il se découvre immédiatement, dès qu'on relance les tests.

Astuce

Même si vous n'avez pas besoin d'apprendre l'API (vous la connaissez déjà), écrivez quand même quelques tests de frontière qui exercent l'interface comme le fait votre code de production. Sans ce filet, on est tenté de rester sur l'ancienne version bien trop longtemps.

Utiliser du code qui n'existe pas encore

Il existe une autre sorte de frontière : celle qui sépare le connu de l'inconnu. Parfois, ce qui se trouve de l'autre côté est carrément indéfini — l'équipe responsable n'a pas encore conçu son API. Faut-il pour autant rester bloqué ?

Martin raconte un projet de système de communications radio. Un sous-système, le « Transmitter », devait émettre des données sur une fréquence donnée, mais son interface n'existait pas encore. Plutôt que d'attendre, l'équipe a commencé à travailler loin de cette zone d'ombre, en se rapprochant peu à peu de la frontière. Au contact, ils ont compris ce qu'ils voulaient que cette interface soit. En substance :

Cale l'émetteur sur la fréquence fournie et émets une représentation analogique des données provenant de ce flux.

Définir l'interface dont on rêve

La stratégie est lumineuse : définir nous-mêmes l'interface idéale, celle que nous aimerions avoir, sans attendre l'API réelle. On lui donne un nom parlant — Transmitter — et une méthode transmit prenant une fréquence et un flux de données.

// L'interface que NOUS contrôlons, pensée pour notre besoin
interface Transmitter {
	transmit(frequency: number, data: DataStream): void;
}

Le grand avantage : cette interface est sous notre contrôle. Le code client (ici, un CommunicationsController) reste lisible et focalisé sur ce qu'il cherche à accomplir, sans une once de détail technique sur l'API future.

class CommunicationsController {
	constructor(private readonly transmitter: Transmitter) {}

	broadcast(frequency: number, data: DataStream): void {
		this.transmitter.transmit(frequency, data);
	}
}

Combler le fossé avec un adaptateur

Le jour où l'API réelle du transmetteur est enfin publiée — avec sa propre signature, biscornue et hors de notre contrôle — on n'a rien à changer dans CommunicationsController. On écrit simplement un TransmitterAdapter qui implémente notre interface et traduit les appels vers leur API. C'est le patron Adaptateur (Adapter, du Gang of Four).

import { VendorRadioApi } from "vendor-radio-sdk";

// L'adaptateur : notre interface d'un côté, leur API de l'autre
class TransmitterAdapter implements Transmitter {
	constructor(private readonly vendor: VendorRadioApi) {}

	transmit(frequency: number, data: DataStream): void {
		// On traduit vers la signature réelle du fournisseur
		this.vendor.keyOn(frequency);
		this.vendor.emitAnalog(data.toBuffer());
	}
}

L'adaptateur encapsule toute l'interaction avec l'API et fournit un point unique à modifier quand celle-ci évoluera. Notre code métier, lui, ne dépend que de Transmitter, qui ne bouge pas.

Une couture bien placée pour les tests

Ce découpage offre en prime une couture (seam) idéale pour les tests. Comme CommunicationsController dépend de l'interface Transmitter et non de l'API concrète, on peut le tester avec un faux émetteur (fake) :

class FakeTransmitter implements Transmitter {
	public readonly sent: { frequency: number; data: DataStream }[] = [];

	transmit(frequency: number, data: DataStream): void {
		this.sent.push({ frequency, data });
	}
}

it("diffuse sur la bonne fréquence", () => {
	const fake = new FakeTransmitter();
	const controller = new CommunicationsController(fake);

	controller.broadcast(101.5, makeStream("ping"));

	expect(fake.sent[0].frequency).toBe(101.5);
});

On teste ainsi toute la logique métier sans dépendre de l'inconnu. Et une fois l'API réelle disponible, on ajoute des tests de frontière qui vérifient que TransmitterAdapter l'utilise correctement.

Note

Définir l'interface dont on rêve, puis l'adapter à la réalité : c'est le même principe que l'encapsulation de Map. Dans les deux cas, on dépend d'une abstraction que l'on maîtrise, et l'on confine le code tiers à un seul endroit.

Des frontières propres

Des choses intéressantes se produisent aux frontières — et le changement en est une. Un bon design absorbe le changement sans réécriture massive. Quand on s'appuie sur du code hors de notre contrôle, un soin particulier protège notre investissement et limite le coût des évolutions futures.

Mieux vaut dépendre de ce que l'on contrôle que de ce que l'on ne contrôle pas — au risque, sinon, de se faire contrôler par lui.

Concrètement, on gère les frontières tierces en n'ayant que très peu d'endroits dans le code qui s'y réfèrent. Deux techniques, illustrées dans ce chapitre :

TechniqueQuand l'employer
Envelopper (wrapper) la bibliothèque dans une classe maisonL'API tierce convient à peu près ; on veut juste la restreindre et la cacher (cas Sensors, AppLogger).
Adaptateur vers une interface idéaleOn veut une interface parfaite pour notre besoin, ou l'API n'existe pas encore (cas Transmitter).

Dans les deux cas, le code « nous parle » mieux, l'usage de la frontière reste cohérent dans tout le système, et le nombre de points de maintenance s'effondre le jour où le code tiers change. Le tout, soutenu par des tests qui définissent clairement nos attentes.

À retenir

  • Il existe une tension naturelle entre la généralité voulue par le fournisseur et le besoin précis de l'utilisateur ; elle se cristallise aux frontières.
  • Ne faites pas circuler une interface de frontière (Map, SDK, client HTTP…) dans tout le système : encapsulez-la dans une classe maison qui n'expose que le nécessaire.
  • Les tests d'apprentissage sont mieux que gratuits : ils consolident votre compréhension de l'API et détectent les régressions à chaque montée de version.
  • Pour le code qui n'existe pas encore, définissez l'interface dont vous rêvez, puis branchez l'API réelle via un adaptateur le moment venu.
  • Une frontière propre a peu de points de contact, fournit une couture pour les tests (fakes), et fait dépendre votre code de ce que vous contrôlez.
  • Que vous enveloppiez ou adaptiez, le but est le même : confiner le code tiers à un seul endroit pour que le changement reste indolore.