Composition over Inheritance : pourquoi l'héritage vous trahit toujours au mauvais moment
Vous aviez conçu une belle hiérarchie de classes. Six mois plus tard, il faut ajouter une fonctionnalité qui ne rentre plus dans le moule. Bienvenue dans le problème que composition plutôt qu'héritage résout depuis trente ans.
Le Briefing Dev - les ressources et actus de la semaine, droit dans ta boîte chaque vendredi gratuitement.
Un jour, un développeur décide de modéliser une application de livraison. Il crée une classe Vehicule. Il en fait hériter Voiture, Camion et Moto. Tout fonctionne. Le modèle est élégant, on dirait un exercice de cours.
Six mois plus tard, l'entreprise ajoute les livraisons en vélo électrique. Un vélo, c'est un véhicule ? Oui. Mais un vélo n'a pas de moteur thermique. La méthode faireLePlein() héritée de Vehicule n'a aucun sens. On la surcharge pour qu'elle lance une exception. Puis arrive la trottinette. Puis les drones de livraison. La hiérarchie commence à craquer.
Ce qui ressemblait à une structure propre est devenu un champ de mines. Chaque nouvelle catégorie oblige à surcharger des méthodes qui ne devraient même pas exister, ou à ajouter des conditions dans la classe mère. C'est exactement le problème que "Composition over Inheritance" résout. Ce principe, popularisé dans les années 1990 par le livre Design Patterns du Gang of Four, affirme qu'il vaut mieux construire ses objets en les assemblant à partir de comportements indépendants (composition) plutôt qu'en les dérivant d'une classe parente (héritage).
Le vrai problème : l'héritage fige ce qui ne devrait pas l'être
L'héritage crée un couplage fort et permanent entre une classe fille et sa classe parente. Toute modification de la classe mère se propage automatiquement aux classes filles. Tout ajout de comportement doit s'inscrire dans la hiérarchie existante. Et surtout, une classe ne peut hériter que d'un seul parent direct (dans la plupart des langages courants), ce qui oblige à faire un choix d'axe de classification dès le départ.
Le piège, c'est que cet axe est rarement évident à l'avance. Prenons l'exemple de notre véhicule. On peut le classer par type (voiture, moto, vélo), par source d'énergie (essence, électrique, musculaire), par usage (livraison, transport de personnes, loisir), par capacité (urbain, longue distance). Chacun de ces axes a sa cohérence. L'héritage vous force à en choisir un seul comme structure principale. Les autres deviendront des exceptions à gérer au cas par cas.
"Préférez la composition d'objets à l'héritage de classe." Gang of Four, Design Patterns, 1994
L'exemple qui montre tout
Reprenons les véhicules avec une approche par héritage naïve :
class Vehicule {
demarrer() { /* ... */ }
faireLePlein() { /* ... */ }
rouler() { /* ... */ }
}
class Voiture extends Vehicule {
// hérite de tout
}
class VeloElectrique extends Vehicule {
faireLePlein() {
throw new Error("Un vélo ne fait pas le plein");
}
// doit neutraliser des méthodes qui ne s'appliquent pas
}
class Drone extends Vehicule {
rouler() {
throw new Error("Un drone ne roule pas, il vole");
}
faireLePlein() {
throw new Error("Un drone se charge, ne se fait pas le plein");
}
}
Cette situation a même un nom : l'antipattern de la méthode "refusée". Quand une classe fille doit surcharger une méthode héritée pour la désactiver, c'est que l'héritage modélise mal la réalité. Le vélo et le drone ne sont pas des sous-types valides de "véhicule qui fait le plein et roule". Ils sont tous des "véhicules" dans un sens général, mais ils n'ont pas les mêmes capacités. C'est exactement ce que dénonce le principe LSP (Liskov Substitution Principle) dans SOLID : une classe fille doit pouvoir remplacer sa classe mère sans casser le programme.
Voici la même situation avec la composition :
// Comportements indépendants
class MoteurThermique {
demarrer() { /* ... */ }
faireLePlein() { /* ... */ }
}
class MoteurElectrique {
demarrer() { /* ... */ }
recharger() { /* ... */ }
}
class DeplacementTerrestre {
avancer() { /* rouler */ }
}
class DeplacementAerien {
avancer() { /* voler */ }
}
// Assemblage par composition
class Voiture {
constructor() {
this.moteur = new MoteurThermique();
this.deplacement = new DeplacementTerrestre();
}
}
class VeloElectrique {
constructor() {
this.moteur = new MoteurElectrique();
this.deplacement = new DeplacementTerrestre();
}
}
class Drone {
constructor() {
this.moteur = new MoteurElectrique();
this.deplacement = new DeplacementAerien();
}
}
Chaque véhicule devient un assemblage de capacités. Ajouter un nouveau type (un bateau électrique, un scooter thermique, un hélicoptère) ne remet rien en cause. On combine des briques existantes, on en ajoute de nouvelles si besoin, sans toucher à la hiérarchie parce qu'il n'y a plus de hiérarchie.
Ce que composition plutôt qu'héritage n'interdit pas
Le principe ne dit pas "l'héritage est mauvais". Il dit "ne commencez pas par l'héritage, commencez par la composition, et n'utilisez l'héritage que quand il est clairement justifié". Cette nuance est importante, parce qu'on trouve beaucoup d'articles qui présentent le principe comme une interdiction pure, ce qui est faux.
L'héritage reste pertinent dans plusieurs situations :
- Quand la relation est vraiment une spécialisation "est-un" stable dans le temps. Un
CarreParfaitqui hérite deRectangleest une erreur classique. UnExceptionPaiementRefusequi hérite deExceptionMetierest naturel. - Quand on utilise un framework qui impose une classe de base. Hériter de
AbstractControllerdans Symfony ou deReact.Componenthistoriquement, c'est une contrainte technique normale. - Quand l'héritage permet de factoriser du comportement vraiment commun et stable, sur une profondeur de un, parfois deux niveaux. Au-delà, les problèmes commencent.
Le signal d'alarme : dès qu'une hiérarchie dépasse trois niveaux de profondeur, ou qu'une classe fille doit neutraliser ou contourner des méthodes héritées, c'est que l'héritage n'était pas le bon outil.
Les quatre façons concrètes d'appliquer la composition
Composition par délégation
La forme la plus simple. L'objet conserve en attribut un autre objet et délègue des opérations à lui. C'est ce qu'on a vu dans l'exemple des véhicules.
class Commande {
constructor(calculateurPrix, expediteur) {
this.calculateurPrix = calculateurPrix;
this.expediteur = expediteur;
}
finaliser() {
const total = this.calculateurPrix.calculer(this.lignes);
this.expediteur.expedier(this);
}
}
Changer la façon de calculer le prix ? On passe un autre calculateurPrix. Changer le mode d'expédition ? On injecte un autre expediteur. Aucune hiérarchie de Commande à gérer.
Composition par interfaces
Au lieu de dépendre d'une classe concrète, on dépend d'un contrat. Cette approche respecte aussi le principe DIP (Dependency Inversion) de SOLID. En TypeScript ou Java :
interface Notifier {
notifier(message: string): void;
}
class NotifierEmail implements Notifier { /* ... */ }
class NotifierSMS implements Notifier { /* ... */ }
class NotifierPush implements Notifier { /* ... */ }
class ServiceCommande {
constructor(private notifier: Notifier) {}
validerCommande(commande: Commande) {
// ...
this.notifier.notifier(`Commande ${commande.id} validée`);
}
}
Le service ne sait rien du canal de notification. On lui injecte celui qu'on veut au moment de la construction. Les tests deviennent triviaux : on injecte un NotifierFake qui enregistre simplement les messages.
Composition par traits ou mixins
Dans certains langages (Ruby, Rust, Scala, PHP 8+ avec les traits, JavaScript via des patterns de mixin), on peut combiner des comportements indépendants dans une même classe sans passer par l'héritage. Exemple en PHP :
trait Horodatable {
public $creeLe;
public $modifieLe;
public function toucher() { $this->modifieLe = new DateTime(); }
}
trait Journalisable {
public function journaliser($action) { /* ... */ }
}
class Commande {
use Horodatable, Journalisable;
}
Une Commande peut être horodatable et journalisable sans avoir à hériter de deux classes (ce qui est impossible dans la plupart des langages) et sans avoir à empiler dans une hiérarchie artificielle.
Composition fonctionnelle
En JavaScript moderne et dans les langages fonctionnels, on compose souvent sans classe du tout. On crée des objets à partir de fonctions qui retournent des capacités :
const avecHorodatage = (obj) => ({
...obj,
creeLe: new Date(),
modifieLe: new Date()
});
const avecJournal = (obj) => ({
...obj,
journaliser: (action) => console.log(action)
});
const creerCommande = (donnees) =>
avecJournal(avecHorodatage({ ...donnees }));
C'est l'approche que les formations JavaScript avancé et React explorent en profondeur, notamment avec les hooks personnalisés qui sont une forme de composition comportementale.
React : la bascule historique vers la composition
L'histoire de React illustre bien le principe. Jusqu'en 2018, les composants à état s'écrivaient avec des classes qui héritaient de React.Component. Pour partager du comportement, les développeurs utilisaient des patterns complexes : HOC (Higher-Order Components), render props, mixins dépréciés. Ces solutions fonctionnaient mais produisaient du code difficile à lire, avec des "wrapper hell" à plusieurs niveaux.
L'arrivée des hooks en 2019 a radicalement changé la donne. Un hook personnalisé est une fonction qui encapsule un comportement réutilisable. On ne partage plus du code par héritage ou par HOC, mais par composition de hooks dans un composant fonctionnel.
function useAuth() { /* logique d'authentification */ }
function useCart() { /* logique du panier */ }
function useAnalytics() { /* tracking */ }
function CheckoutPage() {
const { user } = useAuth();
const { items, total } = useCart();
const { trackEvent } = useAnalytics();
// composition de trois comportements indépendants
}
C'est de la composition pure. Chaque hook fait une chose, peut être testé isolément, et se combine sans collision avec les autres. L'ancien modèle par classe a pratiquement disparu des nouveaux projets React, et c'est un des meilleurs exemples grand public du principe "Composition over Inheritance" appliqué à l'échelle d'un écosystème entier.
Les signaux qui montrent qu'il faut refactoriser vers la composition
Des méthodes surchargées pour ne rien faire ou lever une exception
Le signe le plus clair. Une classe fille qui écrit throw new Error('non supporté') ou return null dans une méthode héritée dit clairement que l'héritage modélise mal la réalité.
Une hiérarchie de plus de trois niveaux
Chaque niveau ajoute une couche à remonter pour comprendre le comportement d'une méthode. Au-delà de trois, la charge cognitive devient prohibitive. Les frameworks historiques en Java ou C# ont pavé le terrain de cette leçon, avec des hiérarchies à six ou huit niveaux devenues cauchemardesques à maintenir.
Le besoin de deux axes de classification
Quand on se retrouve à vouloir dire "ceci est à la fois un Animal Carnivore et un Animal Aquatique", l'héritage simple bloque. La composition accepte sans effort ce genre de combinaison orthogonale.
Des tests qui obligent à instancier toute une chaîne de classes
Tester une méthode d'une classe fille qui dépend de méthodes des classes mères force à tout reconstruire. Avec la composition, on remplace une dépendance par un mock et on teste la classe isolément. C'est aussi ce que permet le respect de la Law of Demeter.
Comment migrer progressivement d'héritage vers composition
Dans un projet existant, refactoriser toute une hiérarchie d'un coup est rarement faisable. La bonne stratégie consiste à identifier les poches les plus douloureuses et à les refactoriser localement.
La technique classique s'appelle "extract class" : pour chaque comportement qui pose problème dans une classe fille, on extrait ce comportement dans une classe dédiée et on l'injecte en composition. Progressivement, la classe fille contient de moins en moins de spécificités héritées et de plus en plus de collaborateurs injectés. Au bout d'un certain nombre d'extractions, l'héritage devient vide de sens et peut être supprimé.
Cette démarche est abordée concrètement dans les formations PHP orienté objet et les fondamentaux de Java et POO, où le refactoring d'un design par héritage vers un design par composition fait partie des exercices pratiques.
Le lien avec les autres principes
Composition plutôt qu'héritage n'est pas un principe isolé. Il amplifie presque tous les autres principes de code.
Il facilite naturellement le respect de SOLID, notamment le principe de responsabilité unique (chaque collaborateur a son rôle) et le principe ouvert-fermé (ajouter un comportement devient ajouter une classe, pas modifier une hiérarchie).
Il s'aligne avec KISS en évitant les hiérarchies profondes et les méthodes surchargées pour rien. Un système composé est plus direct à lire qu'un système hérité, parce que chaque collaborateur est explicite.
Il dialogue avec Separation of Concerns : chaque objet composé a une préoccupation claire, et les assemblages sont explicites au lieu d'être implicites dans la hiérarchie.
Il évite les effets domino que Law of Demeter cherche à prévenir. Avec la composition par injection, les dépendances sont explicites, pas cachées dans une chaîne d'héritage.
Il peut toutefois entrer en tension avec DRY si on l'applique dogmatiquement : la composition peut demander un peu plus de code initial que l'héritage, parce qu'on écrit explicitement ce que l'héritage ferait automatiquement. Ce surcoût est réel, mais il est payant sur la durée.
FAQ
Composition plutôt qu'héritage signifie-t-il qu'il ne faut jamais utiliser l'héritage ?
Non. Le principe est de préférer la composition par défaut, pas d'interdire l'héritage. L'héritage reste pertinent pour modéliser des spécialisations vraies et stables (une exception métier qui hérite d'une exception générique, un type d'entité spécifique dans un framework), ou pour se conformer à des classes de base imposées par un framework. Le vrai conseil est : commencez par la composition, passez à l'héritage seulement quand il est clairement justifié et reste limité en profondeur.
Ce principe s'applique-t-il aussi en programmation fonctionnelle ?
Oui, sous une forme légèrement différente. En programmation fonctionnelle, l'équivalent de la composition est la composition de fonctions : combiner de petites fonctions pures pour former des comportements plus complexes. L'héritage de classe n'a pas d'équivalent direct, mais des notions proches existent dans les typeclasses (Haskell) ou les traits (Rust). Dans tous ces cas, l'esprit est le même : construire par assemblage de briques indépendantes.
Pourquoi le modèle par héritage est-il enseigné en premier dans la plupart des formations ?
Parce qu'il est plus facile à expliquer au départ : "un chien est un animal" est intuitif. La limite apparaît seulement quand on veut modéliser des systèmes plus complexes. Les bonnes formations en conception objet, comme celles de LaPolaris, enseignent l'héritage comme un outil, puis présentent ses limites et introduisent la composition comme le modèle à privilégier dès qu'un système dépasse la taille d'un exercice scolaire.
Les design patterns du Gang of Four sont-ils encore pertinents en 2026 ?
Oui, largement. Les 23 patterns originaux restent une référence, même si certains ont été absorbés dans les langages modernes (Iterator en JavaScript avec les generators, Observer via les événements natifs). Les patterns de composition comme Strategy, Decorator, Composite, Adapter sont particulièrement pertinents parce qu'ils sont tous des applications concrètes de Composition plutôt qu'héritage. Les connaître donne un vocabulaire partagé entre développeurs, même dans des langages et paradigmes différents.
Comment appliquer ce principe dans un projet qui utilise un framework basé sur l'héritage ?
La plupart des frameworks (Symfony, Spring, Angular) imposent un certain niveau d'héritage pour les contrôleurs, les entités ou les composants. La bonne pratique est d'accepter cette couche imposée, mais de ne pas la prolonger inutilement. Au lieu de faire hériter vos propres classes de vos classes de base, composez vos services et votre logique métier. Ainsi, l'héritage reste confiné à la frontière du framework, et votre domaine métier respecte le principe. Les formations Symfony 7 et Spring Boot montrent ce genre d'approche en pratique.
Quelle différence entre composition et agrégation ?
La composition décrit une relation forte où l'objet composé possède et contrôle le cycle de vie de ses parties (une voiture détient son moteur, le moteur n'existe pas sans la voiture dans ce contexte). L'agrégation décrit une relation plus souple où les objets existent indépendamment et sont simplement associés (une équipe agrège des joueurs, les joueurs existent même si l'équipe disparaît). Dans la pratique du code, cette distinction reste utile pour réfléchir à qui crée et détruit quoi, mais elle n'est pas toujours explicite dans le langage. Ce qui compte le plus, c'est que les deux relations respectent "Composition over Inheritance" en évitant le couplage rigide de l'héritage.
La composition rend-elle le code plus lent ?
Dans la quasi-totalité des cas, non. Le coût d'une indirection par composition est négligeable face aux gains en maintenabilité et en testabilité. Les compilateurs modernes (JIT en Java, V8 en JavaScript, PHP 8+ avec JIT) optimisent très bien ces indirections. Les cas où la performance pourrait justifier de l'héritage inline sont rares et relèvent du code critique mesuré, pas du code applicatif standard. Dans ces cas-là, la règle à suivre est celle de Knuth sur l'optimisation prématurée : mesurer d'abord, optimiser ensuite, et pas avant.
Comment reconnaître qu'une hiérarchie d'héritage existante est devenue toxique ?
Plusieurs signaux concordants : les nouveaux développeurs mettent plusieurs semaines à comprendre comment ajouter une fonctionnalité, les tests unitaires deviennent difficiles à écrire, chaque ajout de fonctionnalité demande de modifier plusieurs classes dans la hiérarchie, et des méthodes sont régulièrement surchargées pour désactiver ou contourner le comportement hérité. Quand trois de ces signaux sont présents, un refactoring progressif vers la composition devient urgent. Attendre plus longtemps multiplie le coût à chaque itération.