Decoupling from a service locator

Posted on by Matthias Noback

Decoupling from a service locator - shouldn't that be: don't use a service locator?

Well, not really, since there are lots of valid use cases for using a service locator. The main use case is for making things lazy-loading (yes, you can also use some kind of proxy mechanism for that, but let's assume you need something simpler). Say we have this EventDispatcher class:

class EventDispatcher
{
    public function __construct(array $listeners)
    {
        $this->listeners = $listeners;
    }

    public function dispatch($event)
    {
        foreach ($this->listeners[$event] as $listener) {
            $listener->notify();
        }
    }
}

Now it appears that some event listeners are rather expensive to instantiate. And, even though it may never be notified (because it listens to a rare event), in order to register any event listener, we need to instantiate it:

$dispatcher = new EventDispatcher(
    array(
        'some_rare_event'  => new ExpensiveEventListener()
    )
);

Most applications already use a service container (e.g. the Symfony DependencyInjection Container) which only instantiates services when they are requested for the first time. So let's just use the service container to fetch (and instantiate) listeners only when they are actually needed:

use Symfony\Component\DependencyInjection\ContainerInterface;

class ContainerAwareEventDispatcher
{
    public function __construct(
        array $listenerServiceIds,
        ContainerInterface $container
    ) {
        $this->listenerServiceIds = $listenerServiceIds;
        $this->container = $container;
    }

    public function dispatch($event)
    {
        foreach ($this->listenerServiceIds[$event] as $listenerServiceId) {
            // fetch the actual listener from the service container:
            $listener = $this->container->get($listenerServiceId);

            $listener->notify();
        }
    }
}

I consider this a valid approach. However, now my stand-alone library code has a dependency on the Symfony service container. Which is not good since not all people will have it in their projects. They may already use something else for dependency injection. So, let's decouple this!

Of course, we could roll our own service container interface (or service locator actually) for this, e.g.

interface ServiceLocator
{
    public function get($id);
}

class ContainerAwareEventDispatcher
{
    public function __construct(array $listenerServiceIds, ServiceLocator $container)
    {
        ...
    }
    ...
}

In that case we have stand-alone library code again, because we applied the Dependency inversion principle. The initial dependency direction was from ContainerAwareEventDispatcher to the external ContainerInterface. After introducing our own ServiceLocator interface, there is no outward dependency arrow anymore.

However, introducing the extra interface requires users to implement their own service locator classes. In most situations which require some kind of adapter class this makes perfect sense (see a previous post of mine on decoupling your event system). But in this case, the ServiceLocator interface has only one method with one argument. This, I think, allows us to pick an entirely different solution.

Using anonymous callables for dependency inversion

Instead of using adapter classes, we could use adapter closures (or even more general: callables). We rewrite the EventDispatcher to call the "callable" service locator which is called with just one argument; the service id:

class EventDispatcher
{
    public function __construct(array $listenerServiceIds, callable $serviceLocator)
    {
        $this->listenerServiceIds = $listenerServiceIds;
        $this->serviceLocator = $serviceLocator;
    }

    public function dispatch($event)
    {
        foreach ($this->listenerServiceIds[$event] as $listenerServiceId) {
            $listener = call_user_func($this->serviceLocator, $listenerServiceId);
            $listener->notify();
        }
    }
}

Unfortunately we have lost the explicit contract of "being a service locator" here, but instead, we gained a lot of flexibility. Instead of requiring a service locator of a particular type, users can now decide for themselves how they are going to load the event listeners. For example, using the simple PHP dependency injection container Pimple:

$container = new \Pimple();

$container['expensive_event_listener'] = function () {
    return new ExpensiveEventListener();
};

$container['event_dispatcher'] = function ($container) {
    return new EventDispatcher(
        array(
            'some_rare_event' => array(
                // only provide service ids, no instances:
                'expensive_event_listener'
            )
        ),
        // this is the service locator callable:
        function ($serviceId) {
            return $container[$serviceId];
        }
    );
};

For Symfony we still have to create a simple class, because the service container is not defined at runtime, but compiled to a PHP file. This is what it might look like:

class SymfonyServiceLocator
{
    public function __construct(ContainerInterface $container)
    {
        $this->container = $container;
    }

    public function __invoke($serviceId)
    {
        return $this->container->get($serviceId);
    }
}

Using the magic __invoke() method here will turn the entire object into a callable.

And the service definitions required to make this work:

services:
    event_dispatcher:
        class: EventDispatcher
        arguments:
            # provide an array of service ids by event name
            - []
            - @symfony_service_locator

    symfony_service_locator:
        class: SymfonyServiceLocator
        arguments:
            - @service_container
        public: false

By the way, I used this technique in the SimpleBus project (which provides basic implementations for using a command and/or an event bus in your application). There is an event handler esolver which uses a service locator callable to instantiate only relevant event handlers. And of course there is a service locator for Symfony.

Using a synthetic service

There is another approach which allows us to use a closure a service locator in Symfony applications. I'm not sure if it's preferable over the other approach, but you can decide.

First, we make the symfony_service_locator a "synthetic" service, meaning we can set it at runtime:

services:
    ...

    symfony_service_locator:
        synthetic: true

Now in one of our bundle's boot() method (or in the boot() method of the AppKernel method) we can just set the service locator:

class MyBundle extends Bundle
{
    public function boot()
    {
        $this->container->set(
            'symfony_service_locator',
            function ($serviceId) {
                // for PHP < 5.4 you'd have to explicitly "use" the container
                return $this->container->get($serviceId);
            }
        );
    }
}

Conclusion

I think it's the first time I implemented dependency inversion using anonymous callables like this. I like it so far, but I also think you can only do this when it is a surrogate for a very simple interface (one method, one argument). This condition drastically reduces the potential number of use cases, but I think that's a good thing. In most cases it will make much more sense to just introduce another interface, like you're used to.

One last remark

If you want your users to choose between a lazy-loading an eager-loading strategy, using callables for dependency inversion like this comes with a big advantage: you don't have to create an EagerLoadingEventDispatcher. The user only needs to provide something like an "identity service locator":

$dispatcher = new EventDispatcher(
    array(
        'some_rare_event' => array(
            // provide the actual event listener instances:
            new ExpensiveEventListener()
        )
    ),
    function ($listener) {
        // $listener is already an object so just return it!
        return $listener;
    }
);
PHP Symfony2 decoupling dependency inversion service container