What Does DIP Try To Solve?

DIP is essential for maintaining flexibility and ensuring that a system can easily evolve over time. When high-level modules directly depend on low-level modules, it creates a tight coupling between the two.

Lets stop here for a second…

…. high-level and low-level modules, what do I mean?

// Low-level module (Database)
class Database {
    public function connect(): void {
        echo "Connecting to database...";
    }

    public function query(string $sql): void {
        echo "Executing query: $sql";
    }
}

// High-level module (UserService)
class UserService {
    private $database;

    public function __construct() {
        // Directly depending on the low-level Database class
        $this->database = new Database();
    }

    public function createUser(string $name): void {
        $this->database->connect();
        // Create the user here.
    }
}

Whats wrong here?

  • Direct dependency: The UserService directly depends on the Database class. It instantiates a Database object in its constructor, which tightly couples UserService to the Database implementation. This means UserService cannot function without a Database instance.

  • Hard to switch implementations: If we want to switch from a Database class to another storage system (e.g., a file system, an API, or another database), we must modify UserService itself to accommodate these changes.

  • Difficulty in testing: If we want to test UserService, we have to deal with the real Database class, which might involve connecting to an actual database and performing real queries. This makes unit testing more complex, especially when testing in isolation.

Any changes made to a low-level module could have a cascading effect on the high-level module, making it harder to modify, extend, or test the system.

By introducing abstractions (usually in the form of interfaces), DIP enables the following benefits:

  • Decoupling: High-level modules don’t need to know about the details of low-level modules.

  • Flexibility: You can change the implementation of low-level modules without affecting the high-level modules.

  • Testability: It’s easier to mock or substitute low-level modules for testing purposes.

  • Maintainability: Systems become easier to modify and extend over time, as changes to low-level modules don’t require changes to the business logic.

The Definition

Lets look at the definition:

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.

It doesn’t roll off the tongue does it? Basically all you need to worry about is that its better to inject low-level modules (such as a database instance) into a high-level module (a user service) rather than creating a direct dependency to it.

You end up with a tightly coupled design, which causes a few issues:

  • Flexibility: If we decide to replace the Database class with another implementation (e.g., a mock database, or switch from MySQL to PostgreSQL), we have to modify the UserService class directly. This increases maintenance overhead.

  • Testing: Testing becomes harder because UserService is tightly coupled with Database. We can't easily mock or substitute the Database with a fake version for unit tests without modifying the UserService class.

Real-World Example

Remember the example below?

// Low-level module (Database)
class Database {
    public function connect(): void {
        echo "Connecting to database...";
    }

    public function query(string $sql): void {
        echo "Executing query: $sql";
    }
}

// High-level module (UserService)
class UserService {
    private $database;

    public function __construct() {
        // Directly depending on the low-level Database class
        $this->database = new Database();
    }

    public function createUser(string $name): void {
        $this->database->connect();
        // Create the user here.
    }
}

How can we improve this? Well, not much to it really, make the low-level module implement an interface (or extend an abstract), inject it and typehint to the abstraction.

// Contract
interface DatabaseContract {
    public function connect(): void;
    public function query(string $sql): void;
}

// Low-level module (Database)
class Database implements DatabaseContract {
    public function connect(): void {
        echo "Connecting to database...";
    }

    public function query(string $sql): void {
        echo "Executing query: $sql";
    }
}

// High-level module (UserService)
class UserService {

    public function __construct(private DatabaseContract $database) {

    }

    public function createUser(string $name): void {
        $this->database->connect();
        // Create the user here.
    }
}

This allows you to easily switch out the low-level module.

Final Thoughts

Direct dependencies between high-level and low-level modules create tight coupling, making the system harder to maintain, extend, and test.

By using techniques like Dependency Injection and following the Dependency Inversion Principle (DIP), we can decouple high-level modules from low-level implementations, improving the flexibility and testability of our code.

The Grumpy Dev is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.