Dependency Inversion Principle : Le secret d’un code vraiment flexible

Dependency Inversion Principle : Le secret d’un code vraiment flexible

Le Dependency Inversion Principle (DIP) est le cinquième et dernier des principes SOLID. Il est souvent perçu comme l’un des plus puissants mais c’est aussi probablement le moins bien compris. Ce principe est fondamental pour créer une architecture flexible, testable et maintenable.

Définition

Les modules de haut niveau ne doivent pas dépendre de modules de bas niveau. Tous deux doivent dépendre d’abstractions.


Les abstractions ne doivent pas dépendre des détails. Ce sont les détails qui doivent dépendre des abstractions.

Autrement dit : ne liez pas vos classes à des implémentations concrètes, mais à des comportements abstraits (généralement des interfaces).

Une analogie visuelle : le robot et l’armoire à bras

Imaginez un robot dans un atelier high-tech. Il possède une interface standard sur son épaule pour connecter un bras. Ce robot peut recevoir n’importe quel bras parmi ceux disponibles dans une armoire : un bras laser, un bras à lame, un bras de précision, un bras avec perceuse…

Le robot ne connaît pas les détails internes de chaque bras. Il sait seulement comment activer ou désactiver un bras via une interface commune. Ce sont les bras qui s’adaptent à cette interface standard, pas le robot qui doit changer sa logique à chaque nouveau type d’outil.

C’est exactement le principe de l’inversion de dépendance : le module de haut niveau (le robot) dépend d’une abstraction (l’interface de connexion), et ce sont les modules de bas niveau (les bras-outils) qui s’y adaptent.


Qu’est-ce qu’une dépendance ?

Une dépendance représente tout objet dont une classe a besoin pour fonctionner. Par exemple, une classe de service peut dépendre d’un autre objet comme un repository, un utilitaire ou encore un service tiers qu’elle va appeler pour accomplir sa tâche.

En Java, on reconnaît une dépendance lorsqu’une classe utilise une autre classe pour fonctionner :

Voici un premier (très mauvais) exemple où la dépendance est directement instanciée à l’intérieur de la classe :

Java
class ServiceA {
    private final ServiceB b = new ServiceB();
}

Ici, ServiceA est étroitement couplé à ServiceB car elle le crée elle-même. Si l’implémentation de ServiceB change, ServiceA devra être modifiée également. Ce couplage fort viole le principe d’inversion de dépendance.

Une amélioration consiste à injecter ServiceB depuis l’extérieur (on appelle ça l’injection de dépendance) :

Java
class ServiceA {
    private final ServiceB b;

    public ServiceA(ServiceB b) {
        this.b = b;
    }
}

Ici, ServiceA dépend toujours de ServiceB, même si on ne l’instancie plus à l’intérieur. Ce point est essentiel à bien comprendre : l’injection de dépendance (DI) désigne simplement le fait de passer une dépendance depuis l’extérieur, plutôt que de l’instancier dans la classe. Mais cela ne signifie pas forcément que la dépendance est abstraite. On peut très bien injecter une dépendance concrète, et dans ce cas, le DIP n’est pas respecté.

Le DIP va plus loin : il impose que la dépendance injectée soit une abstraction (généralement une interface), ce qui permet de découpler complètement les modules entre eux. Ainsi, le module de haut niveau reste indépendant des détails d’implémentation, qui peuvent varier, être remplacés ou simulés en test sans modifier le code métier.

Pour appliquer le DIP, il faudrait que ServiceA ne dépende pas directement de ServiceB, mais d’une abstraction (une interface ou une classe abstraite) que ServiceB implémenterait. Ce n’est donc pas la manière dont la dépendance est injectée qui compte, mais de quoi la classe dépend.

Java
interface ServiceBInterface {
    void executer();
}

class ServiceBImpl implements ServiceBInterface {
    public void executer() {
        System.out.println("Service B exécuté");
    }
}

class ServiceA {
    private final ServiceBInterface b;

    public ServiceA(ServiceBInterface b) {
        this.b = b;
    }

    public void faireQuelqueChose() {
        b.executer();
    }
}
  • ServiceA dépend maintenant de l’interface ServiceBInterface.
  • N’importe quelle implémentation (réelle, mock, alternative) peut être injectée.
  • On respecte le DIP car le module de haut niveau dépend d’une abstraction. (une interface ou une classe abstraite) que ServiceB implémenterait.

Quels sont les problèmes d’une dépendance directe ?

1. Couplage fort

Votre classe est liée à une implémentation spécifique. Si ServiceB change, ServiceA devra probablement être modifiée aussi.

2. Difficulté à tester

Il est impossible de remplacer facilement ServiceB par un mock ou une implémentation de test.

3. Manque de flexibilité

Impossible de substituer dynamiquement un autre comportement sans modifier le code source.

4. Propagation des changements

Un changement dans une classe bas niveau peut se répercuter sur toutes les classes qui en dépendent directement.

Mauvais exemple : dépendance concrète

Java
class NotificationService {
    public void envoyer(String message) {
        System.out.println("Notification envoyée : " + message);
    }
}

class CommandeService {
    private final NotificationService notificationService;

    public CommandeService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void traiterCommande() {
        System.out.println("Commande traitée.");
        notificationService.envoyer("Confirmation envoyée.");
    }
}
  • CommandeService est verrouillé à l’implémentation concrète NotificationService.
  • Impossible de tester CommandeService sans réellement envoyer des notifications.

Bonnes pratiques : appliquer le DIP

Étape 1 : créer une abstraction

Java
interface Notificateur {
    void envoyer(String message);
}

Étape 2 : faire dépendre le module de haut niveau de cette abstraction

Java
class CommandeService {
    private final Notificateur notificateur;

    public CommandeService(Notificateur notificateur) {
        this.notificateur = notificateur;
    }

    public void traiterCommande() {
        System.out.println("Commande traitée.");
        notificateur.envoyer("Confirmation envoyée.");
    }
}

Étape 3 : fournir une implémentation concrète au moment de l’exécution

Java
class NotificationEmail implements Notificateur {
    public void envoyer(String message) {
        System.out.println("Email : " + message);
    }
}

class Main {
    public static void main(String[] args) {
        Notificateur email = new NotificationEmail();
        CommandeService service = new CommandeService(email);
        service.traiterCommande();
    }
}

CommandeService ne dépend plus de NotificationEmail, mais de Notificateur.

➡️ Il est donc facile de remplacer l’implémentation par NotificationSMS, NotificationSlack, ou un mock en test.

En résumé

Le principe d’inversion des dépendances vous encourage à :

  • dépendre d’abstractions plutôt que d’implémentations concrètes
  • inverser le contrôle : ce n’est plus la classe qui instancie ses dépendances, mais l’extérieur qui les injecte
  • rendre votre code plus flexible, testable, évolutif

Respecter le DIP, c’est faire un pas vers une architecture propre, modulaire, et durable.


À retenir

  • Ne créez pas vos dépendances dans vos classes : injectez-les.
  • Définissez des interfaces pour les comportements.
  • Faites dépendre vos classes d’abstractions.

Ressources complémentaires