-
Notifications
You must be signed in to change notification settings - Fork 0
02. 10. Dependency Inversion Principle (DIP)
"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." -- Robert C. Martin
The Dependency Inversion Principle (DIP) is the last principle in the SOLID acronym, which is a collection of five design principles aimed at making software designs more understandable, flexible, and maintainable. Here is a literal definition of the Dependency Inversion Principle:
This principle is primarily concerned with reducing dependencies among the code modules, which leads to more decoupled and easily maintainable systems.
Let's break it down a bit further:
-
High-level modules should not depend on low-level modules. Both should depend on abstractions: This is suggesting that the high-level modules ( modules that implement business logic or use cases) should not directly depend on or interact with the low-level modules (modules that perform basic, low-level functions like writing to a database or handling HTTP requests). Both should interact through abstractions (like interfaces or abstract classes).
-
Abstractions should not depend on details. Details should depend on abstractions: This means the abstraction does not know about the underlying implementation. It's the responsibility of the underlying detail (i.e., the classes implementing the interface) to adhere to the contract defined by the abstraction.
Consider the following example in TypeScript:
Without Dependency Inversion:
class MySQLDatabase {
save(data: string): void {
// logic to save data to a MySQL database
}
}
class HighLevelModule {
private database: MySQLDatabase;
constructor() {
this.database = new MySQLDatabase();
}
execute(data: string): void {
// high-level logic
this.database.save(data);
}
}
In the above example, HighLevelModule is a high-level module, and it's directly dependent on the low-level module MySQLDatabase. This means if you decided to change your database from MySQL to MongoDB, you would have to modify HighLevelModule, which is not good.
With Dependency Inversion:
interface IDatabase {
save(data: string): void;
}
class MySQLDatabase implements IDatabase {
save(data: string): void {
// logic to save data to a MySQL database
}
}
class MongoDBDatabase implements IDatabase {
save(data: string): void {
// logic to save data to a MongoDB database
}
}
class HighLevelModule {
private database: IDatabase;
constructor(database: IDatabase) {
this.database = database;
}
execute(data: string): void {
// high-level logic
this.database.save(data);
}
}
In this example, HighLevelModule is now depending on the abstraction IDatabase. Whether the underlying database is MySQL or MongoDB, it doesn't care. It just knows that it can call the save method on the database object. This design allows us to change the database without having to modify the HighLevelModule. This is the Dependency Inversion Principle in action.
Here is how you might instantiate HighLevelModule with different types of databases.
// Instantiate the HighLevelModule with a MySQL database.
let mySQLDatabase: IDatabase = new MySQLDatabase();
let highLevelModule1: HighLevelModule = new HighLevelModule(mySQLDatabase);
// Now use the module to execute some high level function.
highLevelModule1.execute("Some Data for MySQL");
// Instantiate the HighLevelModule with a MongoDB database.
let mongoDBDatabase: IDatabase = new MongoDBDatabase();
let highLevelModule2: HighLevelModule = new HighLevelModule(mongoDBDatabase);
// Now use the module to execute some high level function.
highLevelModule2.execute("Some Data for MongoDB");
In the above example, you can see how we can switch out the database dependency without changing the HighLevelModule code. First, we use a MySQLDatabase, then later a MongoDBDatabase. This is an excellent illustration of the power and flexibility provided by the Dependency Inversion Principle.

The Dependency Inversion Principle (DIP) is crucial to building scalable, maintainable, and robust software systems. Here are some of the concrete advantages of using DIP in your programming:
By depending on abstractions rather than on concrete implementations, modules in the system are less interlinked. This reduces the risk that changes in one module will affect others.
interface IDatabase {
save(data: string): void;
}
class MySQLDatabase implements IDatabase {
save(data: string): void {
// logic to save data to a MySQL database
}
}
class MongoDBDatabase implements IDatabase {
save(data: string): void {
// logic to save data to a MongoDB database
}
}
class HighLevelModule {
private database: IDatabase;
constructor(database: IDatabase) {
this.database = database;
}
execute(data: string): void {
// high-level logic
this.database.save(data);
}
}
In the given example, the HighLevelModule class is decoupled from the specific database implementations (MySQLDatabase, MongoDBDatabase). It depends only on the IDatabase abstraction, which means changes to specific database classes don't affect the HighLevelModule.
Because modules depend on abstractions, you can easily introduce new functionality or change existing functionality by creating new implementations of those abstractions.
If you need to add a new database type, you can easily create a new class implementing IDatabase and pass it to HighLevelModule. The HighLevelModule class doesn't need to change, demonstrating how DIP facilitates easy modification and extension.
Testing becomes easier because you can provide mock implementations of your abstractions during the testing phase. This means you can test components in isolation, without needing to set up and coordinate complex real versions of all their dependencies.
DIP makes it easier to test HighLevelModule. You can create a mock implementation of IDatabase and use it for testing HighLevelModule, ensuring that your tests are not dependent on a live database.
Here's how you might create a mock implementation of IDatabase for testing purposes in TypeScript:
class MockDatabase implements IDatabase {
save(data: string): void {
console.log("Mocked save method called with data: " + data);
}
}
You would use this MockDatabase class in your tests to make sure the HighLevelModule is functioning correctly without needing a real database. For example:
let mockDatabase: IDatabase = new MockDatabase();
let highLevelModule: HighLevelModule = new HighLevelModule(mockDatabase);
// Now when you call the execute method, instead of actually writing to a database, it will simply log the operation.
highLevelModule.execute("Test Data");
In a real-world testing situation, you would likely use a testing framework like Jest, and instead of simply logging the data in the mock, you would use the framework's features to assert that the save method was called correctly. This was a simplified demonstration of the concept.
Since higher-level and lower-level modules are not directly dependent on each other, it's easier to reuse these modules in different parts of the application or in different applications.
The HighLevelModule class can work with any class that implements IDatabase, making it more reusable. You can use it with different database types across different parts of your application or even different applications.
By structuring the codebase in such a way that lower-level details depend on high-level strategies, it becomes easier to scale up the system in the future. For example, you might start with a simple data store like a file system, and then later swap it out for a full database without changing the high-level code.
As your system grows and you decide to introduce a new database system, DIP makes it easy. You would only need to add a new implementation of IDatabase and wouldn't need to touch the HighLevelModule.
To add a new database type to the system, we'd simply create a new class that implements the IDatabase interface. Here's an example of how you might introduce PostgreSQL to your system:
class PostgreSQLDatabase implements IDatabase {
save(data: string): void {
// logic to save data to a PostgreSQL database
console.log("Data saved to PostgreSQL database: " + data);
}
}
Now, you can create a HighLevelModule that uses this new PostgreSQLDatabase:
let postgreSQLDatabase: IDatabase = new PostgreSQLDatabase();
let highLevelModule: HighLevelModule = new HighLevelModule(postgreSQLDatabase);
// Now use the module to execute some high-level function.
highLevelModule.execute("Some Data for PostgreSQL");
In this way, you've introduced a new database type to your system without having to modify existing HighLevelModule code, demonstrating the scalability offered by the Dependency Inversion Principle.
As a result of reduced dependencies, code that follows the Dependency Inversion Principle is typically easier to understand and maintain. Each component can be understood in isolation, and the interactions between them are straightforward and based on clearly-defined interfaces.
Each module (HighLevelModule, MySQLDatabase, MongoDBDatabase) can be understood, developed, and maintained independently. This clear separation of concerns makes the code easier to understand and maintain.
Remember, the SOLID principles (including DIP) are guidelines, not hard rules. It's essential to understand them and apply them appropriately, but also to recognize that there might be valid reasons in some circumstances to choose a different approach.