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.
-
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.
- 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 theString
class but there is no harm in that because we can pretty much guarantee that theString
class is not going to change in a way that would harm our objects that depend on its behavior.
- Defining variables of type
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.
- For example there's a big difference in packaging a smartphone or a laptop (different details). But the flow is the same.
-
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 implementGeneralManufacturingProcess
. (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
andLaptopManufacturingProcess
depends on the higher-level modulesGeneralManufacturingProcess
, not the other way around (Exception). Also, We can easily switch between different implementations ofGeneralManufacturingProcess
without changing the code ofDeviceFactory
. This is theDependency 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 {
...
}
}
- 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.
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.
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
.