Dependency Inversion Principle
Introduction
The Dependency Inversion Principle (DIP) is the fifth and final principle in the SOLID acronym. It states that high-level modules should not depend on low-level modules. Both should depend on abstractions. This helps to decouple the high-level modules from the low-level modules, making the code more flexible and easier to maintain.
The name "Dependency Inversion" can be a bit misleading. It does not mean that dependencies are inverted. The high-level modules still depend on the low-level modules, but instead of depending on the concrete implementations of the low-level modules, they depend on abstractions.
A key point when designing abstractions is how they relate to the implementation details. The abstractions should not depend on the details. Instead, the details should depend on the abstractions.
Is My Module High-Level or Low-Level?
The terms "high-level" and "low-level" can be a bit confusing. In the context of the Dependency Inversion Principle, a high-level module is a module that contains the business logic of the application. It is the module that is responsible for providing value to the user. A low-level module provides the infrastructure or support for the high-level module. They might interact with a database, a file system, or a network service. They might also provide utility functions or helper classes.
Advantages
The advantage of this approach is not really felt until you need to make changes to the code. If you need to change the implementation of a low-level module, you can do so without affecting the high-level module. This is because the high-level module depends on an abstraction, not the concrete implementation. This makes the code more flexible and easier to maintain.
Example
Consider this high-level class that interacts directly with the a SqlLite
database class:
class AccountingReport(private val database: SqlLiteDatabase) {
// Lots of business logic here...
private fun getData(key: String): String {
return database.load(key)
}
private fun saveData(data: String) {
database.save(data)
}
}
class SqlLiteDatabase {
fun load(key: String): String { }
fun save(data: String) { }
fun update(data: String) { }
fun delete(key: String) { }
}
This class works just fine, but violated the dependency inversion principle. If we wanted to switch the type of database we were using, we would have to change the AccountingReport
class, possibly in many places. Instead, we can create an interface that the SqlLiteDatabase
class implements:
interface Database {
fun load(key: String): String
fun save(data: String)
fun update(data: String)
fun delete(key: String)
}
The AccountingReport
class can now depend on the Database
interface instead of the SqlLiteDatabase
class:
class AccountingReport(private val database: Database) {
// Lots of business logic here...
private fun getData(key: String): String {
return database.load(key)
}
private fun saveData(data: String) {
database.save(data)
}
}
The SqlLiteDatabase
class can now implement the Database
interface:
class SqlLiteDatabase : Database {
override fun load(key: String): String { }
override fun save(data: String) { }
override fun update(data: String) { }
override fun delete(key: String) { }
}
If we want to switch to using a different type of database, MongoDB for example, we can create a new class that implements the Database
interface:
class MongoDBDatabase : Database {
override fun load(key: String): String { }
override fun save(data: String) { }
override fun update(data: String) { }
override fun delete(key: String) { }
}
And simply pass an instance of it to the AccountingReport
class through dependency injection without changing any of the code in that class.
Conclusion
The Dependency Inversion Principle is critical for reducing coupling between the business layer and lower level classes. By depending on abstractions, high-level modules can be decoupled from low-level modules, making the code more flexible and easier to maintain.
If your application is expected to live for more than a few months, putting in a little extra effort to follow the Dependency Inversion Principle will pay off in the long run. The upfront work can be a bit more, but the flexibility and maintainability it provides will be worth it.
In cases where you find yourself working with a legacy codebase that does not follow the Dependency Inversion Principle, when you need to make changes to a low level module it can be a bit of a headache. However, you can start by creating abstractions for the low-level modules and then refactor the high-level modules to depend on those abstractions. This will help to decouple the high-level modules from the low-level modules allowing you to make changes more easily.