Skip to content

Arrêtez de développer n'importe comment !

Les Principes SOLID

En programmation informatique, SOLID est un acronyme représentant cinq principes de base pour la programmation orientée objet.

Ces cinq principes sont censés apporter une ligne directrice permettant le développement de logiciels plus fiables, plus robustes, plus maintenables, plus extensibles et plus testables.

Pour en savoir plus sur les principes SOLID nous avons rédigé un petit document en Français ici

Aujourd'hui, nous allons nous interesser au D de SOLID le dernier de ces 5 principes.

Tous les exercices sont faisables entièrement en ligne depuis le site de TypeScript

Dependency Inversion Principle

  • Dependency Inversion Principle ou « principe d'inversion des dépendances » stipule que les objets doivent dépendre d'abstractions plutôt que d'implémentations. Cela signifie qu'il est préférable de typer des arguments avec des types abstraits (classes concrètes ou interfaces) plutôt que des types concrets (classes concrètes) afin de diminuer les couplages et favoriser d'autres implémentations.

Attardons-nous sur la notion importante de ce principe : Inversion.

Le principe de DIP stipule que les modules de haut niveau ne doivent pas dépendre de modules de plus bas niveau. Mais pour quelle raison ?

Pour répondre à cette question, prenons la définition à l’envers : les modules de haut niveau dépendent de modules de bas niveau.

En règle générale les modules de haut niveau contiennent le cœur – business – des applications. Lorsque ces modules dépendent de modules de plus bas niveau, les modifications effectuées dans les modules « bas niveau » peuvent avoir des répercussions sur les modules « haut niveau » et les « forcer » à appliquer des changements.

Exercice

🏠 Pour illustrer ce principe nous allons prendre l'exemple d'une maison connectée. Nous allons devoir créer les différents équipements qui composent cette maison.

  • Prenons une lampe 🛋 :
    // Tous les exemples sont en TypeScript.
    class Lamp {
        stateOn(): void {
            console.log("Jour")
        }
    
        stateOff(): void {
            console.log("Nuit")
        }
    }
    
  • Puis une classe interrupteur qui permet d'allumer la lampe :
    class LampButton {
    
        private state = false
    
        constructor(private lamp: Lamp) {}
    
        //Toggle Lamp on / off
        toggle(): void {
            this.state = !this.state
            if (this.state) {
                this.lamp.stateOn()
            } else {
                this.lamp.stateOff()
            }
        }
    }
    

📝 Complétez le code puis ajoutez une classe "fan" qui pourra être allumé par un bouton. De façon a correspondre à ce diagrame :

graph LR
    LampButton -->|use| Lamp
    FanButton -->|use| Fan

Faites fonctionner le ventilateur et la lampe :

const lamp = new Lamp()
const lampButton = new LampButton(lamp)

const fan = new Fan()
const fanButton = new FanButton(fan)

lampButton.toggle()
fanButton.toggle()

👉 Question 1 Peut-on utiliser la classe Lamp séparement ? (A-t-on besoins de quelques chose pour l'instancier ?)

:::spoiler C'est quoi une instance ? Avant tout, pour simplifier notons qu'un "objet de classe" est une "instance de classe".

De plus pour comprendre une instance, il faut savoir ce qu'est une classe. Les classes permettent en quelque sorte de décrire un concept. Les classes, c'est le "plan" selon lequel on va construire un objet. C'est aussi un peu comme le dictionnaire, la définition d'un mot représente la classe, et son utilisation concrète dans une phrase c'est son instance. Le programme ne manipule pas des classes, mais des objets.

Je pourrais par exemple avoir plusieurs objets lampe : lampeDeBureau et lampeDeChevet. Ces deux lampes correspondent à la définition de la classe Lamp :::

👉 Question 2 Peut-on utiliser la classe Fan séparément ? (Pareil)

👉 Question 3 Peut-on utiliser les classes Button séparément ? (Idem)

👉 Question 4 Que ce passe-t-il si un nouveau modèle de lampe sort tout juste sur le marché ? Imaginons que désormais la lampe s'apelle NeoLamp que faudrait-il faire pour lui ajouter un interrupteur ? (Sans utiliser l'héritage)

👉 Question 5 Quel est le problème ?

👉 Question 6 Quels autres solutions peut-on envisager ?

👉 Question 7 En quoi dans l'avenir, et sur un code plus complexe, cela peut-il poser des problèmes ? Ici nous pouvons raisoner en terme de nombre de classes modifiées et importance/complexité des classes.


L'exercice ici est d'acquérir et de démontrer une vision d'ensemble et d'anticipation des futurs points bloquants d'un projet.

Appliquons le principe d'inversion des dépendances

La première étape et de faire de l’abstraction. Et généralement quand on parle d'abstraction on parle d'interface.

Donc a des fins d'« inversion », nous allons utiliser une interface :

graph LR
    IEquipment["IEquipment"]
    Lamp -->|implement| IEquipment
    Fan -->|implement| IEquipment
    Button -->|use| IEquipment

:::info 💡 Notez une chose intéressante à propos des dépendances par rapport à la lampe et au ventilateur: Nous les avons inversés (Le mot est lâché) ! À présent les flèches partent de (et non plus vers) Lamp et Fan. Les trois classes se rejoignent sur IEquipment. :::

👉 Question 8 A quoi peut ressembler l'interface IEquipement ?

:::spoiler C'est quoi une interface ?

Les interfaces servent à définir des contrats. La notion d'interface est utilisée pour représenter des propriétés transverses de classes. Une interface nous dit juste que telle classe possède telle propriété, indépendamment de ce qu'elle représente. Par exemple nous pourrions ajouter l'interface Inflammable à nos lampe et ventilateur pour définir si oui ou non ces objet sont inflammables.

Attention les interfaces ne doivent pas être confondues avec l'héritage : l'héritage représente un sous-ensemble (exemple : un magicien est un sous-ensemble d'un personnage). Ainsi, une voiture et un personnage n'ont aucune raison d'hériter d'une même classe. Par contre, une voiture et un personnage peuvent tous les deux se déplacer, donc une interface représentant ce point commun pourra être créée. :::

:::spoiler Correction si vous n'y arrivez pas

interface IEquipment {
    stateOn(): void
    stateOff(): void
}
:::

👉 Question 9 Implémentez l'interface dans les classes correspodantes. Dans le constructeur de la classe Button faites en sorte de dépendre d'abstraction (l'interface) plutot que de l'implémentation (les classes concrètes).

:::spoiler Correction si vous n'y arrivez pas

class Fan implements IEquipment {
    stateOn(): void {
        console.log("Pfffffffffff")
    }

    stateOff(): void {
        console.log("....")
    }
}
class Lamp implements IEquipment {
    stateOn(): void {
        console.log("Jour")
    }

    stateOff(): void {
        console.log("Nuit")
    }
}
class Button {
    private state = false

    constructor(private equipment: IEquipment) {}

    toggle(): void {
        this.state = !this.state
        if (this.state) {
            this.equipment.stateOn()
        } else {
            this.equipment.stateOff()
        }
    }
}

A l’utilisation, voici ce que ça donne :

const lamp = new Lamp()
const lampButton = new Button(lamp)

const fan = new Fan()
const fanButton = new Button(fan)

lampButton.toggle()
fanButton.toggle()

:::

L’essentiel est donc de ne pas dépendre de l’implémentation, mais d’une abstraction. Grâce à cela, l’implémentation (les classes Lamp et Fan) peut être remplacée ou si votre projet évolue nous pouvons en ajouter. Libre à vous de créer des RollerShutter, Hoover etc… tant que ceux-ci implémentent l’interface IEquipment. Au final, peu importe vos équipements, le code ne bougera pas.

👉 Question 10 Et si finalement je voulais ajouter un autre type de bouton ? Par exemple un interrupteur à réglage variable. * Quel impact cela aurait dans le code ?

👉 Question 11 Admettons que par la suite, en plus d'avoir ce nouveau type de bouton, je dois modifier la classe IEquipement car finalement je veux ajouter un autre type de comportement. * Quel sont les impacts dans le code ? Quel est le problème ?

Nous avons donc toujours pas entièrement répondu au principe d’inversion des dépendances.

Quel est la définition exact du principe d’inversion des dépendances ?

La définition est la suivante : Les modules de haut niveau ne devraient pas dépendre des modules de bas niveau. Les 2 devraient dépendre d’abstraction. * Les abstractions ne devraient pas dépendre de détails (= de l’implémentation). * Les détails (= les implémentations) devraient dépendre d’abstractions.

Ce qui se traduit en code par :

interface IButtonAdapter {
    turnOn(): void
    turnOff(): void
}
class Button {

    private state = false

    constructor(private adapter: IButtonAdapter) {}

    toggle(): void {
        this.state = !this.state
        if (this.state) {
            this.adapter.turnOn()
        } else {
            this.adapter.turnOff()
        }
    }
}

Nous avons donc :

graph LR
    IEquipment["IEquipment"]
    IButtonAdapter["IButtonAdapter"]
    Lamp -->|implement| IEquipment
    Fan -->|implement| IEquipment
    ButtonAdapter -->|implement| IButtonAdapter
    Button -->|use| IButtonAdapter

Les 2 modules sont maintenant indépendants, les interfaces sont bien définies (et différentes), les tests unitaires sont probablement encore plus simples (on ne mock que le nécessaire, comme on a dit lors du I-Interface Segregation)… :::warning Mais une chose saute aux yeux : ils sont tellement indépendants qu’ils ne savent plus communiquer ensemble! Nous avons un petit problème 🙂 :::

Il existe une solution pour faire communiquer 2 modules qui « ne parlent pas la même langue ». Cette solution, c'est le pattern Adapter.

Je vous invite à faire une recherche de ce design pattern sur google.

👉 Question 12 A quoi sert ce Design pattern ? En quoi peut-il être utile dans notre cas ?

Ça peut paraitre compliqué, mais ça ne l’est pas.

Voici son code, il fait vraiment office de passerelle entre les 2 modules en implémentant une interface et en utilisant l’autre :

//Implémentation de l'interface 
class ButtonAdapter implements IButtonAdapter {

    constructor(private equipment: IEquipment) {}

    turnOn(): void {
        this.equipment.stateOn()
    }

    turnOff(): void {
        this.equipment.stateOff()
    }
}

A l’utilisation, voici ce que ça donne :

const lampButtonAdapter = new ButtonAdapter(new Lamp())
const fanButtonAdapter = new ButtonAdapter(new Fan())

const lampButton = new Button(lampButtonAdapter)
const fanButton = new Button(fanButtonAdapter)

lampButton.toggle()
fanButton.toggle()

Voilà à quoi cela va ressembler en UML. Vous remarquerez que les flèches partent de ButtonAdapter, les 2 modules ne se connaissent toujours pas:

graph LR
    IEquipment["IEquipment"]
    IButtonAdapter["IButtonAdapter"]
    Lamp -->|implement| IEquipment
    Fan -->|implement| IEquipment
    ButtonAdapter -->|implement| IButtonAdapter
    Button -->|use| IButtonAdapter
    ButtonAdapter -->|use| IEquipment

👉 Question 13 En guise de conclusion, pour vous quels sont les avantages de cette façon de faire ? Dans quel cas cela peut-il être utile ?


(c) Surpuissant - Damien Dabernat 2025