Understanding Dependency Injection by example with the Symfony DI component (part 1/2)

The release of Symfony 2.0 in 2011 is probably the single most important step towards the adoption of the dependency injection design pattern in the world of PHP. By providing a real use-case for this pattern in a recognized and popular PHP framework, it was the reason for many developers to start adopting dependency injection in their projects.

In 2018, the usage of dependency injection in PHP is widespread: almost all the modern PHP frameworks provides a DI container and the PHP Framework Interoperability group even created a standard on the subject.

In this article and in its follow-up, I would like to discuss the reasons why Dependency Injection matters and guide you inside the Symfony 4 Dependency Injection component internals, in order to better understand how to use its features.

What is Dependency Injection?

Before talking about how to implement DI, let’s talk about the pattern itself to ensure we agree on why it is useful.

The aim of dependency injection is, as its name suggest, to inject dependencies of classes in an “automated” way, ie. without needing the user of the class to manage these dependencies.

Let’s imagine we are building a user registration system: we are going to need a database persister, a mailer to send the confirmation e-mail and a bit of logic to call these components. With a bit of simple OOP, it could look like this:

class Persister {
// A class able to persist a user
}
class Mailer {
// A class able to send an e-mail to the user
}
class RegistrationManager {
private $persister;
private $mailer;
    public function __construct(Persister $p, Mailer $m) {
$this->persister = $p;
$this->mailer = $m;
}
    public function register(User $user) {
// Register the user using our injected classes:
// $this->persister->persist($user);
// $this->mailer->sendConfirmation($user);
}
}

This principle of delegating certain parts of a task (here registration) to injected objects is called encapsulation. By using encapsulation, a developer is able to split the logic in multiple decoupled components interacting with each other.

One key advantage of encapsulation is the ability to easily test each component independently, by injecting in it different objects instead of real ones (we could for instance inject a different Persister that does not actually persist anything, in order to test the RegistrationManager without needing a database). This idea of injecting fake objects into the one we want to test is what’s called mocking.

Note: being able to easily switch between real and fake objects using encapsulation is exactly why interfaces are so useful: you only need to provide an object implementing an interface instead of one extending another class.

However, while using encapsulation is an amazing improvement over procedural code in terms of decoupling, it still has a drawback: the user of a class needs to inject dependencies into it manually. In this specific case, a user wanting to use the RegistrationManager will first need to create a Persister and a Mailer:

$manager = new RegistrationManager(
new Persister($doctrine),
new Mailer($twig, $swiftmailer)
);

This creates two new main issues:

  • it can become cumbersome to have to write so much new, as a user of a class (this example is small but it may quickly become much bigger for many classes);
  • even more importantly, there is an issue of responsibility: by giving the responsibility of injecting the right dependencies of the class to its user, the developer maintaining the class cannot add or remove a dependency to this class. In our example, if a new dependency was needed by the RegistrationManager (ie. if a new argument was added to its constructor), each usage of this class in the application code would need to be updated to inject this new dependency;

This can be summarized as follows: by using only encapsulation, packages cannot inject their own dependencies themselves and need to rely on their user to do it for them. This introduces a coupling between the application and its vendor packages and creates huge backward compatibility issues.

This is where dependency injection is useful. The aim of dependency injection is to let the package developer explain how a class needs to be built (using configuration).

By doing so, a generic system can be used to read this explanation and automatically generate the code needed to instantiate the classes. In our context, it would mean this system would be able to automatically generate an equivalent of the following code:

$manager = new RegistrationManager(
new Persister($doctrine),
new Mailer($twig, $swiftmailer)
);

This generic system would also provide a way for the user to get an instance of the class without having to deal with its dependencies.

What is a Service Container?

Dependency Injection is a pattern that greatly improves maintainability by decoupling parts of an application that were highly coupled. However, Dependency Injection is only a pattern: we need an implementation to use it.

This is what service containers are: implementations of the dependency injection pattern. They are especially useful as they are extremely easy to extends and adapt to many situations.

Note: when you use a service container, all the classes you are able to create using it are called services, but they are still simple classes (it’s just a different name).

By using an application-wide service container, package developers are able to tell this container how to instantiate the classes they wrote, and this container is able to create instances of these classes when the user needs them.

In Symfony, the DependencyInjection component is the one providing this container, reading configuration from packages and building service instances for you.

How to build a simple Service Container?

Now let’s discuss a bit on how we could build a service container, in order to better understand how it works.

As a service container developer, we would need to provide two key features:

  • a way for developers to register the way to create instance of their classes;
  • a way for users of our container to retrieve created instances of classes;

These features are the requirements of any service container, and if you think about it, they are not that difficult to create. By using a small class for the container, anonymous functions and a bit of logic, we can create a tiny container that works well enough:

$container = new Container();
// Define how to create services instances using anonymous functions
$container['session_storage'] = function ($c) {
return new SessionStorage('SESSION_ID');
};

$container['session'] = function ($c) {
return new Session($c['session_storage']);
};
// Use the container to retrieve the session: note how we didn't
// need to know the dependency of Session on SessionStorage

$user = $container['session']->get('user');

This extremely simple container actually exists in the Symfony ecosystem: it is called Pimple and it was used by the now-deprecated Silex micro-framework.

Note: there is even a container fitting in 140 characters, Twitee :) !


Upcoming

In the coming weeks, I will publish a follow-up to this article to dig a bit deeper in Dependency Injection and how to implement advanced features using the internals of the Symfony Dependency Injection component as an example!

Do you have feedbacks / ideas? Don’t hesitate to comment!