10 - Couplage et cohesion -- composition vs héritage
Ce que tu vas apprendre
- La différence entre couplage faible et couplage fort avec des exemples concrets
- Ce qu'est la cohesion et pourquoi elle rend les modules fiables
- Pourquoi la composition bat l'héritage dans la majorite des cas
- L'injection de dépendances sans framework
- Le découplage par événements
Prerequisites
Cet article fait suite a 09 - DRY, KISS, YAGNI. Les concepts de SOLID sont utiles ici, en particulier le D (Dependency Inversion).
Dans un projet e-commerce sur lequel j'ai travaille, le module de paiement importait directement le module utilisateur, qui importait le module de notification, qui importait le module de paiement. Un cercle parfait. Changer une ligne dans les notifications cassait les paiements. Personne ne comprenait pourquoi. Le couplage etait invisible et total.
Couplage : moins tu en sais, mieux tu te portes
Le couplage mesure a quel point un module depend des détails internes d'un autre. Un couplage fort signifie qu'un changement ici casse quelque chose la-bas.
typescript// Couplage fort : OrderService connait la structure interne de Database
class OrderService {
createOrder(order: Order): void {
const db = Database.getInstance();
db.connection.pool.query(
`INSERT INTO orders (id, user_id, total)
VALUES ($1, $2, $3)`,
[order.id, order.userId, order.total]
);
}
}
Ce service connaît le singleton, la connexion, le pool, et la syntaxe SQL. Si tu changes de base de donnees, tu reecris le service. Si tu changes la structure de la table, pareil.
typescript// Couplage faible : OrderService ne connait qu'une interface
interface OrderRepository {
save(order: Order): Promise<void>;
}
class OrderService {
constructor(private orders: OrderRepository) {}
async createOrder(order: Order): Promise<void> {
await this.orders.save(order);
}
}
Le service sait qu'il peut sauvegarder une commande. Il ne sait pas comment. C'est exactement la bonne quantité d'ignorance.
Cohesion : un module fait une chose bien
La cohesion mesure a quel point les éléments d'un module sont lies entre eux. Un module cohesif regroupe des choses qui changent ensemble pour les memes raisons.
typescript// Faible cohesion : un fourre-tout
class UserManager {
createUser(data: UserData): User { /* ... */ }
sendWelcomeEmail(user: User): void { /* ... */ }
generateInvoice(user: User): PDF { /* ... */ }
updateProfilePicture(user: User, file: Buffer): void { /* ... */ }
calculateLoyaltyPoints(user: User): number { /* ... */ }
}
// Forte cohesion : chaque classe a un role precis
class UserService {
create(data: UserData): User { /* ... */ }
update(id: string, data: Partial<UserData>): User { /* ... */ }
deactivate(id: string): void { /* ... */ }
}
class LoyaltyService {
calculatePoints(user: User): number { /* ... */ }
applyReward(user: User, reward: Reward): void { /* ... */ }
}
class UserNotificationService {
sendWelcome(user: User): void { /* ... */ }
sendDeactivation(user: User): void { /* ... */ }
}
La regle est simple : si tu dois modifier UserManager pour quatre raisons différentes (changement de regles de fidelite, nouveau template d'email, nouvelle validation de profil, nouveau format de facture), la cohesion est mauvaise.
Le piège de l'héritage
L'héritage parait naturel. Un AdminUser est un User, donc AdminUser extends User, non ? En pratique, l'héritage créé du couplage vertical. Chaque sous-classe est couplee a l'implementation de son parent.
typescript// Heritage : ca commence bien
class Animal {
constructor(protected name: string) {}
move(): string { return `${this.name} bouge`; }
eat(): string { return `${this.name} mange`; }
}
class Bird extends Animal {
fly(): string { return `${this.name} vole`; }
}
// Puis ca deraille
class Penguin extends Bird {
// Un pingouin ne vole pas. Que faire ?
fly(): string {
throw new Error("Les pingouins ne volent pas");
}
}
On viole le principe de substitution de Liskov (voir article 08). L'héritage a force un pingouin dans une hiérarchie qui ne lui convient pas.
La composition resout le problème
Avec la composition, on assemble des comportements au lieu de les hériter.
typescript// Comportements comme fonctions
interface Movement {
move(name: string): string;
}
const walking: Movement = {
move: (name) => `${name} marche`,
};
const flying: Movement = {
move: (name) => `${name} vole`,
};
const swimming: Movement = {
move: (name) => `${name} nage`,
};
// Composition : on assemble
class Animal {
constructor(
private name: string,
private movements: Movement[]
) {}
move(): string[] {
return this.movements.map((m) => m.move(this.name));
}
}
const duck = new Animal("Canard", [walking, flying, swimming]);
const penguin = new Animal("Pingouin", [walking, swimming]);
// duck.move() -> ["Canard marche", "Canard vole", "Canard nage"]
// penguin.move() -> ["Pingouin marche", "Pingouin nage"]
Pas de hiérarchie rigide. Pas de méthode qui lance une exception. Chaque animal a exactement les capacités dont il a besoin.
Injection de dépendances sans framework
Tu n'as pas besoin de NestJS ou d'InversifyJS pour faire de l'injection de dépendances. Un constructeur suffit.
typescript// Construction manuelle dans un fichier de composition
function createApp(): App {
const config = loadConfig();
const db = new PostgresDatabase(config.databaseUrl);
const userRepo = new PostgresUserRepository(db);
const emailService = new SmtpEmailService(config.smtp);
const userService = new UserService(userRepo, emailService);
const userController = new UserController(userService);
return new App(userController);
}
Ce fichier est le seul endroit qui connaît les implementations concrètes. Tous les autres modules travaillent avec des interfaces. C'est le "composition root" -- un pattern simple et efficace.
Découplage par événements
Quand deux modules n'ont pas besoin de se connaître du tout, les événements sont la solution.
typescript// Un EventEmitter type
type EventMap = {
"order:created": { orderId: string; userId: string };
"order:paid": { orderId: string; amount: number };
"user:registered": { userId: string; email: string };
};
class TypedEventBus {
private handlers = new Map<string, Function[]>();
on<K extends keyof EventMap>(
event: K,
handler: (data: EventMap[K]) => void
): void {
const list = this.handlers.get(event as string) ?? [];
list.push(handler);
this.handlers.set(event as string, list);
}
emit<K extends keyof EventMap>(event: K, data: EventMap[K]): void {
const list = this.handlers.get(event as string) ?? [];
list.forEach((handler) => handler(data));
}
}
// Le module de commande emet, le module de notification ecoute
// Ils ne se connaissent pas
orderService.on("order:created", (data) => {
notificationService.sendOrderConfirmation(data.userId, data.orderId);
});
Le module de commande ne sait pas que des notifications existent. Le module de notification ne sait pas comment les commandes sont créées. Chacun peut évoluer indépendamment.
Attention : les événements ajoutent de l'indirection. Un exces d'événements rend le flux d'exécution invisible. Comme pour tout, c'est une question d'équilibre. Plus de reflexions sur l'architecture découplé sur paltemps.fr.
Résumé
- Le couplage faible fait que les modules ne dependent pas des détails internes des autres
- La cohesion forte regroupe ce qui change ensemble pour les memes raisons
- L'héritage créé du couplage vertical et des hierarchies rigides
- La composition assemble des comportements -- plus flexible, plus testable
- L'injection de dépendances n'a pas besoin de framework -- un constructeur suffit
- Les événements decouplent complètement, mais ajoutent de l'indirection
Article précédent : 09 - DRY, KISS, YAGNI
Article suivant : 11 - Complexite cyclomatique