Testable dependency injection in a DI-lite framework like Silex

As I mentioned previously (in the context of Symfony and Drupal 8) service-based dependency injection is a gold standard of decoupling all your bits of code from each other.

It makes testing easier immediately and reduces coupling (which has its own benefits); in the long run, it avoids the temptation within your own code to freely ask the service container for arbitrary services, which can lead to spaghetti code and lots of mocking during testing. It also moves your code away from framework-dependence, because as long as they get services which satisfy particular interfaces, the business logic will work the same regardless.

Micro-frameworks give you freedom to shoot yourself in either or even both feet

But what if you're using a much lighter framework like Silex, where you don't have a services.yml at your disposal to enforce good DI decoupling? Silex lets you build services arbitrarily in PHP, which is very flexible and allows you to build them in a decoupled way; a good example is given:

$app = new Silex\Application();
// Decoupled pattern: give Service just the other services it needs and no more.
$app['some_service'] = function ($app) {
    return new Service($app['some_other_service'], $app['some_service.config']);
};

Here the application (which is also the DI container) returns a Service object prepared by injecting two other services into its constructor. All code can be tested in isolation.

However, you need to avoid the temptation to pass the DI container in and let Service()__construct()'s internal logic retrieve the services it wants:

// Coupled pattern: give Service the entire DI container.
$app['coupled_service'] = function ($app) {
    return new Service($app);
};

This is taking the first few steps towards permanently storing the container inside Service and retrieving arbitrary services at method execution time. Even as a minimum, this pattern will start to complicate testing Service without mocking up an entire Application. So use the decoupled pattern mentioned first above, if you can.

Controllers like services? Controllers as services!

Most of your services, then, can be decoupled, tested separately, but then combined during the setting-up of the Silex application. But what about routing?

Silex permits very loose routing handler definition e.g. an anonymous function can be registered with a route, which is almost textbook untestable code! You could only ever test that code with a full system test, as you need the application to configure itself, as if it were a production site, to invoke that code.

Anonymous functions can also give type hints, which means that a function-based controller can quite easily ask for the DI container, which is again a strong-coupling design pattern. Here's an example taken from the Silex documentation:

// Coupled pattern: every service can be summoned in code
// that cannot be tested in isolation
$app->get('/blog/{id}', function (Application $app, Request $request, $id) {
    // ...
});

However, along with these short-cuts Silex also lets us use a gold standard of routing (it just doesn't enforce it): controllers as classes. Even then, if your routing is just done by string names of class and method e.g. $app->get('/', 'Controller\\HomeController:get'); then how do you inject services?

The temptation is to use the first tool to hand, which is type hinting (again):

// Coupled again.
namespace Controller;
class HomeController
{
    public function get(Request $request, Application $app)
    {
        // Can couple to any service here via $app.
    }
}

As you can see, this introduces potential coupling to arbitrary services (also again).

Instead, register the confusedly-named Service-Controller Service Provider:

<?php
$app->register(new Silex\Provider\ServiceControllerServiceProvider());

This then allows controllers to be registered like any other service, and then their service name used instead of the class name during routing. Here's an example from an actual running application:

<?php
$app['config'] = new ConfigService();
$this['home'] = function() use ($app) {
        return new Controller\HomeController($app['config']);
};
$this->get('/', 'home:get');

If you also define your controller constructor using interface argument typing:

<?php
 
namespace Controller;
class HomeController
{
    private $config = null;
 
    public function __construct(ConfigServiceInterface $config)
    {
        $this->config = $config;
    }
 
    public function get(Request $request)
    {
        // Only have access to Request and a ConfigService.
    }
}

then you won't even have to use mocks: instead, you can define and inject class DummyConfigService implements ConfigServiceInterface, and even write tests for your resulting DummyConfig class. Even then, the real ConfigService can be created and passed in, if you want to do integration testing alongside unit testing.

Everything is now testable, in isolation and together!

Summary

If you know what you're doing, you can write reasonably coupled code in microframeworks like Silex and still have a perfectly robust application. But as your application grows it always helps to have a decoupling strategy, that you can deploy to separate out complex code into separately testable chunks.

Enforcing non-container DI into services, and implementing controllers as services, means that "everything is a service", and everything is testable in isolation. This can bring sanity to your code, helping a larger team or merely your future self.

Add new comment