Movatterモバイル変換


[0]ホーム

URL:


Sitemap
Open in app

Today is time for the fifth and last part of my series about theSOLIDprinciples. This time I want to talk about theDependency Inversion Principle,the letterD 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 theDependency 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?

TheDependency Inversion Principle (DIP)emphasizes decoupling and abstraction. The principle consists of two core concepts:high-level modules should not depend on low-level modules, andboth should depend on abstractions. This inverted dependency relationship promotes flexibility, testability, and maintainability.

Implementing theDependency 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: TheDIPreduces 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 theDependency Inversion Principlewith it negative consequencesand 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 followingDIP. Suppose I have aUserServiceclass that is responsible for user authentication. It depends on a concreteConcreteUserRepositoryclass to fetch user details from the database. TheUserServiceitself 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, theUserServicehas a direct dependency on theConcreteUserRepositoryclass, violating theDependency Inversion Principle. This tight coupling makes it challenging to swap or extend the database implementation without modifying theUserServiceclass. Every change in the dependency also makes an update in theUserServicenecessary.

Depending on a concrete implementation, that is instantiated inside theUserServiceitself, also brings problems when it comes to testing the functionality. Because the repository is directly instantiated it is only possible to test theUserServicewith 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. TheUserServiceclass would then depend on this interface instead of the concreteConcreteUserRepository 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 theUserRepositoryinterface, I adhere to theDependency Inversion Principle. Now, theUserServiceclass depends on an abstraction, allowing for easy substitution or extension of the database implementation without modifying theUserServiceclass itself. TheUserService 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 theUserServiceis a lot easier. Because theUserRepositoryabstraction is injected to theUserServiceby 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

TheDependency Inversion Principle is a powerful concept that promotes loose coupling and abstraction in software development. By applyingDIPin 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 theDependency 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 theSOLID 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.

--

--

Matthias Schenk
Matthias Schenk

Written by Matthias Schenk

Kotlin | Java | Spring Ecosystem | Software Craftsman

Responses (1)


[8]ページ先頭

©2009-2025 Movatter.jp