L'abstraction n'est pas un pattern. C'est une discipline
Chaque fois que vous injectez un driver de base de données directement dans une classe de service, vous prenez une décision. Le problème, c'est que la plupart des équipes ne se rendent pas compte qu'elles la prennent.
Le code tourne. La fonctionnalité part en production. Le sprint se clôture. Le couplage fort n'échoue jamais bruyamment. Il s'accumule en silence, dans chaque service qui connaît la forme de votre connexion à la base de données, dans chaque classe qui importe directement le SDK de paiement, dans chaque fonction qui appelle une API externe sans rien entre les deux.
Le temps que vous le sentiez, c'est déjà partout.
Votre service n'a qu'un seul travail
Un PaymentService doit savoir traiter un paiement. Il n'a pas à savoir que l'historique des paiements vit dans une table PostgreSQL, accessible via un driver particulier, sur un pool de connexions particulier.
Pourtant, c'est exactement ce qui se passe quand vous écrivez ceci :
// ❌
class PaymentService {
async processPayment(userId: string, amount: number) {
const db = new DatabaseConnection(process.env.DB_URL);
await db.query('INSERT INTO transactions ...', [userId, amount, 'pending']);
const stripe = new Stripe(process.env.STRIPE_KEY);
const charge = await stripe.charges.create({ amount, currency: 'usd' });
await db.query('UPDATE transactions SET status = ? ...', ['completed', charge.id]);
}
}
Ce n'est pas un service de paiement. C'est une fonction qui orchestre simultanément un driver de base de données, du SQL brut et un SDK tiers. La logique métier et l'infrastructure ne font qu'un.
Remplacez PostgreSQL par MongoDB : tout réécrire. Remplacez Stripe par un autre fournisseur : tout réécrire. Essayez d'écrire un test unitaire sans base de données active ni vraie clé d'API : impossible.
Chaque dépendance externe que le service touche est un clou planté dans la logique métier. Et ces clous se multiplient.
Le pattern Repository : votre base de données n'a rien à faire là
Le pattern Repository place un contrat entre votre logique applicative et votre couche de persistance. Le contrat parle le langage de votre domaine. L'implémentation parle le langage de la base de données.
// Le contrat : votre application ne connaît que ça
interface UserRepository {
findById(id: string): Promise<User | null>
save(user: User): Promise<void>
delete(id: string): Promise<void>
}
// Une implémentation
class PostgresUserRepository implements UserRepository {
async findById(id: string) {
const row = await this.db.query('SELECT * FROM users WHERE id = ?', [id]);
return row ? this.toUser(row) : null;
}
}
// Une autre implémentation, même contrat
class MongoUserRepository implements UserRepository {
async findById(id: string) {
const doc = await this.collection.findOne({ _id: id });
return doc ? this.toUser(doc) : null;
}
}
Le service qui a besoin d'un utilisateur reçoit un UserRepository. Il appelle findById. Ce qui se passe de l'autre côté lui est invisible.
Cette séparation n'a rien de cosmétique. C'est la frontière entre votre logique métier et votre infrastructure. Traversez-la délibérément, ou pas du tout.
Le pattern Gateway : les API externes sont aussi des détails d'implémentation
Le même problème s'applique à chaque service externe dont dépend votre application : fournisseurs de paiement, passerelles SMS, services d'e-mail, API tierces. Le pattern Gateway enveloppe le service externe derrière un contrat que votre application contrôle.
// Le contrat
interface SmsGateway {
send(to: string, message: string): Promise<void>
}
// Twilio aujourd'hui
class TwilioSmsGateway implements SmsGateway {
async send(to: string, message: string) {
await this.client.messages.create({ to, body: message, from: this.sender });
}
}
// Un autre fournisseur demain, même contrat
class AfricasTalkingSmsGateway implements SmsGateway {
async send(to: string, message: string) {
await this.sms.send({ to: [to], message, from: this.sender });
}
}
Votre service de notification reçoit un SmsGateway. Il appelle send. Que le message transite par Twilio, Africa's Talking ou n'importe quel autre fournisseur relève entièrement d'une décision d'infrastructure, extérieure à la logique métier.
Un fournisseur change ses tarifs ? Vous écrivez une nouvelle classe et vous ajustez le câblage. Rien ne bouge dans le service.
L'Adapter : faire le pont entre votre contrat et le monde extérieur
Quand vous regardez à l'intérieur d'une implémentation de Gateway, il y a généralement un écart. Votre contrat parle le langage de votre domaine. Le SDK parle le sien. L'Adapter est ce qui comble cet écart.
Prenez un fournisseur de paiement. Son SDK renvoie un objet de réponse brut, avec ses propres noms de champs, sa propre structure d'erreur, ses propres codes de statut. Rien de tout ça n'a sa place dans votre service. L'Adapter traduit.
// Ce que votre domaine attend
interface ChargeResult {
success: boolean
transactionId: string
amount: number
}
interface PaymentGateway {
charge(amount: number, currency: string): Promise<ChargeResult>
}
// L'Adapter : traduit le monde de Stripe dans le vôtre
class StripePaymentGateway implements PaymentGateway {
async charge(amount: number, currency: string): Promise<ChargeResult> {
try {
const charge = await this.stripe.charges.create({
amount: Math.round(amount * 100), // Stripe travaille en cents
currency,
source: 'tok_visa',
});
// La réponse de Stripe adaptée dans votre type de domaine
return {
success: charge.status === 'succeeded',
transactionId: charge.id,
amount: charge.amount / 100,
};
} catch (error) {
return { success: false, transactionId: '', amount: 0 };
}
}
}
Le service reçoit un ChargeResult. Il ne sait rien des codes de statut de Stripe, de son format de montant ou de son modèle d'erreur. Si l'API de Stripe change demain, vous corrigez l'Adapter. Le contrat reste intact. Le service ignore même qu'il s'est passé quelque chose.
Chaque fois que vous écrivez une implémentation de Gateway, vous écrivez aussi un Adapter. La distinction mérite d'être nommée explicitement, car elle clarifie la responsabilité : un côté est votre domaine, l'autre est le leur. L'Adapter est le traducteur qui se tient entre les deux.
Le Wrapper : empiler du comportement sans toucher à la logique
Une fois que vous avez un contrat, quelque chose d'intéressant devient possible. Vous pouvez ajouter du comportement à une implémentation sans la modifier, en l'enveloppant dans une autre classe qui implémente le même contrat.
C'est le pattern Decorator, souvent appelé Wrapper dans ce contexte. Il est particulièrement utile pour les préoccupations transversales : journalisation, logique de réessai, mise en cache, circuit breakers. Un comportement qui s'applique quelle que soit l'implémentation sous-jacente.
// Un wrapper qui ajoute une logique de réessai à n'importe quel SmsGateway
class RetryingSmsGateway implements SmsGateway {
constructor(
private inner: SmsGateway,
private maxAttempts: number = 3
) {}
async send(to: string, message: string): Promise<void> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
try {
await this.inner.send(to, message);
return;
} catch (error) {
lastError = error as Error;
if (attempt < this.maxAttempts) {
await this.delay(attempt * 500); // exponential-ish backoff
}
}
}
throw lastError;
}
private delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Un wrapper qui ajoute de la journalisation
class LoggingSmsGateway implements SmsGateway {
constructor(private inner: SmsGateway, private logger: Logger) {}
async send(to: string, message: string): Promise<void> {
this.logger.info(`Sending SMS to ${to}`);
try {
await this.inner.send(to, message);
this.logger.info(`SMS delivered to ${to}`);
} catch (error) {
this.logger.error(`SMS failed to ${to}`, error);
throw error;
}
}
}
Ces wrappers se combinent. Vous les empilez au niveau du câblage, et chaque couche ajoute son comportement sans savoir ce qui se trouve en dessous.
// Câblage : Twilio, avec journalisation, avec réessai
const smsGateway = new RetryingSmsGateway(
new LoggingSmsGateway(
new TwilioSmsGateway(twilioClient),
logger
),
3
);
Le service reçoit toujours un SmsGateway. Il appelle toujours send. Il ne sait rien des réessais, de la journalisation, ni de Twilio. Tout cela est composé à l'extérieur, au niveau de l'application, sans toucher à une seule ligne de logique métier.
L'injection de dépendances : les contrats ne fonctionnent que si vous les utilisez correctement
Définir des interfaces, c'est la moitié du travail. L'autre moitié consiste à ne jamais laisser le service décider quelle implémentation il reçoit.
C'est ce que résout l'injection de dépendances (Dependency Injection). Le service déclare ce dont il a besoin à travers son constructeur. Quelque chose à l'extérieur, typiquement un conteneur DI ou la racine de composition de votre application, décide quelle implémentation concrète lui est injectée.
// ✅ Le service déclare ses dépendances via des contrats
class NotificationService {
constructor(
private sms: SmsGateway,
private users: UserRepository
) {}
async notifyUser(userId: string, message: string) {
const user = await this.users.findById(userId);
if (!user) return;
await this.sms.send(user.phoneNumber, message);
}
}
// Le câblage se fait une seule fois, au niveau de l'application
const service = new NotificationService(
new RetryingSmsGateway(new TwilioSmsGateway(twilioClient), 3),
new PostgresUserRepository(dbConnection)
);
Le service n'instancie jamais ses dépendances. Il n'importe jamais de driver. Il ne lit jamais une variable d'environnement. Il reçoit tout via son constructeur et fait confiance aux contrats.
Cette règle unique, appliquée avec constance, est ce qui rend une base de code testable.
La testabilité : le bénéfice que vous ressentez immédiatement
Quand chaque dépendance externe est cachée derrière un contrat, tester la logique métier devient simple. Pas de base de données en cours d'exécution. Pas de clé d'API active. Pas d'appel réseau. Vous injectez des mocks.
// Un mock qui remplace entièrement un SDK tiers
class MockSmsGateway implements SmsGateway {
public sent: Array<{ to: string; message: string }> = [];
async send(to: string, message: string) {
this.sent.push({ to, message });
}
}
class MockUserRepository implements UserRepository {
private users: User[] = [
{ id: '1', phoneNumber: '+242068511358', name: 'Test User' }
];
async findById(id: string) {
return this.users.find(u => u.id === id) ?? null;
}
async save(user: User) { this.users.push(user); }
async delete(id: string) { this.users = this.users.filter(u => u.id !== id); }
}
// Le test : de la logique pure, aucune infrastructure
it('sends a notification to the correct user', async () => {
const sms = new MockSmsGateway();
const users = new MockUserRepository();
const service = new NotificationService(sms, users);
await service.notifyUser('1', 'Your order has shipped.');
expect(sms.sent).toHaveLength(1);
expect(sms.sent[0].to).toBe('+242068511358');
});
Le test s'exécute en quelques millisecondes. Il ne touche jamais à une base de données ni à un réseau. Et il vous dit quelque chose de réel sur le comportement de votre code, pas sur la disponibilité de votre infrastructure.
C'est ce que permettent les contrats. Pas seulement de la flexibilité. De la confiance.
"Ça ne changera jamais", et quand c'est honnête
Il existe un cas légitime de couplage fort. Il ressemble à ceci : "C'est un script jetable. Il ne touche qu'une seule table. Il sera supprimé dans trois mois."
Cet argument est valable quand il est vrai, et quand c'est une décision.
Le problème, c'est que la plupart des couplages forts en production n'ont pas commencé comme un choix délibéré. Ils ont commencé comme un raccourci qui n'était pas censé durer, dans un service censé être temporaire, dans un projet où "on nettoiera ça plus tard" était sincère et n'est jamais arrivé.
Si vous décidez consciemment qu'un morceau de code n'a pas d'avenir, le couplage fort est un compromis valable. Écrivez cette décision dans un commentaire. Assumez-la.
Tout le reste est un accident qui n'attend que de s'accumuler.
La discipline est la partie qui ne semble jamais productive
Repository, Gateway, Adapter, Wrapper : les patterns sont documentés, bien compris, largement disponibles. Le problème n'est pas la connaissance. La plupart des développeurs qui écrivent du couplage fort savent exactement à quoi ressemble l'alternative.
Le problème, c'est le moment de la décision. La fonctionnalité est due. Le driver est juste là. Écrire l'interface a l'air d'une formalité sans résultat visible.
La discipline, c'est ce qui se passe à ce moment précis : définir le contrat avant d'écrire l'implémentation, non pas parce que la douleur est immédiate, mais parce que vous avez vu assez de bases de code pour savoir exactement où mène le raccourci.
Il mène à un service que vous ne pouvez pas tester sans base de données active. À une migration de fournisseur qui touche quarante fichiers au lieu d'un seul. À une base de code où remplacer une dépendance externe devient un projet, pas un après-midi.
Les patterns vous donnent le vocabulaire. La discipline, c'est ce qui vous pousse à vous en servir avant même de ressentir la douleur.