Skip to content

Latest commit

 

History

History
258 lines (201 loc) · 12.4 KB

6. Dependency Inversion Principle - DIP.MD

File metadata and controls

258 lines (201 loc) · 12.4 KB

Dependency Inversion Principle

Violations of DIP happens when higher level modules depend on lower level ones susceptible to change.

  • Well designed software objects have single responsibily, so they would depend on other objects to get things done (assuming they are organized). However, if unrelated objects get intertwined and become difficult to untie knots between objects. This is called Tight Coupling. To avoid this, it is recommended to follow SOLID principle.

Dependency Inversion

  • DIP states that Highlevel modules should not depend on Low-level modules. Both should depend on Abstraction. Abstraction should not depend on details and details should depend upon abstractions.

  • In other words, we should always program to interfaces, not to implementations. This allows for greater flexibility, modularity, and ease of testing.

  • We solved this in Part 5. by introducing abstraction.

abstract public class Employee {
    abstract public void performDuties();
    
    // ...
}
  • As children of the abstract Employee class it became compulsory for them to implement all abstract methods defined on the paired abstract class. So each kind of employee implemented its own version of performDuties (this is the details mention above) method whatever performing duties specifically meant for them.
  • Other classes could then depend on the abstract idea of employees working when sent the perform duties message.
  • If the specific details of their work changed in let's say the doctor class or the nurse class the clients sending the perform duties message wouldn't know or care.

Abstract Classes and Interfaces don't change as often as concrete derivatives (Classes that implement other Classes or Interfaces)

  • Abstract classes and interfaces change far less than classes that extend and implement them.
  • Abstract methods and interfaces define a contract that their derivatives must materialize and this contract is not meant to change often.

Exception

  • However, it's totally OK to depend on concrete low-level classes as long as they don't change.
  • For example:
    • Defining variables of type String is a ubiquitous dependency. I can go as far as saying pretty much all classes have a dependency on the String class but there is no harm in that because we can pretty much guarantee that the String class is not going to change in a way that would harm our objects that depend on its behavior.

Example

image

italic for Abstraction, and # sign for protected scope. (in UML)

  • Protected visibility for variables and methods is used when we want only subclasses to have visibility within a package to them.
package processes;

public abstract class GeneralManufacturingProcess {
    private String processName;

    public GeneralManufacturingProcess(String name){
        processName = name;
    }

    protected abstract void assembleDevice();
    protected abstract void testDevice();
    protected abstract void packageDevice();
    protected abstract void storeDevice();

    // template method
    public void launchProcess(){
        if (processName != null && !processName.isEmpty()) {
            packageDevice();
            assembleDevice();
            testDevice();
            storeDevice();
        } else {
            System.out.println("no process name was specified");
        }
    }
}
package processes;

public class SmartphoneManufacturingProcess extends GeneralManufacturingProcess{
    public SmartphoneManufacturingProcess(String name) {
        super(name);
    }

    @Override
    protected void assembleDevice() {
        System.out.println("assembled smartphone");
    }

    @Override
    protected void testDevice() {
        System.out.println("tested smartphone");
    }

    @Override
    protected void packageDevice() {
        System.out.println("packaged smartphone");
    }

    @Override
    protected void storeDevice() {
        System.out.println("stored smartphone");
    }
package processes;

public class LaptopManufacturingProcess extends GeneralManufacturingProcess{

    public LaptopManufacturingProcess(String name) {
        super(name);
    }

    @Override
    protected void assembleDevice() {
        System.out.println("assembled laptop");
    }

    @Override
    protected void testDevice() {
        System.out.println("tested laptop");
    }

    @Override
    protected void packageDevice() {
        System.out.println("packaged laptop");
    }

    @Override
    protected void storeDevice() {
        System.out.println("stored laptop");
    }
}
package clients;

import processes.GeneralManufacturingProcess;
import processes.SmartphoneManufacturingProcess;


public class DeviceFactory {
    public static void main(String args[]){
        GeneralManufacturingProcess manufacturer = new SmartphoneManufacturingProcess("Iphone process");
        manufacturer.launchProcess(); 
        // cannot access the other processes (eg. assembleDevice()...) since they are protected, which means 
        // DeviceFactory class, which is not a children of the `GeneralManufacturingProcess` cannot access those methods.
        // Those protected methods are not visible to the DeviceFactory class.
        // In Java, only classes that are childs of a class can access the protected scope,
        // unless they are in the same Package. However this is An ANTI-PATTERN if they are in the same package.
    }
}

This is the Template Method Design Pattern.

  • It's often used in abstracting workflows common across many types of objects the abstract methods are implemented in the subclasses but are called in the abstract class itself as part of a generally defined process or flow. More formally this design pattern is classified as a behavioral design pattern that defines the program skeleton in a method called the Template method.

  • The beauty of the Template method is it defines the general flow without getting into the details.

    • For example there's a big difference in packaging a smartphone or a laptop (different details). But the flow is the same. GeneralManufacturingProcess Abstract classes allow launching the process of assembling with the launchProcess method while keeping the process the same across different device's manufacturing processes (SmartphoneManufacturingProcess,LaptopManufacturingProcess) with different details implemented for each device's manufacturing process.
  • If we ever wanted to change the order of manufacturing methods, or add more processes methods (assembleDevice, testDevice, packageDevice, storeDevice), we are free to do so in the GeneralManufacturingProcess Abstract Class' launchProcess, which means the changes will also reflect on the child classes that implement GeneralManufacturingProcess. (eg. changing the order of processes in the method will also change order of processes of Laptop and Smartphone manufacturing, and adding abstract methods will require the child classes to also methods to implement the parent's method)

  • This means the lower-level modules SmartphoneManufacturingProcess and LaptopManufacturingProcess depends on the higher-level modules GeneralManufacturingProcess, not the other way around (Exception). Also, We can easily switch between different implementations of GeneralManufacturingProcess without changing the code of DeviceFactory. This is the Dependency Inversion Principle.

// adding abstract methods here will require child classes to implement the parent's method
protected abstract void assembleDevice();
protected abstract void testDevice();
protected abstract void packageDevice();
protected abstract void storeDevice();

public void launchProcess() {
    if (processName != null && !processName.isEmpty()) {
        // changing the order of these methods will reflect the processes of the child classes
        packageDevice();
        assembleDevice();
        testDevice();
        storeDevice();
    } else {
        ...
    }
}

Note about packages

  • It's important to always organize packages around the features in the application. Package names should correspond to important high level concepts. The goal in package design should be High Cohesion.
  • In other words, classes that are related should be packaged together and this allows for more modular design (High Cohesion). This is simiar to the SRP, which means Classes should have a single responsibility. In this case, a package should have a single feature.

Violation Example

public class NotificationManager {
    private EmailService emailService;

    public NotificationManager() {
        emailService = new EmailService();
    }

    public void notifyByEmail(String message) {
        emailService.sendEmail(message);
    }
}

public class EmailService {
    public void sendEmail(String message) {
        // implementation of email notification service
    }
}

In this example, the NotificationManager class directly depends on the EmailService class, violating the Dependency Inversion Principle. This makes the NotificationManager class tightly coupled to the EmailService implementation, making it difficult to switch to a different implementation or add new notification services without modifying the NotificationManager class. For instance, if EmailService class discard it's sendEmail method, the NotificationManager class also would have to change it's implemention.

To fix this violation of the Dependency Inversion Principle, we could create an interface NotificationService and make EmailService implement it. We could then modify the NotificationManager class to depend on NotificationService instead of EmailService. This would allow us to easily switch to a different implementation of NotificationService without modifying the NotificationManager class.

Refactoring

public interface NotificationService {
    public void sendNotification(String message);
}

public class EmailService implements NotificationService {
    @Override
    public void sendNotification(String message) {
        // implementation of email notification service
    }
}

public class SMSService implements NotificationService {
    @Override
    public void sendNotification(String message) {
        // implementation of SMS notification service
    }
}

public class NotificationManager {
    private NotificationService notificationService;

    public NotificationManager(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void notify(String message) {
        notificationService.sendNotification(message);
    }
}

In this example, we have an interface NotificationService that defines a method sendNotification() for sending notifications. We have two implementations of this interface, EmailService and SMSService, that provide different ways of sending notifications.

We also have a class NotificationManager that depends on NotificationService, but instead of depending on a specific implementation, it depends on the interface NotificationService. This allows us to easily switch between different implementations of the NotificationService interface without changing the code of the NotificationManager class.

Here's an example of how to use this code:

NotificationService emailService = new EmailService();
NotificationManager notificationManager = new NotificationManager(emailService);
notificationManager.notify("Hello, world!");

NotificationService smsService = new SMSService();
notificationManager = new NotificationManager(smsService);
notificationManager.notify("Hello, world!");

In this example, we first create an instance of EmailService and use it to create a new instance of NotificationManager. We then call the notify() method of NotificationManager to send a notification via email.

We then create an instance of SMSService and use it to create a new instance of NotificationManager. We again call the notify() method of NotificationManager to send a notification via SMS.

By programming to the interface NotificationService and using dependency injection, we have made our code more flexible and easier to maintain. We can easily switch between different implementations of NotificationService without changing the code of NotificationManager.