Skip to main content

Factories and dependency injection

Published on

Last week I wrote about dependency injection anti-patterns in Drupal. These anti-patterns occur when your service's constructor has logic that interacts with the injected dependent services beyond assigning them to properties. These anti-patterns include creating a new object from a factory or retrieving an object from a stack. However, Symfony's service container supports defining services built from factories. This can streamline your code and follow best practices when using dependency injection. Drupal uses this factory service design pattern for cache bins, loggers, and the HTTP client service.

Factories for services

Factories provide a way to create objects that are not services. Consider them a way to abstract away dependency injection in complex objects to that your service does not need to. Symfony supports using the factory design pattern with dependency injection. The service defines the factory (class or service ID) and method for object creation. Then the service container will invoke that factory method and return the created object.

Here is the service definition for the default cache bin service:

  cache.default:
    class: Drupal\Core\Cache\CacheBackendInterface
    tags:
      - { name: cache.bin }
    factory: ['@cache_factory', 'get']
    arguments: [default]

This specifies that the class will be an instance of Drupal\Core\Cache\CacheBackendInterface. The service container will invoke the get method on the @cache_factory service. The arguments values are passed as arguments to the get method. The @cache_factory service is backed by the Drupal\Core\Cache\CacheFactory class. The service container will call CacheFactory::get('default'). This factory reads from settings overrides and the cache_default_bin_backends service container parameter to determine what kind of backend class to create.

Loggers follow the same approach.

  logger.channel_base:
    abstract: true
    class: Drupal\Core\Logger\LoggerChannel
    factory: ['@logger.factory', 'get']
    
  logger.channel.default:
    parent: logger.channel_base
    arguments: ['system']

The logger.channel.default gets constructed by the service container with LoggerChannelFactory::get('system'). Drupal provides the abstract service definition logger.channel_base to simplify defining additional logger channels. This factory sets up logger channels and injects the actual logging services – such as the database logger or syslog.

The HTTP client is created from a factory, as well.

  http_client:
    class: GuzzleHttp\Client
    factory: ['@http_client_factory', 'fromOptions']

This factory ensures HTTP client middlewares are configured on the Guzzle client, along with any settings overrides.

Factory injection pattern with configuration objects

What if we used the factory pattern with dependency injection using configuration objects? I honestly never considered this. Ross commented about their approach on my blog post:

services:
  my_module.config:
  class: Drupal\Core\Config\ImmutableConfig
  factory: config.factory:get
  arguments: ['my_module.settings']

This does streamline handling configuration objects. Instead of injecting the configuration factory and having to call $this->configFactory->get('my_module.settings') in your logic, you have the configuration object as a property while not breaking design patterns. But is it a good idea? What are the side effects? One of my concerns around anti-patterns in dependency injection is unintended side effects when the service is constructed. Fetching configuration through a factory service definition raises a few flags purely due to how the configuration factory works. The factories for cache bins, loggers, and the HTTP client service are fairly light. The configuration factory is more of a repository than a factory. And that little nuance is what makes me hesitant.

The flow for loading configuration works something like this:

  1. Check if the configuration object exists in the class's static cache (\Drupal\Core\Config\ConfigFactory::$cache), return that cached object if it is.
  2. Read the configuration object from configuration storage. The first storage is \Drupal\Core\Config\CachedStorage which checks if the config object exists in the config cache bin. If the object isn't cached, then it is read from the active configuration store (the database.)
  3. An ImmutableConfig object is created from the data retrieved from storage in \Drupal\Core\Config\ConfigFactory::createConfigObject.
  4. Configuration overrides are applied to the config object – configuration overrides are the $config['my_module.settings'] declarations in your settings.php file.
  5. The configuration object is returned

My concern is that retrieving a configuration object touches cache and the database store. Those are two connections that may not be needed if your service is initialized but the logic touching the configuration object does not run. Although, I'd much rather see this approach than someone manually assigning a configuration object to a property. This way if the service is serialized, implementing \Drupal\Core\DependencyInjection\DependencySerializationTrait will properly re-initialize the configuration object if the service is serialized.

I'm available for one-on-one consulting calls – click here to book a meeting with me 🗓️