Le tiroir du bureau contenait un bric-à-brac de câbles. HDMI vers VGA, USB-C vers USB-A, une prise européenne avec un adaptateur pour prise américaine. Des artefacts de l’incompatibilité. Dans le code, c’était la même chose. Deux systèmes, deux époques, deux langages qui devaient communiquer. Réécrire l’un d’eux ? Trop long, trop risqué. Il fallait un traducteur. Un Adaptateur. Pas une solution élégante, mais une solution qui évite le conflit.
Adapter Class vs Object
Le pattern Adapter se décline en deux variantes, deux philosophies pour résoudre le même problème : faire fonctionner une classe avec une interface qui n’est pas la sienne.
Adapter d’Objet (Composition) : L’approche la plus souple et la plus recommandée. L’adaptateur contient une instance de la classe à adapter (l’adaptée) et implémente l’interface cible. Il délègue les appels à l’objet adapté, en traduisant au besoin.
Adapter de Classe (Héritage) : L’adaptateur hérite à la fois de la classe à adapter et implémente l’interface cible (si le langage le permet, ce qui n'est pas le cas en TS avec une seule classe parente). C’est moins flexible car il couple l’adaptateur à une classe concrète spécifique.
En TypeScript, sans héritage multiple, on privilégie presque toujours l’Adapter d’Objet.
Conversion d’interface
Le cœur du métier. Prendre un appel conçu pour une interface (la cible), le transformer, et le transmettre à l’objet adapté qui comprend une autre interface (l’adaptée).
Prenons le cas classique : intégrer un vieux module de logging dans un nouveau système.
// --- L'ANCIEN SYSTÈME (Legacy - à adapter) ---
// C'est une bibliothèque stable, éprouvée, mais avec une interface "vieillotte".
class VieuxLogger {
// Méthode avec un nom étrange et des paramètres dans un ordre différent.
public ecrireLog(priorite: string, message: string, timestamp: Date): void {
console.log(`[${timestamp.toISOString()}] ${priorite.toUpperCase()} - ${message}`);
}
}
// --- LE NOUVEAU SYSTÈME (Notre code moderne) ---
// C'est l'interface que notre application attend.
interface LoggerCible {
// Méthode simple : niveau numérique, message.
log(niveau: number, message: string): void;
}
// Notre service moderne, qui dépend de l'interface `LoggerCible`.
class ServiceImportant {
constructor(private logger: LoggerCible) {}
executerOperation() {
this.logger.log(1, "Démarrage de l'opération...");
// ... logique métier ...
this.logger.log(3, "Opération réussie !");
}
}
Adapter d’Objet (Recommandé)
// L'ADAPTATEUR (Composition)
class LoggerAdapter implements LoggerCible {
// L'adaptateur COMPOSE (contient) une instance du vieux logger.
private vieuxLogger: VieuxLogger;
constructor(vieuxLogger: VieuxLogger) {
this.vieuxLogger = vieuxLogger;
}
// Implémentation de l'interface cible.
log(niveau: number, message: string): void {
// ICI, LA CONVERSION MAGIQUE.
// Il faut traduire les paramètres de la cible vers ceux de l'adapté.
const priorite = this.convertirNiveauEnPriorite(niveau);
const timestamp = new Date(); // On génère un timestamp maintenant.
// On délègue l'appel au vieux logger avec les bons arguments.
this.vieuxLogger.ecrireLog(priorite, message, timestamp);
}
// Méthode helper pour la conversion de format.
private convertirNiveauEnPriorite(niveau: number): string {
const mapping: { [key: number]: string } = {
1: 'info',
2: 'warning',
3: 'erreur',
4: 'critique'
};
return mapping[niveau] || 'info';
}
}
// --- MISE EN ŒUVRE ---
const monVieuxLogger = new VieuxLogger(); // L'ancien composant, inchangé.
const adaptateur = new LoggerAdapter(monVieuxLogger); // L'adaptateur qui l'enveloppe.
const monService = new ServiceImportant(adaptateur); // On injecte l'adaptateur !
monService.executerOperation();
// Affiche :
// [2024-01-15T10:30:00.000Z] INFO - Démarrage de l'opération...
// [2024-01-15T10:30:00.001Z] ERREUR - Opération réussie ! (Note : niveau 3 -> 'erreur')
Adapter de Classe (Théorique en TS - souvent via héritage partiel)
TypeScript n'a pas d'héritage multiple de classes. On peut simuler en implémentant une interface et en étendant une classe, mais cela ne résout pas le cœur du problème si on doit adapter une classe concrète.
// Approche hybride (si VieuxLogger était une classe abstraite ou si on utilisait un wrapper).
// C'est souvent plus compliqué et moins flexible que l'Adapter d'Objet.
abstract class VieuxLoggerAbstrait {
abstract ecrireLog(priorite: string, message: string, timestamp: Date): void;
}
class LoggerAdapterHeritage extends VieuxLoggerAbstrait implements LoggerCible {
// On doit quand même implémenter la conversion.
log(niveau: number, message: string): void {
const priorite = this.convertirNiveauEnPriorite(niveau);
this.ecrireLog(priorite, message, new Date()); // Appel à la méthode parente abstraite
}
// On doit implémenter la méthode abstraite de la classe parente.
ecrireLog(priorite: string, message: string, timestamp: Date): void {
console.log(`[ADAPTATEUR HERITAGE] ${timestamp.toISOString()}] ${priorite} - ${message}`);
}
// ... conversion ...
}
// Plus lourd, plus couplé. L'Adapter d'Objet est presque toujours supérieur.
Cas legacy
C’est le terrain de chasse naturel de l’Adapter.
- Intégration de bibliothèques tierces : Vous utilisez une librairie de paiement dont l’API ne correspond pas à votre modèle interne. Vous créez un adaptateur autour d’elle.
- Refactoring progressif : Vous voulez remplacer un vieux module par un nouveau, mais pas d’un coup. Vous introduisez d’abord un adaptateur qui implémente la nouvelle interface en utilisant l’ancien module. Tous les nouveaux code l’utilisent. Puis, un à un, vous remplacez les parties de l’ancien module. L’adaptateur sert de couche d’abstraction.
- Tests : Pour simuler un service externe (une API REST, une base de données) dans vos tests unitaires, vous créez un adaptateur « Mock » qui implémente la même interface mais retourne des données fictives.
- Normalisation : Plusieurs services similaires mais avec des API légèrement différentes (ex: différents fournisseurs de SMS). Vous créez un adaptateur pour chacun, derrière une interface commune.
// Exemple : Adapter pour différents fournisseurs de paiement.
interface PaiementProcessor {
process(amount: number, currency: string): Promise<{ success: boolean; transactionId: string }>;
}
class StripeAdapter implements PaiementProcessor {
private stripe: any; // Instance du SDK Stripe
async process(amount: number, currency: string) {
// Conversion des paramètres vers l'API Stripe.
const paymentIntent = await this.stripe.paymentIntents.create({
amount: amount * 100, // Stripe utilise des centimes
currency: currency.toLowerCase(),
});
return { success: paymentIntent.status === 'succeeded', transactionId: paymentIntent.id };
}
}
class PayPalAdapter implements PaiementProcessor {
private paypal: any; // Instance du SDK PayPal
async process(amount: number, currency: string) {
// Conversion des paramètres vers l'API PayPal.
const order = await this.paypal.createOrder({
purchase_units: [{ amount: { value: amount.toString(), currency_code: currency } }],
});
return { success: order.status === 'APPROVED', transactionId: order.id };
}
}
// Le code métier utilise l'interface commune, peu importe le fournisseur.
class ServicePaiement {
constructor(private processor: PaiementProcessor) {}
async effectuerPaiement(montant: number) {
return await this.processor.process(montant, 'EUR');
}
}
Je rangeai les câbles adaptateurs dans le tiroir. L’Adapter n’était pas un pattern glamour. Il ne créait rien de nouveau, il ne résolvait pas de problèmes algorithmiques profonds. C’était un pragmaticien. Un traducteur de conflits.
Il reconnaissait une réalité du développement : le monde est rempli de pièces qui ne s’emboîtent pas. Plutôt que de forcer ou de tout jeter, il proposait un interstice, une interface de traduction.
Ce n’était pas une fin en soi. Un système rempli d’adaptateurs est un système qui a beaucoup de dettes d’intégration. Mais c’était un outil de survie, de transition, d’intégration.
Le prochain pattern structurel serait le Bridge. Là où l’Adapter réconciliait des choses qui existaient déjà, le Bridge était une conception proactive pour empêcher que deux dimensions d’un système ne se soudent l’une à l’autre. Une architecture pour éviter le couplage avant qu’il n’arrive.