[DUMD 15/24] L'inversion des dépendances (Dependency Inversion Principle)

Auteur du billet de blog : Nicolas Hilaire - Neotech Solutions

Nicolas Hilaire

Consultant .NET
  Publié le mardi 10 janvier 2017

Artisan logiciel particulièrement intéressé par les technologies .NET. Polyvalent et curieux, je suis néanmoins à l'écoute des autres technologies du marché. MVP (Microsoft Most Valuable Professional) de 2007 à 2014, je suis également auteur d'un ouvrage pour apprendre le C#, à destination des débutants et de plusieurs MOOCs sur le C#, Windows Phone ou ASP.NET MVC...

Quinzième billet sur comment devenir un meilleur développeur. Pour retrouver le sommaire ainsi que tous les liens, rendez-vous sur le premier billet.

 

Nous allons parler dans ce billet du principe d'inversion des dépendances (le D de SOLID).

Les langages orientés objets comme le C# ou le Java nous offrent un atout majeur : le polymorphisme. Et grâce à lui, nous avons maintenant un super pouvoir : celui de pouvoir gérer nos dépendances correctement.

Lorsque nous tentons de résoudre des problèmes avec un langage de développement, nous avons souvent tendance à découper le problème initial en plus petits qui eux mêmes sont à redécouper en plus petits, etc. Cette approche dite "top-down" à tendance à nous faire créer des modules de bas niveau (gestion de fichiers, base de données, ...) afin qu'ils puissent être réutilisés par des modules de haut niveau, qui auront pour rôle d'orchestrer les modules de bas niveau.

 

Le problème des dépendances

Voici un exemple tout bête comme il peut y en avoir des centaines :

 

 

Nous avons donc une classe (PanierService) qui en utilise une autre (Logger) - l'objet panier ici ne nous intéresse pas.

Rien de transcendant. En analysant les dépendances, on voit bien que la classe PanierService dépend de la classe Logger. On peut résumer cette dépendance avec le schéma suivant :

 

 

Et voilà notre ami le changement qui pointe le bout de son nez !

 

Nononon, nous n'allons plus loguer dans un fichier texte désormais, il faut loguer dans une base de données NoSQL parce que mon voisin de palier m'a dit que c'était mieux.


Comme nous sommes de bonne composition, il suffit de modifier la classe Logger :

 

 

On garde la méthode précédente qui est probablement utilisée ailleurs, et on ajoute une nouvelle méthode. Et mince, cela oblige aussi à changer la classe PanierService, voire potentiellement d'autres :

 

 

Bon, on ne nous la fait pas. On sait très bien qu'il faut utiliser des abstractions, je l'ai assez répété. Voici notre interface et nos deux implémentations :

 

 

Et ainsi, le service panier utilise donc cette interface et n'a plus à changer si jamais on change de logueur :

 

 

Désormais, PanierService ne dépend plus d'un logger. Il dépend d'une abstraction.

Bravo, vous avez bien fait votre travail :).

On peut le résumer avec le schéma suivant :

 

 

Maintenant, la classe PanierService dépend de ILogger et les classes LoggerFichier et LoggerElasticSearch dépendent de ILogger (car elles implémentent l'interface).

Les trois classes dépendent de l'abstraction.

 

Au runtime bien sûr, on aura bien la classe PanierService qui utilisera une classe Concrète de Log, mais le super avantage c'est qu'à la compilation ces classes ne dépendent pas l'une de l'autre.


La classe PanierService utilise donc un "concept", un comportement de log, et peu importe ce qu'il y a derrière. C'est un peu comme lorsqu'on va au restaurant. Il suffit de dire qu'on veut manger tel plat, et finalement peu importe de savoir si tous les produits sont en stocks, s'ils sont périmés, si on a la sauce, si on a la recette, etc., tout ce qu'on a à savoir c'est qu'on veut commander tel plat et que le serveur saura nous l'apporter sans que l'on se pose plus de question.

 

Le cas de l'architecture en couches

Dans beaucoup de projets sur lesquels je suis intervenu, on retrouve une classique architecture en couches, dans ce genre là :

 

 

Une IHM, par exemple réalisée avec un framework web. Une couche métier, en général sans aucun framework. Puis une couche d'accès aux données, qui dans 99% des cas utilise une base de données et une bibliothèque pour accéder aux données (dans le monde .NET, on peut utiliser directement les classes d'ADO.NET ou un ORM comme Entity Framework ou NHibernate).

Il peut y avoir des variantes avec des couches en plus, voire des couches transverses comme des couches utilitaires. C'est tellement classique qu'on le retrouve partout. Et toujours avec une sacro-sainte règle : une couche de haut niveau ne peut appeler qu'une couche de niveau plus bas. On retrouve avec cette règle - que je ne mets volontairement pas en avant - le découpage top-down réalisé précédemment avec le gros problème découpé en plus petits. Le dernier petit problème que l'on rencontre au point de plus bas niveau est (souvent) une requête SQL en base de données.

J'imagine qu'un schéma de ce genre doit parler à tout le monde :

 

 

Les carrés sans remplissage représentent des modules différents (une assembly par exemple)


Quel est l'intérêt d'un tel découpage ? Ne pas avoir de dépendances cycliques. Et puis vaguement le fait qu'on doit pouvoir utiliser la classe Client dans le service client, mais aussi dans le service panier ou le service commande, etc.

Nous allons disséquer le cas de l'architecture en couches mal faite avec un exemple concret, en très réduit, d'un tel découpage.

Une méthode de démarrage (Main) qui appelle une couche de présentation :

 

 

L'IHM qui récupère les clients depuis la couche métier, et qui les affiche :

 

 

La couche métier qui récupère les clients dans un repository et qui applique une règle métier :

 

 

Enfin, la couche d'accès aux données qui ici simule un accès en base de données, avec son entité Client :

 

 

Je pense que vous devez vous y retrouver dans ce code, tellement il est simple et représentatif. Voici une petite copie d'écran des différents projets dans ma solution :

 

 

Notez qu'aux niveau des références projets

  • DemoArchiCouche référence Presentation
  • Presentation référence Metier
  • Metier réference AccessDonnees
  • AccessDonnees ne référence rien

Cela vous parait propre ? Pourtant, il y a un soucis avec ce découpage.

 

L'inversion de dépendance

L'inversion va encore plus loin que l'utilisation d'une abstraction comme on l'a vu pour le logger. La définition officielle nous dit :

 

Les modules de haut-niveau ne doivent pas dépendre des modules de bas-niveau. Ils doivent dépendre d'abstractions
Les abstractions ne doivent pas dépendre de détails, les détails doivent dépendre de l'abstraction



Je ne m'attarderai pas sur la seconde phrase, qui a surtout un intérêt dans un langage comme le C++ avec des fichiers .h, concentrons nous sur la première.

Relisons-la : ... modules de haut-niveau ... pas dépendre ... modules de bas-niveau. Ce n'est pas DU TOUT ce que nous avons fait au dessus. Il n'y a pas trente-six solutions pour faire en sorte que les modules de haut niveau ne dépendent pas des modules de bas niveau. Il faut introduire des abstractions :

 

 

 

Le point important est que les abstractions doivent appartenir au module qui les utilise, en l’occurrence par rapport aux couleurs sur le dessin.

 

Nous avons ici inversé la dépendance.


Reprenons notre exemple du log. Je suis un service de panier qui a besoin d'un comportement de log. Pour ne plus dépendre d'un quelconque logger, je dois m'assurer que mon abstraction fasse partie du module de mon service de panier et par contre, je ne dois dépendre d'aucune implémentation qui elles, seront dans un autre module :

 

 

Le cas des utilisations multiples

C'est bien beau tout ça, mais je fais comment lorsque j'ai plusieurs "services" qui doivent utiliser mon logger ? Bien sûr, ces services sont dans des modules séparés !
Dans ce cas, il faut introduire l'abstraction dans un autre module ; sachant que cela implique que les deux consommateurs doivent se mettre d'accord sur l'interface du composant. Cela sera plus clair avec un schéma :

 

 

Le cas des composants intégrés

Que se passe-t'il dans le cas où nous utilisons des composants prêts à l'emploi ? Dans notre cas du logger, on pourrait être tenté d'utiliser une bibliothèque comme Log4Net. Sauf que vous vous doutez bien que si Log4Net fourni une abstraction, elle sera forcément dans le même package que l'implémentation. Au mieux dans une assembly à part, mais certainement pas dans la votre.

Il faut donc que vous fournissiez une abstraction supplémentaire pour encapsuler le comportement de log. Et cela fonctionne très bien avec le pattern adapter par exemple :

 

 

Le code qui va bien

Maintenant, voici le code retravaillé pour tenir compte de l'inversion de dépendances.

Le Main s'occupe de créer toutes les classes (cela pourrait être fait par un container IOC), on appelle ça la composition des objets faite en général dans un composition root :

 

 

 La couche présentation est modifiée pour accepter une abstraction vers l'annuaire, injectée dans le constructeur :

 

 

Dans la couche métier, on a toujours notre Annuaire qui s'occupe d'implémenter les règles clients, sachant qu'il vient avec son abstraction (IAnnuaire) et qu'il dépend d'une interface IAnnuaireClient dont le rôle est de fournir des clients à l'annuaire. Sachant que ces clients ne sont pas des instances de la classe Client de la couche d'accès aux données, il s'agit d'un objet métier que j'ai appelé pour que ça soit plus clair : ClientMetier.

 

 

Dans la couche d'accès aux données, on a toujours notre repository et notre modèle Client, que j'ai renommé ClientModele pour que ce soit plus clair. Et nous avons en plus la classe ClientRepositoryAdapter qui implémente l'interface IAnnuaireClient de la couche métier. Comme on l'a vu, la dépendance est donc implémentée dans la couche de bas niveau, mais l'abstraction est dans la couche métier (et possède un nom "métier") :

 

 

Notez que maintenant, au niveau des références projets, nous avons :

  • DemoArchiCouche référence Presentation, Metier et AccessDonnees
  • Presentation référence Metier
  • Metier ne référence rien
  • AccessDonnees référence Metier

Grâce à l'inversion de dépendance, le métier est complètement isolé. Il ne dépend de rien. Et c'est bien ça le plus important, car ce qui apporte de la valeur à votre produit, c'est bien le cœur de métier ! Et donc, il est à l'abri de tous les détails dont il dépend. Si on veut changer la base de données, il suffit de changer le repository. Aucun impact dans la couche métier. Si on veut changer l'interface pour faire du web ou un autre client lourd plus évolué ? On change l'IHM, aucun impact sur la couche métier. Cette couche d'IHM pourrait sans problème accueillir des design pattern évolués comme MVP, MVC, MVVM, etc. Vous voulez changer le composition root et utiliser un container IOC ? Aucun impact.

Voici donc le schéma de notre architecture finale :

 

 

Si vous avez bien compris l'intérêt de cette architecture, vous pourrez passer au stade supérieur et effectuer des recherches sur l'architecture hexagonale, appelée également "port and adapter", avec des variantes comme l'architecture en oignon ou encore d'autres variantes plus poussée comme la clean architecture de Robert C. Martin et le développement par plugin.

 

Pour aller jusqu'au bout

Pour aller jusqu'au bout dans l'application de ce principe, faites en sorte que vos variables membres ne soient jamais typées avec une classe concrète. Toujours une abstraction.

De la même façon, aucune classe ne doit dériver d'une classe concrète. Toujours une classe abstraite.

Bien sûr, vous allez fatalement violer ce principe à un moment ou un autre, typiquement dans la phase de composition des objets, car il faut bien à un moment créer les instances concrètes des classes. Mais c'est isolé.

Il vous arrivera bien sûr aussi d'utiliser des classes concrètes directement, par exemple la classe String de votre framework préféré, ou d'autres classes du même genre. Mais là non plus ce n'est pas grave car la dépendance n'est pas volatile, elle a très peu de chance de changer !

 

Injection de dépendances

En général l'injection de dépendance est souvent confondue avec l'inversion de dépendance. Même si les concepts sont liés, on parle d'injection de dépendance comme d'une série de techniques permettant de fournir une dépendance. On peut injecter une dépendance par le constructeur, par une propriété, directement en paramètre d'une méthode, etc.

Le but de l'injection de dépendance est de séparer la façon dont est obtenue une dépendance du cœur de métier du composant. On peut utiliser un conteneur IOC pour cela (mais ce n'est pas une obligation).

Alors que vous l'aurez compris, l'inversion de dépendances est une technique pour gérer les dépendances entre les objets.

 

Conclusion

L'application du principe d'inversion de dépendance est une clé dans vos développements de qualité, c'est pour cela que j'ai voulu présenter le DIP en premier. Grâce au polymorphisme des langages orientés objet vous allez pouvoir faire en sorte que vos modules de haut-niveau ne dépendent plus des modules de bas-niveau. Ils dépendront d'abstractions et vous pourrez ainsi isoler les parties importantes de vos applications, typiquement les règles métier.

 

Prochain billet sur le principe de responsabilité unique.

Commentaires