Le principe d'inversion de dépendance en Java

1. Aperçu

Le principe d'inversion de dépendance (DIP) fait partie de la collection de principes de programmation orientée objet communément appelés SOLID.

À la base, le DIP est un paradigme de programmation simple - mais puissant - que nous pouvons utiliser pour implémenter des composants logiciels bien structurés, hautement découplés et réutilisables .

Dans ce didacticiel, nous explorerons différentes approches pour implémenter le DIP - une en Java 8 et une en Java 11 à l'aide de JPMS (Java Platform Module System).

2. L'injection de dépendances et l'inversion de contrôle ne sont pas des implémentations DIP

Tout d'abord, faisons une distinction fondamentale pour bien comprendre les bases: le DIP n'est ni injection de dépendances (DI) ni inversion de contrôle (IoC) . Même ainsi, ils fonctionnent tous très bien ensemble.

En termes simples, DI consiste à créer des composants logiciels pour déclarer explicitement leurs dépendances ou collaborateurs via leurs API, au lieu de les acquérir par eux-mêmes.

Sans DI, les composants logiciels sont étroitement couplés les uns aux autres. Par conséquent, ils sont difficiles à réutiliser, à remplacer, à simuler et à tester, ce qui entraîne des conceptions rigides.

Avec DI, la responsabilité de fournir les dépendances des composants et les graphiques d'objets de câblage est transférée des composants au cadre d'injection sous-jacent. De ce point de vue, la DI n'est qu'un moyen d'atteindre l'IoC.

D'autre part, l' IoC est un modèle dans lequel le contrôle du flux d'une application est inversé . Avec les méthodologies de programmation traditionnelles, notre code personnalisé a le contrôle du flux d'une application. Inversement, avec IoC, le contrôle est transféré vers un framework ou un conteneur externe .

Le framework est une base de code extensible, qui définit des points d'accroche pour brancher notre propre code .

À son tour, le framework rappelle notre code via une ou plusieurs sous-classes spécialisées, en utilisant les implémentations d'interfaces et via des annotations. Le framework Spring est un bel exemple de cette dernière approche.

3. Principes de base du DIP

Pour comprendre la motivation derrière le DIP, commençons par sa définition formelle, donnée par Robert C. Martin dans son livre, Agile Software Development: Principles, Patterns, and Practices :

  1. Les modules de haut niveau ne doivent pas dépendre de modules de bas niveau. Les deux devraient dépendre d'abstractions.
  2. Les abstractions ne doivent pas dépendre des détails. Les détails doivent dépendre des abstractions.

Il est donc clair qu'au fond, le DIP consiste à inverser la dépendance classique entre les composants de haut niveau et de bas niveau en supprimant l'interaction entre eux .

Dans le développement logiciel traditionnel, les composants de haut niveau dépendent de composants de bas niveau. Ainsi, il est difficile de réutiliser les composants de haut niveau.

3.1. Choix de conception et DIP

Considérons une simple classe StringProcessor qui obtient une valeur String à l' aide d'un composant StringReader et l'écrit ailleurs à l'aide d'un composant StringWriter :

public class StringProcessor { private final StringReader stringReader; private final StringWriter stringWriter; public StringProcessor(StringReader stringReader, StringWriter stringWriter) { this.stringReader = stringReader; this.stringWriter = stringWriter; } public void printString() { stringWriter.write(stringReader.getValue()); } } 

Bien que l'implémentation de la classe StringProcessor soit basique, il existe plusieurs choix de conception que nous pouvons faire ici.

Décomposons chaque choix de conception en éléments distincts, pour comprendre clairement comment chacun peut avoir un impact sur la conception globale:

  1. StringReader et StringWriter , les composants de bas niveau, sont des classes concrètes placées dans le même package. StringProcessor , le composant de haut niveau est placé dans un package différent. StringProcessor dépend de StringReader et StringWriter . Il n'y a pas d'inversion des dépendances, par conséquent StringProcessor n'est pas réutilisable dans un contexte différent.
  2. StringReader et StringWriter sont des interfaces placées dans le même package avec les implémentations . StringProcessor dépend désormais des abstractions, mais pas les composants de bas niveau. Nous n'avons pas encore atteint l'inversion des dépendances.
  3. StringReader et StringWriter sont des interfaces placées dans le même package avec StringProcessor . Désormais, StringProcessor a la propriété explicite des abstractions. StringProcessor, StringReader et StringWriter dépendent tous d'abstractions. Nous avons réalisé l'inversion des dépendances de haut en bas en faisant abstraction de l'interaction entre les composants . StringProcessor est désormais réutilisable dans un contexte différent.
  4. StringReader et StringWriter sont des interfaces placées dans un package distinct de StringProcessor . Nous avons réussi l'inversion des dépendances et il est également plus facile de remplacer les implémentations StringReader et StringWriter . StringProcessor est également réutilisable dans un contexte différent.

De tous les scénarios ci-dessus, seuls les éléments 3 et 4 sont des implémentations valides du DIP.

3.2. Définition de la propriété des abstractions

L'élément 3 est une implémentation DIP directe, où le composant de haut niveau et la ou les abstraction (s) sont placés dans le même package. Par conséquent, le composant de haut niveau possède les abstractions . Dans cette implémentation, le composant de haut niveau est responsable de la définition du protocole abstrait à travers lequel il interagit avec les composants de bas niveau.

De même, le point 4 est une implémentation DIP plus découplée. Dans cette variante du modèle, ni les composants de haut niveau ni les composants de bas niveau n'ont la propriété des abstractions .

Les abstractions sont placées dans une couche séparée, ce qui facilite la commutation des composants de bas niveau. Dans le même temps, tous les composants sont isolés les uns des autres, ce qui permet une encapsulation plus forte.

3.3. Choisir le bon niveau d'abstraction

Dans la plupart des cas, le choix des abstractions que les composants de haut niveau utiliseront devrait être assez simple, mais avec une mise en garde à noter: le niveau d'abstraction.

Dans l'exemple ci-dessus, nous avons utilisé DI pour injecter un type StringReader dans la classe StringProcessor . Cela serait efficace tant que le niveau d'abstraction de StringReader est proche du domaine de StringProcessor .

En revanche, nous manquerions simplement les avantages intrinsèques du DIP si StringReader est, par exemple, un objet File qui lit une valeur String à partir d'un fichier. Dans ce cas, le niveau d'abstraction de StringReader serait bien inférieur au niveau du domaine de StringProcessor .

Pour faire simple, le niveau d'abstraction que les composants de haut niveau utiliseront pour interagir avec les composants de bas niveau doit toujours être proche du domaine des premiers .

4. Implémentations Java 8

Nous avons déjà examiné en profondeur les concepts clés du DIP, nous allons maintenant explorer quelques implémentations pratiques du modèle dans Java 8.

4.1. Implémentation directe DIP

Let's create a demo application that fetches some customers from the persistence layer and processes them in some additional way.

The layer's underlying storage is usually a database, but to keep the code simple, here we'll use a plain Map.

Let's start by defining the high-level component:

public class CustomerService { private final CustomerDao customerDao; // standard constructor / getter public Optional findById(int id) { return customerDao.findById(id); } public List findAll() { return customerDao.findAll(); } }

As we can see, the CustomerService class implements the findById() and findAll() methods, which fetch customers from the persistence layer using a simple DAO implementation. Of course, we could've encapsulated more functionality in the class, but let's keep it like this for simplicity's sake.

In this case, the CustomerDao type is the abstraction that CustomerService uses for consuming the low-level component.

Since this a direct DIP implementation, let's define the abstraction as an interface in the same package of CustomerService:

public interface CustomerDao { Optional findById(int id); List findAll(); } 

By placing the abstraction in the same package of the high-level component, we're making the component responsible for owning the abstraction. This implementation detail is what really inverts the dependency between the high-level component and the low-level one.

In addition, the level of abstraction of CustomerDao is close to the one of CustomerService, which is also required for a good DIP implementation.

Now, let's create the low-level component in a different package. In this case, it's just a basic CustomerDao implementation:

public class SimpleCustomerDao implements CustomerDao { // standard constructor / getter @Override public Optional findById(int id) { return Optional.ofNullable(customers.get(id)); } @Override public List findAll() { return new ArrayList(customers.values()); } }

Finally, let's create a unit test to check the CustomerService class' functionality:

@Before public void setUpCustomerServiceInstance() { var customers = new HashMap(); customers.put(1, new Customer("John")); customers.put(2, new Customer("Susan")); customerService = new CustomerService(new SimpleCustomerDao(customers)); } @Test public void givenCustomerServiceInstance_whenCalledFindById_thenCorrect() { assertThat(customerService.findById(1)).isInstanceOf(Optional.class); } @Test public void givenCustomerServiceInstance_whenCalledFindAll_thenCorrect() { assertThat(customerService.findAll()).isInstanceOf(List.class); } @Test public void givenCustomerServiceInstance_whenCalledFindByIdWithNullCustomer_thenCorrect() { var customers = new HashMap(); customers.put(1, null); customerService = new CustomerService(new SimpleCustomerDao(customers)); Customer customer = customerService.findById(1).orElseGet(() -> new Customer("Non-existing customer")); assertThat(customer.getName()).isEqualTo("Non-existing customer"); }

The unit test exercises the CustomerService API. And, it also shows how to manually inject the abstraction into the high-level component. In most cases, we'd use some kind of DI container or framework to accomplish this.

Additionally, the following diagram shows the structure of our demo application, from a high-level to a low-level package perspective:

4.2. Alternative DIP Implementation

As we discussed before, it's possible to use an alternative DIP implementation, where we place the high-level components, the abstractions, and the low-level ones in different packages.

For obvious reasons, this variant is more flexible, yields better encapsulation of the components, and makes it easier to replace the low-level components.

Of course, implementing this variant of the pattern boils down to just placing CustomerService, MapCustomerDao, and CustomerDao in separate packages.

Therefore, a diagram is sufficient for showing how each component is laid out with this implementation:

5. Java 11 Modular Implementation

It's fairly easy to refactor our demo application into a modular one.

This is a really nice way to demonstrate how the JPMS enforces best programming practices, including strong encapsulation, abstraction, and component reuse through the DIP.

We don't need to reimplement our sample components from scratch. Hence, modularizing our sample application is just a matter of placing each component file in a separate module, along with the corresponding module descriptor.

Here's how the modular project structure will look:

project base directory (could be anything, like dipmodular) |- com.baeldung.dip.services module-info.java   |- com |- baeldung |- dip |- services CustomerService.java |- com.baeldung.dip.daos module-info.java   |- com |- baeldung |- dip |- daos CustomerDao.java |- com.baeldung.dip.daoimplementations module-info.java |- com |- baeldung |- dip |- daoimplementations SimpleCustomerDao.java |- com.baeldung.dip.entities module-info.java |- com |- baeldung |- dip |- entities Customer.java |- com.baeldung.dip.mainapp module-info.java |- com |- baeldung |- dip |- mainapp MainApplication.java 

5.1. The High-Level Component Module

Let's start by placing the CustomerService class in its own module.

We'll create this module in the root directory com.baeldung.dip.services, and add the module descriptor, module-info.java:

module com.baeldung.dip.services { requires com.baeldung.dip.entities; requires com.baeldung.dip.daos; uses com.baeldung.dip.daos.CustomerDao; exports com.baeldung.dip.services; }

For obvious reasons, we won't go into the details on how the JPMS works. Even so, it's clear to see the module dependencies just by looking at the requires directives.

The most relevant detail worth noting here is the uses directive. It states that the module is a client module that consumes an implementation of the CustomerDao interface.

Of course, we still need to place the high-level component, the CustomerService class, in this module. So, within the root directory com.baeldung.dip.services, let's create the following package-like directory structure: com/baeldung/dip/services.

Finally, let's place the CustomerService.java file in that directory.

5.2. The Abstraction Module

Likewise, we need to place the CustomerDao interface in its own module. Therefore, let's create the module in the root directory com.baeldung.dip.daos, and add the module descriptor:

module com.baeldung.dip.daos { requires com.baeldung.dip.entities; exports com.baeldung.dip.daos; }

Now, let's navigate to the com.baeldung.dip.daos directory and create the following directory structure: com/baeldung/dip/daos. Let's place the CustomerDao.java file in that directory.

5.3. The Low-Level Component Module

Logically, we need to put the low-level component, SimpleCustomerDao, in a separate module, too. As expected, the process looks very similar to what we just did with the other modules.

Let's create the new module in the root directory com.baeldung.dip.daoimplementations, and include the module descriptor:

module com.baeldung.dip.daoimplementations { requires com.baeldung.dip.entities; requires com.baeldung.dip.daos; provides com.baeldung.dip.daos.CustomerDao with com.baeldung.dip.daoimplementations.SimpleCustomerDao; exports com.baeldung.dip.daoimplementations; }

In a JPMS context, this is a service provider module, since it declares the provides and with directives.

In this case, the module makes the CustomerDao service available to one or more consumer modules, through the SimpleCustomerDao implementation.

Let's keep in mind that our consumer module, com.baeldung.dip.services, consumes this service through the uses directive.

This clearly shows how simple it is to have a direct DIP implementation with the JPMS, by just defining consumers, service providers, and abstractions in different modules.

Likewise, we need to place the SimpleCustomerDao.java file in this new module. Let's navigate to the com.baeldung.dip.daoimplementations directory, and create a new package-like directory structure with this name: com/baeldung/dip/daoimplementations.

Finally, let's place the SimpleCustomerDao.java file in the directory.

5.4. The Entity Module

Additionally, we have to create another module where we can place the Customer.java class. As we did before, let's create the root directory com.baeldung.dip.entities and include the module descriptor:

module com.baeldung.dip.entities { exports com.baeldung.dip.entities; }

In the package's root directory, let's create the directory com/baeldung/dip/entities and add the following Customer.java file:

public class Customer { private final String name; // standard constructor / getter / toString }

5.5. The Main Application Module

Next, we need to create an additional module that allows us to define our demo application's entry point. Therefore, let's create another root directory com.baeldung.dip.mainapp and place in it the module descriptor:

module com.baeldung.dip.mainapp { requires com.baeldung.dip.entities; requires com.baeldung.dip.daos; requires com.baeldung.dip.daoimplementations; requires com.baeldung.dip.services; exports com.baeldung.dip.mainapp; }

Now, let's navigate to the module's root directory, and create the following directory structure: com/baeldung/dip/mainapp. In that directory, let's add a MainApplication.java file, which simply implements a main() method:

public class MainApplication { public static void main(String args[]) { var customers = new HashMap(); customers.put(1, new Customer("John")); customers.put(2, new Customer("Susan")); CustomerService customerService = new CustomerService(new SimpleCustomerDao(customers)); customerService.findAll().forEach(System.out::println); } }

Enfin, compilons et exécutons l'application de démonstration - soit à partir de notre IDE, soit à partir d'une console de commande.

Comme prévu, nous devrions voir une liste des objets Client imprimée sur la console au démarrage de l'application:

Customer{name=John} Customer{name=Susan} 

De plus, le schéma suivant montre les dépendances de chaque module de l'application:

6. Conclusion

Dans ce tutoriel, nous avons plongé en profondeur dans les concepts clés du DIP, et nous avons également montré différentes implémentations du modèle en Java 8 et Java 11 , ce dernier utilisant le JPMS.

Tous les exemples d'implémentation Java 8 DIP et Java 11 sont disponibles à l'adresse over sur GitHub.