[policy, documentation] Clarify Constructor BC policy and document best practices for subclassing other classes

Created on 4 February 2019, almost 6 years ago
Updated 30 November 2023, about 1 year ago

Problem/Motivation

https://www.drupal.org/core/d8-bc-policy β†’ currently states this:

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.

I think when that was written, it wasn't quite clear to us just how common it will be that constructors are overriden due to dependency injection. Especially on base classes (e.g. ContentEntityForm, entity handlers, plugin base classes) but also specific implementations.

Recently, we started being more careful with those kind of changes, because we did break quite a few contrib and custom modules and that doesn't help when we're trying to keep everyone on the most recent minor version. However, there are no clear rules, which resulted in very different approaches, some cases being extremely strict, while others have been committed with possibly breaking changes.

See also 🌱 Use final to define classes that are NOT extension points Active on a discussion as to when and how we should specifically disallow subclasses.

Proposed resolution

As part of the dozens of constructor changes due to entity.manager replacements, the following rules have been applied:

1. If the replacement also implements the same interface as the original service (e.g. EntityManager implements all its 10 or so replacement interfaces and can be injected as EntityTypeManagerInterface $entity_type_manager for example), then we can just replace it. In case of EntityManager, we will then do @trigger_error() on all method calls and catch it through that.
2. Other cases are added as new optional arguments, with a @trigger_error() + and fallback to \Drupal::service() if not provided.

In some rare cases, arguments also need to be removed, this hasn't been clearly defined yet. See #3025152: Remove entityManager constructor argument from SystemManager β†’ , there is a way to remove type hints and then dynamically check what we received, we could just remove it or we could wait until the 9.x branch to just remove it. The first approach likely only makes sense in case we would need to remove an argument to a very common base class.

Yet another challenge are injecting dependencies into a class that previous did not have a constructor at all.

I (@Berdir) would suggest we define it as a best-effort/best-practive thing, the default is that we do it, but we also can make an exception if there' s a reason, for example when we have to add a completely new constructor, or remove an argument from a class that is very unlikely (or rarely) subclassed. That also includes that we will not add @legacy tests for such constructor changes and usually no dedicated CR is necessary. We might want to do one to announce the change in BC policy, there is now also already a snippet in the EntityManager CR: https://www.drupal.org/node/2549139 β†’ .

There have also been some practices on how services as well as anything that is based on one of the ContainerFactory interfaces can be subclassed and extra dependency injected, typically through setter methods that do not require an overridden constructor. We should decide if we want to adopt that in core to set an example, so that changes like this will in the future be easier to make.

Services, using setter methods in the service definition: https://www.md-systems.ch/en/blog/techblog/2016/12/17/how-to-safely-inje...

Plugins, by calling setter methods from the create() method, after calling the parent: https://www.previousnext.com.au/blog/safely-extending-drupal-8-plugin-cl....

Remaining tasks

Define a new BC policy for constructors, update documentation for that and best practices, if we agree on that.

User interface changes

API changes

Data model changes

Release notes snippet

πŸ“Œ Task
Status

Active

Version

11.0 πŸ”₯

Component
BaseΒ  β†’

Last updated 1 day ago

Created by

πŸ‡¨πŸ‡­Switzerland berdir Switzerland

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

Not all content is available!

It's likely this issue predates Contrib.social: some issue and comment data are missing.

  • πŸ‡«πŸ‡·France GaΓ«lG Lille, France

    @akalam I ended here searching for the same. After a bit of reading, here's what I found out:

    • AFAIK, there is no official way to subclass dependency-injecting classes (services, plugins,...).

    • For services, it's usually better to decorate than subclass when possible (for plugins, I guess it's too complex to decorate).

    • It's not official, but if you have to subclass and do not control the parent class (you're in custom or contrib and parent class is in core or other contrib), you should not override the constructor but rather inject additional needed services directly in create(), as explained in various blog posts such as https://www.hashbangcode.com/article/drupal-9-extending-drupal-base-clas... and https://www.previousnext.com.au/blog/safely-extending-drupal-8-plugin-cl...

    • Why? Because your code won't break if the parent constructor changes its signature.

    • Drupal core still overrides constructors because it can: it controls the parent classes, which are in core too. This may explain why the bad practice of overriding constructors is widespread, because core is taken as an example.

    • Solely injecting additional needed services in create() and not in constructor can be a problem in one case: unit tests. So, if/when you need to unit-test your class, you can declare a setter that allows you to set the service just after construction.

Production build 0.71.5 2024