Réflexions sur l’injection de dépendance
Je n'avais pas prévu de me lancer sur un si vaste article car je ne suis pas encore certain de bien maitriser ce pattern. Mais j'y ai été encouragé par Rémi après avoir lu son dernier article qui traite du même sujet et en avoir discuté avec lui (au passage, allez y jeter un œil 😉).
Ma première rencontre avec l’injection de dépendance remonte à mes années d’études à l’époque où je pensais que Java était le meilleur langage de programmation (il faut bien que jeunesse se fasse 😌). Mon prof du moment nous avait présenté l'annotation @Inject
lorsque l'une de nos classes avait besoin d'utiliser une autre classe : «Et ainsi vous utilisez l'injection de dépendance !».
Une fois mes études finies et que je me suis intéressé au développement en Ruby, je n’ai plus jamais entendu parlé d'injection de dépendance. J’ai donc fini par me dire qu’il s’agissait d’une mécanique propre à Java, et qui permettait juste de lier un objet à un autre d'une façon un peu «magique», le seul bénéfice était de ne pas avoir à coder son assignation.
Tout à changé après la lecture du livre 99 Bottles of OOP de Sandi Metz et Katrina Owen (il va falloir que je vous parles de ce livre un jour, c’est un must have pour moi), dans lequel elle explique la notion de dépendance, pourquoi il faut éviter d’avoir un couplage fort entre les objets, et comment limiter cet aspect grâce à l’injection de dépendance.
Et il se trouve qu’au moment où je lisais ce livre j’avais expérimenté à plusieurs reprises des frictions dans mes développements car beaucoup de nos classes ont des dépendances fortes avec d’autres ce qui rend l’étape de test moins simple à réaliser.
Mais c’est quoi une dépendance ?
En développement, on parle de dépendance lorsqu’un objet a besoin d’un autre objet pour accomplir sa tâche.
Selon Wikipédia, il existe 4 conditions pour qu’un objet dépende d’un autre :
- la dépendance par composition : lorsque qu’un objet A possède un attribut de type B
- la dépendance par héritage : lorsque A est une sous-classe de B
- la dépendance par transivité : lorsque A dépend de C qui dépend de B
- la dependance d´usage : lorsque A appelle une méthode de B
Pour la suite de l’article, on va surtout s’attarder sur la dépendance par composition.
Pourquoi c'est mal d'avoir des dépendances entre objet ?
Si comme moi, vous connaissez la programmation orientée objet, vous savez qu'on ne peut pas, ne pas avoir de dépendance entre objets, ce paradigme encourage la multiplication des classes chacune ayant des responsabilités propres, qui doivent collaborer entre elles. Aller à l'encontre de ce paradigme finit très souvent par produire une application dont le code est indigeste à lire et à maintenir.
L'une des causes de cette indigestion provient de classes qui sont enchevêtrées les unes dans les autres à un point où le moindre changement nécessaire pour l'une des classes va nécessiter de la part du développeur de modifier le code de chacune des classes qui dépendent de cette dernière.
Il est donc important, de maintenir une saine relation entre les différents objets pour faciliter l'ajout de fonctionnalités ou la correction de bugs. Et l'injection de dépendance est une des techniques à notre disposition permettant d'améliorer la qualité du code d'une application.
Prenons l'exemple d'une application de gestion de facture qui affiche à l'utilisateur une liste de ses dépenses et lui permet d'exportes ces dernières au format CSV.
Une implémentation naïve pourrait être la suivante :
Imaginons maintenant que certains utilisateurs souhaitent modifier les colonnes retournées dans le fichier d'export CSV. La solution retenue par le développeur est de passer en paramètre les colonnes attendues au moment de la création d'une instance de la classe CSVExporter
. Le souci c'est qu'avec l'implémentation actuelle, il est nécessaire que le modèle UserExpenses
connaisse la notion de colonnes attendues par un utilisateur pour un export, pour pouvoir ensuite les transmettre à la nouvelle instance de CSVExporter
qui va pouvoir les utiliser; les colonnes attendues pour l'export des dépenses en CSV n'ont aucun intérêt dans le fonctionnement du modèle UserExpenses
pourtant à cause du couplage fort entre les 2 classes le développeur chargé de la feature va être obligé de les modifier toutes les 2.
Un autre problème qui apparait lorsque le couplage entre un objet et ses dépendances est trop fort, c’est qu’il est plus compliqué d’écrire les tests associés. Voici un exemple de test RSpec permettant de vérifier le comportement du modèle UserExpenses
en cas d’erreur rencontrée lors de son export :
Ici notre test a besoin de connaitre les détails de l’implémentation de la classe UserExpenses
pour tester son comportement. Ce qui rajoute un nouveau couplage fort entre le test et l’implémentation actuel de la classe et qui rendra encore plus difficile toute tentative de refactoring, car il y aura des modifications à apporter à plusieurs endroits en même temps augmentant le risque d’introduire des régressions.
Quelqu’un a commandé une injection de dépendance ?
Pour paraphraser 99 bottles of OOP, moins un objet connais de chose sur un autre, meilleur sera le système qui utilisera ces 2 objets car il sera plus facile de répondre à des nouveaux besoins utilisateurs car les zones affectées seront réduites au minimum.
Il faut donc se poser la question de savoir quelles informations sont vraiment nécessaires pour le fonctionnement de la classe UserExpenses
.
- A t’elle vraiment besoin de créer et utiliser une instance de la classe
CSVExporter
? - N’aurait t’elle pas juste besoin d’utiliser un objet qui sera chargé de gérer son export en CSV ?
Dépendez des abstractions, pas des concrétions.
- 99 bottles of OOP
A première vue ce refactoring n’est pas vraiment efficace, il y a plus de lignes de code pour un résultat identique.
Voyons donc comment tester la classe UserExpenses
réécrite
Ici nous pouvons voir qu’il y a déjà une première amélioration visible, notre test n’a plus besoin de connaitre le fonctionnement interne de la méthode #export
ni d’utiliser des mocks, tout ce qu’il faut c’est passer un objet répondant à la méthode #export_expenses
et qui accepte un objet en paramètre et lève une erreur.
Enfin, si il devient nécessaire de rendre configurable les colonnes du fichier CSV exporté en les passant dans le constructeur de la classe CSVExporter
, il sera très facile de faire la modification car les changements à apporter ne concerneront pas la classe UserExpenses
.
Vers l’infini et au delà !
Comme je l’ai évoqué au dessus, et comme les tests le montrent, notre classe UserExpenses
ne dépend plus directement de la classe CSVExporter
mais d’une abstraction, que j'ai nommée expenses_exporter, qui doit avoir dans son API publique une méthode #export_expenses(user_expenses)
.
Et c'est parce que nous avons réfléchi à la dépendance existante entre la classe UserExpenses
et la classe CSVExporter
et que nous avons mis en place l'injection de dépendance que nous avons pu réfléchir et nommée l'abstraction sous-jacente à notre exporter CSV dont les dépenses utilisateurs dépendent réellement.
Maintent que l'abstraction est acquise, il est possible de rajouter un nouveau format d’export pour des dépenses utilisateurs. Par exemple si certains d'entre eux préfèrent obtenir un export en JSON, il suffira alors d’ajouter un nouveau service chargé de gérer ce nouveau format et de l'injecter dans notre classe UserExpenses
à la place du CSVExporter
.
Pour conclure
Ça va bientôt faire 2 ans maintenant que j’ai pris conscience de la notion de classes fortement couplées, et d’injection de dépendance et il m’est maintenant très difficile de programmer autrement.
Je craignais au début que l’injection de dépendance allait rendre mes constructeurs beaucoup plus verbeux, et qu’ils allaient être surchargés de dépendance à injecter, mais au contraire car si je me retrouve à injecter plus de 2 dépendances dans une classe, je me pose toujours la question de savoir si l’objet que j’utilise est bien implémenté et si il ne devrait pas être séparé en plusieurs sous-objet ayant chacun leur dépendance.
Je suis donc 100% convaincu maintenant que l’injection de dépendance est un pattern utile, et qu’il devrait être mieux connu des développeurs Ruby afin de les aider à produire un code beaucoup plus souple.
Update 29/12/2021 : prise en compte des retours faits par Nicolas
Des liens pour aller plus loin
Dependency injection - Wikipedia
99 Bottles — Sandi Metz
Exploring dependency injection in Ruby - Remi Mercier