SOLID — Dependency Inversion Principle (Part 5)
Today is time for the fifth and last part of my series about the SOLID principles. This time I want to talk about the Dependency Inversion Principle, the letter D of the acronym.
It is a principle whose name is often misused interchangeable with Dependency Injection even it is not the same. Dependency Injection is an Inversion of Control technique for supplying objects (“dependencies”) to a class by a way of the Dependency Injection Design Pattern. Typically passing dependencies via constructor or field. In contrast the Dependency Inversion Principle is a general design guideline which recommends that classes should only have direct relationships with high-level abstractions. So to make things more clear let’s start having a look at the principle.
What is the Dependency Inversion Principle?
The Dependency Inversion Principle (DIP) emphasizes decoupling and abstraction. The principle consists of two core concepts: high-level modules should not depend on low-level modules, and both should depend on abstractions. This inverted dependency relationship promotes flexibility, testability, and maintainability.
Implementing the Dependency Inversion Principle in projects can yield several benefits:
- Loose Coupling: By introducing abstractions, high-level modules are no longer directly dependent on low-level modules. This loose coupling allows for independent development, modification, and replacement of individual components.
- Testability: Abstractions make it easier to write unit tests by enabling the use of mock objects or test doubles. With dependencies abstracted away, I can isolate and test individual modules more effectively.
- Maintainability: The DIP reduces the impact of changes in low-level modules on high-level modules. This modular structure makes it simpler to update or replace components without affecting the entire system, leading to improved maintainability.
- Scalability: The use of abstractions allows for the addition of new implementations without modifying existing code. This scalability makes it easier to extend the system’s functionality while preserving the existing codebase.
In the next part I will have a look at a practical example that shows a violation of the Dependency Inversion Principle with it negative consequences and also a transformation of to version that is following the principle.
Example
Let’s first start with a negative example to understand the pitfalls of not following DIP. Suppose I have a UserService class that is responsible for user authentication. It depends on a concrete ConcreteUserRepository class to fetch user details from the database. The UserService itself is responsible for the instantiation of the dependency.
class ConcreteUserRepository{
fun findUserBy(username: String): User? {
...
}
...
}
class UserService {
private val userRepository: ConcreteUserRepository = ConcreteUserRepository()
fun authenticateUser(username: String, password: String): Boolean {
// Logic to authenticate user using the DatabaseService
val existingUser = userRepository.findUserBy(username)
if(existingUser != null) {
// authenticate
...
}
}
...
}
In this example, the UserService has a direct dependency on the ConcreteUserRepository class, violating the Dependency Inversion Principle. This tight coupling makes it challenging to swap or extend the database implementation without modifying the UserService class. Every change in the dependency also makes an update in the UserService necessary.
Depending on a concrete implementation, that is instantiated inside the UserService itself, also brings problems when it comes to testing the functionality. Because the repository is directly instantiated it is only possible to test the UserService with the real implementation. So I’m forced to write integration tests, for which I need to provide a database. This makes the tests very slow. Also I first need to add test data to the database so the test can run successfully.
class UserIntegrationTest{
val userService = UserService()
@Test
fun `authenticateUser returns true for existing username`(){
// given
val username = "existingUser"
// add user to database
...
// when
val actual = userService.authenticateUser(username)
// then
assertThat(actual).isTrue()
}
}
As you can see this is an an optimal way of working with dependencies.
A better approach would be to introduce an abstraction or interface, UserRepository, to encapsulate the database operations. The UserService class would then depend on this interface instead of the concrete ConcreteUserRepository class.
interface UserRepository {
fun findUserBy(username: String): User?
...
}
class ConcreteUserRepository: UserRepository {
override fun findUserBy(username: String): User? {
...
}
...
}
class UserService(private val userRepository: UserRepository) {
fun authenticateUser(username: String, password: String): Boolean {
// Logic to authenticate user using the DatabaseService
val existingUser = userRepository.findUserBy(username)
if(existingUser != null) {
// authenticate
...
}
}
...
}
By introducing the UserRepository interface, I adhere to the Dependency Inversion Principle. Now, the UserService class depends on an abstraction, allowing for easy substitution or extension of the database implementation without modifying the UserService class itself. The UserService does not need to know any details from the concrete repository implementation, just that it fulfils the contract of the abstraction and returns a user if available.
With this change the testing for the UserService is a lot easier. Because the UserRepository abstraction is injected to the UserService by constructor I can replace it in the test by a fake implementation. So with this it is no longer necessary to use a real database for it. This makes the tests a lot faster and also more predictable, because the risk of failure of external components is removed (e.g. database not available, constraint violations, …).
class FakeUserRepository: UserRepository{
override fun findUserBy(username: String): User? {
return User(username)
}
}
class UserTest{
val fakeUserRepository = FakeUserRepository()
val userService = UserService(fakeUserRepository)
@Test
fun `authenticateUser returns true for existing username`(){
// given
val username = "existingUser"
// when
val actual = userService.authenticateUser(username)
// then
assertThat(actual).isTrue()
}
}
Conclusion
The Dependency Inversion Principle is a powerful concept that promotes loose coupling and abstraction in software development. By applying DIP in projects, I can achieve flexible, testable, and maintainable code. Through the practical example, I’ve seen how to invert dependencies by introducing interfaces and relying on abstractions. Remember, violating the Dependency Inversion Principle can lead to tightly coupled code, making it difficult to change or extend the system.
That’s it.
I’ve finished the evaluation of the last of the SOLID principles. Applying to the principles leads to software that is easier to understand, modify, test, and maintain. It improves code quality, reduces technical debt, and enhances the long-term success and sustainability of software projects. Even though the principles are meanwhile over 20 years old they haven’t lost their relevance in developers daily work, independent of the used programming paradigm - object oriented or functional programming.