Il principio di inversione delle dipendenze in Java

1. Panoramica

Il Dependency Inversion Principle (DIP) fa parte della raccolta di principi di programmazione orientata agli oggetti comunemente noti come SOLID.

In sostanza, il DIP è un paradigma di programmazione semplice ma potente che possiamo utilizzare per implementare componenti software ben strutturati, altamente disaccoppiati e riutilizzabili .

In questo tutorial, esploreremo diversi approcci per l'implementazione del DIP: uno in Java 8 e uno in Java 11 utilizzando JPMS (Java Platform Module System).

2. L'iniezione di dipendenza e l'inversione del controllo non sono implementazioni DIP

Innanzitutto, facciamo una distinzione fondamentale per ottenere le basi giuste: il DIP non è né iniezione di dipendenza (DI) né inversione di controllo (IoC) . Anche così, funzionano tutti alla grande insieme.

In poche parole, DI consiste nel creare componenti software per dichiarare esplicitamente le loro dipendenze o collaboratori attraverso le loro API, invece di acquisirli da soli.

Senza DI, i componenti software sono strettamente collegati tra loro. Quindi, sono difficili da riutilizzare, sostituire, simulare e testare, il che si traduce in progetti rigidi.

Con DI, la responsabilità di fornire le dipendenze dei componenti e gli oggetti grafici di cablaggio viene trasferita dai componenti al framework di iniezione sottostante. Da questo punto di vista, DI è solo un modo per ottenere IoC.

D'altra parte, IoC è un modello in cui il controllo del flusso di un'applicazione viene invertito . Con le metodologie di programmazione tradizionali, il nostro codice personalizzato ha il controllo del flusso di un'applicazione. Al contrario, con IoC, il controllo viene trasferito a un framework o contenitore esterno .

Il framework è una base di codice estensibile, che definisce i punti di aggancio per collegare il nostro codice .

A sua volta, il framework richiama il nostro codice attraverso una o più sottoclassi specializzate, utilizzando le implementazioni delle interfacce e tramite annotazioni. Il framework Spring è un bell'esempio di quest'ultimo approccio.

3. Fondamenti di DIP

Per comprendere la motivazione alla base del DIP, iniziamo con la sua definizione formale, fornita da Robert C. Martin nel suo libro, Agile Software Development: Principles, Patterns, and Practices :

  1. I moduli di alto livello non dovrebbero dipendere dai moduli di basso livello. Entrambi dovrebbero dipendere dalle astrazioni.
  2. Le astrazioni non dovrebbero dipendere dai dettagli. I dettagli dovrebbero dipendere dalle astrazioni.

Quindi, è chiaro che in fondo, il DIP riguarda l'inversione della classica dipendenza tra componenti di alto livello e di basso livello, astraendo l'interazione tra loro .

Nello sviluppo di software tradizionale, i componenti di alto livello dipendono da quelli di basso livello. Pertanto, è difficile riutilizzare i componenti di alto livello.

3.1. Scelte progettuali e DIP

Consideriamo una semplice classe StringProcessor che ottiene un valore String utilizzando un componente StringReader e lo scrive da qualche altra parte utilizzando un componente 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()); } } 

Sebbene l'implementazione della classe StringProcessor sia basilare, ci sono diverse scelte di progettazione che possiamo fare qui.

Rompiamo ogni scelta di design in elementi separati, per capire chiaramente come ciascuna può influire sul design generale:

  1. StringReader e StringWriter , i componenti di basso livello, sono classi concrete collocate nello stesso pacchetto. StringProcessor , il componente di alto livello viene inserito in un pacchetto diverso. StringProcessor dipende da StringReader e StringWriter . Non c'è inversione delle dipendenze, quindi StringProcessor non è riutilizzabile in un contesto diverso.
  2. StringReader e StringWriter sono interfacce posizionate nello stesso pacchetto insieme alle implementazioni . StringProcessor ora dipende dalle astrazioni, ma i componenti di basso livello no. Non abbiamo ancora raggiunto l'inversione delle dipendenze.
  3. StringReader e StringWriter sono interfacce posizionate nello stesso pacchetto insieme a StringProcessor . Ora, StringProcessor ha la proprietà esplicita delle astrazioni. StringProcessor, StringReader e StringWriter dipendono tutti dalle astrazioni. Abbiamo ottenuto l'inversione delle dipendenze dall'alto verso il basso astraendo l'interazione tra i componenti . StringProcessor è ora riutilizzabile in un contesto diverso.
  4. StringReader e StringWriter sono interfacce collocate in un pacchetto separato da StringProcessor . Abbiamo ottenuto l'inversione delle dipendenze ed è anche più facile sostituire leimplementazioni StringReader e StringWriter . StringProcessor è anche riutilizzabile in un contesto diverso.

Di tutti gli scenari di cui sopra, solo gli articoli 3 e 4 sono implementazioni valide del DIP.

3.2. Definizione della proprietà delle astrazioni

Item 3 is a direct DIP implementation, where the high-level component and the abstraction(s) are placed in the same package. Hence, the high-level component owns the abstractions. In this implementation, the high-level component is responsible for defining the abstract protocol through which it interacts with the low-level components.

Likewise, item 4 is a more decoupled DIP implementation. In this variant of the pattern, neither the high-level component nor the low-level ones have the ownership of the abstractions.

The abstractions are placed in a separate layer, which facilitates switching the low-level components. At the same time, all the components are isolated from each other, which yields stronger encapsulation.

3.3. Choosing the Right Level of Abstraction

In most cases, choosing the abstractions that the high-level components will use should be fairly straightforward, but with one caveat worth noting: the level of abstraction.

In the example above, we used DI to inject a StringReader type into the StringProcessor class. This would be effective as long as the level of abstraction of StringReader is close to the domain of StringProcessor.

By contrast, we'd be just missing the DIP's intrinsic benefits if StringReader is, for instance, a File object that reads a String value from a file. In that case, the level of abstraction of StringReader would be much lower than the level of the domain of StringProcessor.

To put it simply, the level of abstraction that the high-level components will use to interoperate with the low-level ones should be always close to the domain of the former.

4. Java 8 Implementations

We already looked in depth at the DIP's key concepts, so now we'll explore a few practical implementations of the pattern in Java 8.

4.1. Direct DIP Implementation

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); } }

Infine, compiliamo ed eseguiamo l'applicazione demo, dall'interno del nostro IDE o da una console di comando.

Come previsto, dovremmo vedere un elenco di oggetti Customer stampato sulla console all'avvio dell'applicazione:

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

Inoltre, il diagramma seguente mostra le dipendenze di ogni modulo dell'applicazione:

6. Conclusione

In questo tutorial, abbiamo approfondito i concetti chiave del DIP e abbiamo anche mostrato diverse implementazioni del pattern in Java 8 e Java 11 , con quest'ultimo che utilizza JPMS.

Tutti gli esempi per l'implementazione DIP Java 8 e l'implementazione Java 11 sono disponibili su GitHub.