The more I work with Symfony and projects which use it (like Drupal 8), the more convinced I am that dependency injection, specifically using services.yml
files and passive injection, is the beating heart of a testable, maintainable codebase.
What do I mean by that? Well, not only should we never hardcode a new X()
in the PHP we ourselves write, but we should also, as far as possible, write classes which accept services from the container, and not those which keep track of their container and request arbitrary services from it. There's an inherent two-step process involved in refactoring to achieve this: each step can revolutionize the way we write and test code; but together they make our code—comparatively—joyously decoupled (by which I mean, the first time I figured it out, I actually laughed.)
Here's a standard example: first of how services work, then of how passive DI through YAML configuration works. Imagine we write a class Foo
which relies on an instance of class Quux
to do something:
class Foo { public function bar() { $quux = new Quux(); return $quux->blort(); } }
This is the simple, super-coupled way of writing this code. It works, but if (new Quux())->blort()
has side effects, then it will be impossible to test (new Foo())->bar()
in isolation from them.
First decoupling: registering Quux
as a service
If we register Quux
as a service:
# Example Symfony services.yml services: app.quux: class: AppBundle\Quux
Then—if we weren't aware of how bad global
s are—we might write the following in our Symfony application:
class Foo { public function bar() { // For illustration purposes only: don't actually do this. global $kernel; $quux = $kernel->getContainer()->get('app.quux'); return $quux->blort(); } }
We've solved the problem we started with: Quux
has entirely disappeared from this code, and we no longer have the new
keyword in a class that doesn't need to use it. But those benefits are at the expense of introducing a dependency on an global kernel and its container. (In a sense, this is also the paradigm that ContainerAware*
traits and interfaces follow: they get direct access to the container, so they can retrieve services arbitrarily by name.)
Now, when it comes to testing Foo
, we now have to also (as a minimum) implement test doubles or mocks for either the global $kernel
and/or its DI container
; and then mock up the services on the container so that it all fits together for the test.
Second decoupling: registering Foo
as a service
What happens to our code if we register both Quux
and Foo
as services?
# Example Symfony services.yml services: app.foo: class: AppBundle\Foo calls: - [setQuux, ['@app.quux']] app.quux: class: AppBundle\Quux
There's an extra configuration item for the Foo
service, compared to Quux
. This defines calls made on the new Foo()
object, after instantiation, but before it's returned to whatever's asking for it. This setter pattern is a standard way of tying together two classes in passive dependency injection.
For it all to work properly, we now have to write that new method on Foo
:
class Foo { protected $quux; public function setQuux(Quux $quux) { $this->quux = $quux; } public function bar() { return $this->quux->blort(); } }
We've now not only decoupled from an explicit dependency on creating new Quux()
s; we've also decoupled from the kernel and the services container. We can test this class in isolation by at most having to pass a mock Quux
into it, with a single mock method blort()
.
Even neater, if we define a QuuxLike
interface, and instead define our function footprint as setQuux(QuuxLike $quux)
, then we don't even have to pass a mock Quux
in at all: we can pass any object that satisfies the interface, including dummies without side effects (but test those dummies too!) Furthermore, if several different classes need Quux
, we can write a QuuxAware
trait with setQuux()
in it.
Turtles all the way down; services all the way up?
Wait a minute, though: haven't we just pushed the overall problem one layer up? What about the object that wants to use Foo
? Must that not be a service now too? And if so, does that not mean that everything eventually becomes a service?
The answer to this, really, is: yes and no. At the very top of the execution path in our custom AppBundle
in a Symfony application are usually the controller classes. These can either extend Controller
and become ContainerAware
; or they can not, and (optionally) be registered as services.
Even if we don't register controllers as services, one really neat aspect of Symfony is that it autodetects the type enforcement on controller method arguments, and so will itself offer new objects, loaded from the object's repository (which can itself be a service with Quux
injected passively into it.) So at the top of the tree, Symfony is handling the initial injection that starts everything off.
I haven't examined the Drupal case in enough depth yet, but even if it doesn't quite have all the architecture of a perfect DI container, the point is that we don't need to have 100% of our codebase refactored to be testable in isolation: 90–95% is good enough, if our unit tests sit alongside system and integration tests. Methodologies work best when pragmatic, not dogmatic, and dependency injection is no different. But if done consistently, and if bypassed then only ever when we and our team agree to do so, it can revolutionize our code.