7 min read

Réflexions sur l’injection de dépendance

L’injection de dépendance est une méthode de programmation qui m’a été présenté mais sans m’être expliquée, il aura fallu un livre pour qu’enfin je comprenne son intérêt. J’ai donc eu envie de partager avec vous ce que j’ai appris sur le sujet.
Réflexions sur l’injection de dépendance
Photo by Roméo A. / Unsplash

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 !».

Mon moi du passé au moment d'utiliser le @inject

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.

Une jeune fille criant : Prove it!

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 :

# in app/services/csv_exporter.rb
class CSVExporter
  def export_expenses(user_expenses)
    # not relevant here
  end
end

# in app/models/user_expenses.rb
class UserExpenses
  def export
    CSVExporter.new.export_expenses(self)
  rescue
    nil
  end
end

# in app/controllers/exports_controller.rb
class ExportsController
  def create
    @user_expenses = UserExpenses.find_by(user_id: params[:user_id])
    
    send_data @user_expenses.export
  end
end
L'implémentation complète naïve du problème

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 :

RSpec.describe UserExpenses do
  describe '#export' do
    let(:user_expenses) { UserExpenses.new }
  
    it “handles exporter errors“ do
      allow_any_instance_of(CSVExporter).to receive(:export_expenses).and_raise_error(StandardError)
      
      expect(user_expenses.export).to be_nil
    end
  end
end
J'en ai vu beaucoup des tests comme ceci 

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.

  1. A t’elle vraiment besoin de créer et utiliser une instance de la classe CSVExporter ?
  2. 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
# in app/models/user_expenses.rb
class UserExpenses
  def initialize(expenses_exporter = CSVExporter.new)
    @expenses_exporter = expenses_exporter
  end
  
  def export
    @expenses_exporter.export_expenses(self)
  end
end

# in app/controllers/exports_controller.rb
class ExportsController
  def create
    @user_expenses = UserExpenses.find_by(user_id: params[:user_id])
    
    send_data @user_expenses.export
  end
end
Ici on injecte la dépendance via le constructeur du modèle

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

RSpec.describe UserExpenses do
  class FakeExporter
    def export_expenses(_)
      raise
    end
  end

  describe ‘#export’ do
    let(:user_expenses) { UserExpenses.new(FakeExporter.new) }
  
    it “handles exporter errors“ do      
      expect(user_expenses.export).to be_nil
    end
  end
end
Il reste cependant possible d'utiliser un objet stubbé à la place du FakeExporter

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.

# in app/models/user_expenses.rb
class UserExpenses
  attr_writer :expenses_exporter
  
  def export
    @expenses_exporter.export_expenses(self)
  end
end

# in app/controllers/exports_controller.rb
class ExportsController
  def create
    @user_expenses = UserExpenses.find_by(user_id: params[:user_id])
    @expected_csv_cols = User.find(params[:user_id]).csv_cols

    @user_expenses.expenses_exporter = CSVExporter.new(@expected_csv_cols)
    
    send_data @user_expenses.export
  end
end
L'injection de dépendance peut aussi se faire via un setter

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.

# in app/services/json_exporter.rb
module JSONExporter
  def export_expenses(user_expenses)
    # do things
  end
  
  module_function :export_expenses
end

# in app/controllers/exports_controller.rb
class ExportsController
  def create
  	@user = User.find(params[:user_id])
    @user_expenses = UserExpenses.find_by(user_id: params[:user_id])

    @user_expenses.expenses_exporter = case params[:output]
      when :csv then CSVExporter.new(user.csv_cols)
      when :json then JSONExporter
    end
    
    send_data @user_expenses.export
  end
end
Une solution permettant de choisir le format de sortie d'un export

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