Dependency Inversion Principle
Motivation
When we design software applications we can consider the low level classes the classes
which implement basic and primary operations(disk access, network protocols,...) and
high level classes the classes which encapsulate complex logic(business flows, ...). The
last ones rely on the low level classes. A natural way of implementing such structures
would be to write low level classes and once we have them to write the complex high
level classes. Since high level classes are defined in terms of others this seems the
logical way to do it. But this is not a flexible design. What happens if we need to
replace a low level class?
Let's take the classical example of a copy module which reads characters from the
keyboard and writes them to the printer device. The high level class containing the
logic is the Copy class. The low level classes are KeyboardReader and PrinterWriter.
In a bad design the high level class uses directly and depends heavily on the low level
classes. In such a case if we want to change the design to direct the output to a new
FileWriter class we have to make changes in the Copy class. (Let's assume that it is
a very complex class, with a lot of logic and really hard to test).
In order to avoid such problems we can introduce an abstraction layer between high level
classes and low level classes. Since the high level modules contain the complex logic
they should not depend on the low level modules so the new abstraction layer should not
be created based on low level modules. Low level modules are to be created based on the
abstraction layer.
According to this principle the way of designing a class structure is to start from high
level modules to the low level modules: High Level Classes --> Abstraction
Layer --> Low Level Classes
Intent
- High-level modules should not depend on low-level modules. Both should depend on
abstractions.
- Abstractions should not depend on details. Details should depend on abstractions.
Dependency Injection Example
Below is an example which violates the Dependency Inversion Principle. We have the
manager class which is a high level class, and the low level class called Worker. We
need to add a new module to our application to model the changes in the company
structure determined by the employment of new specialized workers. We created a new
class SuperWorker for this. Let's assume the Manager class is quite complex,
containing very complex logic. And now we have to change it in order to introduce the new
SuperWorker. Let's see the disadvantages:
- we have to change the Manager class (remember it is a complex one and this will
involve time and effort to make the changes).
- some of the current functionality from the manager class might be affected.
- the unit testing should be redone.
All those problems could take a lot of time to be solved and they might induce new errors
in the old functionlity. The situation would be different if the application had been
designed following the Dependency Inversion Principle. It means we design the manager
class, an IWorker interface and the Worker class implementing the IWorker interface.
When we need to add the SuperWorker class all we have to do is implement the IWorker
interface for it. No additional changes in the existing classes.
// Dependency Inversion Principle - Bad example
class Worker {
public void work() {
// ....working
}
}
class Manager {
Worker worker;
public void setWorker(Worker w) { worker = w; }
public void manage() { worker.work(); } }
class SuperWorker { public void work() { //.... working much more } }
|
Below is the code which supports the Dependency Inversion Principle. In this new design a
new abstraction layer is added through the IWorker Interface. Now the problems from the
above code are solved(considering there is no change in the high level logic):
- Manager class doesn't require changes when adding SuperWorkers.
- Minimized risk to affect old functionality present in Manager class since we don't
change it.
- No need to redo the unit testing for Manager class.
// Dependency Inversion Principle - Good example interface IWorker { public void work(); }
class Worker implements IWorker{ public void work() { // ....working } }
class SuperWorker implements IWorker{ public void work() { //.... working much more } }
class Manager { IWorker worker;
public void setWorker(IWorker w) { worker = w; }
public void manage() { worker.work(); } }
|
Types of Dependency Injection
The main intent of the depedency injection can be achieved in a few ways:
Constructor Injection
We can inject the depedency to the main component direcly in the constructor when the component is instantiated. In this case the
dependency should be already instantiated when the constructor of the main component is invoked.
public class UserService {
private final Database database;
public UserService(Database database) {
this.database = database;
}
public User getUser(int id) {
return database.query("SELECT * FROM users WHERE id = " + id);
}
}
|
Method Injection
Method injection is similar to Constructor Injection, but we inject the dependencies in methods. This concept
is less likely used in practice, but we might have to use it in certain situations.
public class UserController {
public User getUser(int id, UserService userService) {
return userService.getUser(id);
}
public static void main(String[] args) {
UserService userService = new UserService();
UserController userController = new UserController();
User user = userController.getUser(123, userService);
// Do something with the user...
}
}
|
Propery Injection
This type of dependency injection consists in passing the dependency through a property. It allows a lazy loading initialization
of the dependency, in case the dependency is not instantiated when main component is instantiated.
Further more, it makes the code less coupled, since the constructor of the main component does not have to know anything about the
dependency. This is useful when the component is instantiated using a factory pattern mechanism. In this case we want to avoid
having the factory being dependent on the dependency we want to inject.
public class OrderService {
// Dependencies assigned directly
private ProductService productService;
public OrderService() {
// ...
}
public void setProductService(ProductService productService) {
this.productService = productService;
}
// ...
}
|
Applicability of Dependency Inversion Principle
Incorporating the Dependency Inversion Principle into your software design can yield
numerous benefits in terms of flexibility, maintainability, and testability. By creating
an abstraction layer between high-level and low-level modules, you establish a clear
separation of concerns and reduce the tight coupling between different parts of your
application. This separation allows for easier modifications and extensions without
affecting existing code.
Testability and Mocking
One of the significant advantages of adhering to the Dependency Inversion Principle is the
improved testability of your code. With high-level modules depending on abstractions, it
becomes much simpler to substitute real implementations with mock or stub objects during
testing. This facilitates isolated unit testing, as you can easily create mock objects that
simulate the behavior of the low-level modules, enabling thorough testing of your high-level
logic without relying on external systems or resources.
Inversion of Control (IoC) Containers
The Dependency Inversion Principle aligns well with the concept of Inversion of Control
(IoC). IoC containers are tools that manage the instantiation and wiring of objects in your
application. By using an IoC container, you can centralize the configuration of your
dependencies, making it easier to swap out implementations and manage the overall structure
of your application. This approach complements the Dependency Inversion Principle by
providing a systematic way to achieve loose coupling and manage the flow of control.
Maintainability and Extensibility
As your software evolves, new requirements and features may necessitate changes to your
codebase. By adhering to the Dependency Inversion Principle, you create a foundation for
more maintainable and extensible code. When you introduce new functionality, you can focus
on creating new high-level classes and abstractions without needing to modify existing
components. This reduces the risk of unintended side effects and promotes a modular and
scalable architecture.
Real-World Examples
The Dependency Inversion Principle is prevalent in modern software development, and its
benefits can be observed in various design patterns and frameworks. For instance, many
popular frameworks, such as Spring for Java and Angular for TypeScript/JavaScript, heavily
emphasize the use of dependency injection and abstractions. These frameworks provide tools
and conventions that facilitate the application of the Dependency Inversion Principle,
resulting in more manageable and adaptable codebases.
SOLID Principles
Remember that the Dependency Inversion Principle is just one of the SOLID principles, a set of five design principles that
guide the development of well-structured and maintainable software. When used in conjunction
with the other SOLID principles (Single Responsibility Principle, Open/Closed Principle,
Liskov Substitution Principle, and Interface Segregation Principle), the Dependency
Inversion Principle can contribute to the creation of software that is both robust and
resilient to change.
Conclusion
When this principle is applied it means the high level classes are not working directly
with low level classes, they are using interfaces as an abstract layer. In this case
instantiation of new low level objects inside the high level classes(if necessary) can
not be done using the operator new. Instead, some of the Creational design patterns can
be used, such as Factory Method, Abstract Factory, Prototype.
The Template Design Pattern is an example where the DIP principle is applied.
Of course, using this principle implies an increased effort, will result in more classes
and interfaces to maintain, in a few words in more complex code, but more flexible. This
principle should not be applied blindly for every class or every module. If we have a
class functionality that is more likely to remain unchanged in the future there is not
need to apply this principle.
|