My friends and family are under attack in Ukraine.
Donate to protect them directly or help international organizations.

Dependency Injection

This technique makes code more testable and maintainable. It also helps developers to work in parallel without stepping on each other's toes. Whether I refactor legacy code or start a new project, this is generally a low-hanging fruit that brings the most benefits.

What is a Dependency?

Say we have a product repository that loads products from the database:

class ProductRepository
{
    public function getAll(): array
    {
        $this->connection->find(/* search criteria */);
    }
}

The product repository requires a database connection to operate. The connection object is therefore a dependency of the ProductRepository class. There are multiple ways to get that dependency into our class. I'll show you a commonly used technique called constructor injection.

Constructor Injection

This approach clearly exposes dependencies, making the code easier to maintain. It also ensures that all dependencies are available at instantiation, causing fewer runtime errors.

class ProductRepository
{
    public function __construct(private Connection $connection) {}
}

Whoever instantiates ProductRepository is responsible for providing an instance of Connection. If we use a Dependency Injection Container (DIC), this is very easy to manage. I'll use PHP-DI for the following examples.

Whenever we need an instance of ProductRepository in our code, we request it from the DIC:

$container->get(ProductRepository::class);

The DIC will attempt to automatically instantiate ProductRepository by looking at its constructor parameters, and will find a Connection in the list. It will then continue by looking at the Connection constructor and so on, until there are no more constructor parameters. It will instantiate everything and return a ProductRepository, ready with all its dependencies.

Configuring the DIC

In some cases, the DIC will not have enough information to instantiate the class, such as when it encounters scalar parameters instead of classes:

class Connection
{
    public function __construct(private string $dbName) {}
}

The DIC does not know which string value to provide, so we'll need to configure it. Here is an example of a configuration file used by PHP-DI:

[
    Connection::class => function (Container $c) {
        return new Connection('my_database');
    },
]

Since Connection::class is defined in the array keys, the associated method will be called to instantiate this class, instead of trying to do it automatically.

Improved Testability

Using the constructor injection makes testing a breeze:

protected function setUp(): void
{
    $this->mockConnection = $this->createMock(Connection::class);
    $this->repository = new ProductRepository($this->mockConnection);
}

We can now stub the connection's methods and test the repository.

Interfaces

To make code even more maintainable, classes can depend on interfaces instead of concrete classes. Let's change the constructor of ProductRepository:

class ProductRepository
{
    public function __construct(private ConnectionInterface $connection) {}
}

Here is the new ConnectionInterface:

interface ConnectionInterface
{
    public function find(Criteria $criteria): ResultSet;
}

We can then implement that interface:

class PdoConnection implements ConnectionInterface
{
    public function __construct(private PDO $pdo) {}

    public function find(Criteria $criteria): ResultSet
    {
        //...
    }
}

We need to adjust the DIC configuration:

[
    ConnectionInterface::class => function (Container $c) {
        return new PdoConnection(
            new PDO('mysql:host=localhost;port=3306;dbname=my_database')
        );
    },
]

By depending on an interface, it becomes possible to receive a different implementation without breaking anything in our class. It also makes is easier for developers to work in parallel, because they don't need to know anything about each other's code. All they need to agree on is the interface. Each developer can then work on their own code and tests, not having to worry about their pieces fitting together in the end.

Learn about more topics