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:
- Check if the configuration object exists in the class's static cache (
\Drupal\Core\Config\ConfigFactory::$cache
), return that cached object if it is. - Read the configuration object from configuration storage. The first storage is
\Drupal\Core\Config\CachedStorage
which checks if the config object exists in the configcache
bin. If the object isn't cached, then it is read from the active configuration store (the database.) - An
ImmutableConfig
object is created from the data retrieved from storage in\Drupal\Core\Config\ConfigFactory::createConfigObject
. - Configuration overrides are applied to the config object – configuration overrides are the $
config['my_module.settings']
declarations in yoursettings.php
file. - 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 🗓️
Want more? Sign up for my weekly newsletter