How to safely inject additional services into an overridden service

Sometimes a contrib or custom module needs to override a service provided by Drupal core or another module. That allows to alter almost anything that is going on in Drupal and provide an alternative or extending implementation. However, there is a risk that a module that does so breaks with a new minor version.

The Drupal 8 backwards compatibility and internal API policy contains the following paragraph:

Constructors for service objects, plugins, and controllers

The constructor for a service object (one in the container), plugin, or controller is considered internal unless otherwise marked, and may change in a > minor release. These are objects where developers are not expected to call the constructor directly in the first place. Constructors for value objects where a developer will be calling the constructor directly are excluded from this statement.

This happened for example with the MailManager, an additional argument was added in 8.2.0 that broke the Mailsystem module that is maintained by us.

Mailsystem overrides that service to allow for a different plugin for formatting and sending an e-mail and to use a specific theme for rendering e-mails. For that, it needs the theme manager service and injects that in the constructor, as services should do, it did that like this in the service provider:

    // Overrides mail-factory class to use our own mail manager.
    $container->getDefinition('plugin.manager.mail')
      ->setClass('Drupal\mailsystem\MailsystemManager')
      ->addArgument(new Reference('theme.manager'))
      ->addArgument(new Reference('theme.initialization'));

This caused a problem because while it was easy to change the constructor for Drupal 8.2, that would have meant that would not have been compatible anymore with Drupal 8.1 and users would have been forced to override the module together with Drupal core.

After some testing, we found a way that allowed us to inject those services in a way that works both with Drupal 8.1 and Drupal 8.2, by using setter injection:

    // Overrides mail-factory class to use our own mail manager.
    $container->getDefinition('plugin.manager.mail')
      ->setClass('Drupal\mailsystem\MailsystemManager')
      ->addMethodCall('setThemeManager', [new Reference('theme.manager')])
      ->addMethodCall('setThemeInitialization', [new Reference('theme.initialization')]);

This allowed us to remove the constructor from MailsystemManager and release a new version before Drupal 8.2.0 was released, so that users could safely update the module already with Drupal 8.1. And if another argument is added in Drupal 8.3 or later, we do not have to change our overridden service anymore.

Unfortunately, this approach doesn't work for plugins, controllers and forms as those use the create() method and that will need to change if a new argument needs to be passed to the constructor. Recent changes in Drupal core attempt to minimize the impact by making new constructor arguments optional, so that existing overrides do not break. It is recommended to use the same approach in stable contrib modules.