Explore using more service closures to break deep dependency chains and load fewer services

Created on 8 August 2025, 5 days ago

Problem/Motivation

In 📌 Convert template preprocess in system.module Active , I identified this dependency chain that loads ModuleExtensionList very early:

router_listener -> router -> router.no_access_checks -> url_generator -> renderer -> theme.manager -> theme.registry -> extension.list.module

And in 📌 Convert template_preprocess and related functions in views_ui Active , I also noticed that OptionsRequestSubscriber triggers a very early chain for the ConfigFactory.

I'm thinking about some kind of debug script within the Container that will dump out dependency chains into a file, to identify long ones and verify the impact of changes.

Steps to reproduce

Proposed resolution

Use some service closures in specific places.

Remaining tasks

User interface changes

Introduced terminology

API changes

Data model changes

Release notes snippet

📌 Task
Status

Active

Version

11.0 🔥

Component

base system

Created by

🇨🇭Switzerland berdir Switzerland

Live updates comments and jobs are added and updated live.
  • Performance

    It affects performance. It is often combined with the Needs profiling tag.

Sign in to follow issues

Comments & Activities

  • Issue created by @berdir
  • 🇬🇧United Kingdom catch

    This is a good idea.

    Specifically with this one:

    router_listener -> router -> router.no_access_checks -> url_generator -> renderer -> theme.manager -> theme.registry -> extension.list.module
    

    Url generator in this one looked weird, so I checked, it's not used.

    This has been the case since #3293284: Throw an exception when Router::generate() is called which is 'only' three years ago. Opened 📌 Remove Url generator from router class Active .

  • 🇨🇭Switzerland berdir Switzerland

    As mentioned in 📌 Remove Url generator from router class Active , I wrote a little patch to write out all essentially all Container::createService() with their initiator. Initiator here means the first createService() call when we're not already within a createService() chain. And when leaving that initial createService() all, I write out that list as a single line into a txt file. The result is a list of all dependency chains. Any direct call to get() that is a new server, including such as plugin/storage/controller "DI" is seen as a standalone thing. As well as service locators and similar factories that initiate services lazily.

    A separate file is written for each request with timestamp and path, which allow to compare them. It's a single line, but it's actually a tree of services, so not every -> actually represents a chained call. It would be possible to visualize this better, but it might also make it harder to diff. That wasn't a focus yet.

    The idea is to track when a given service is used the first time. And the goal of this issue would be to find meaningful and worthwhile ways to push them "back", non-html (such as json api, but also things like autocomplete callbacks and assets) and cached pages are likely where we'll see the most benefit, as "back" might mean that they wouldn't be needed at all. The script can be run before and after a change to visual the change.

    I excluded private services because those would differ between cache clears and likely aren't so meaningful here.

    That can either be done with a regular diff, or what I prefer, within a fresh git repository with a word-based, as seen in the screenshot in that other issue.

    The changes as diff:

    diff --git a/core/lib/Drupal/Component/DependencyInjection/Container.php b/core/lib/Drupal/Component/DependencyInjection/Container.php
    index dc68c9c8a24..5f4cc3d6521 100644
    --- a/core/lib/Drupal/Component/DependencyInjection/Container.php
    +++ b/core/lib/Drupal/Component/DependencyInjection/Container.php
    @@ -46,6 +46,8 @@
      */
     class Container implements ContainerInterface, ResetInterface {
    
    +  protected array $currentDependencyChain = [];
    +
       /**
        * The parameters of the container.
        *
    @@ -225,6 +227,15 @@ public function reset(): void {
        *   and cannot be instantiated.
        */
       protected function createService(array $definition, $id) {
    +
    +    $initial = FALSE;
    +    if (!str_starts_with($id, 'private__')) {
    +      if (empty($this->currentDependencyChain)) {
    +        $initial = TRUE;
    +      }
    +      $this->currentDependencyChain[] = $id;
    +    }
    +
         if (isset($definition['synthetic']) && $definition['synthetic'] === TRUE) {
           throw new RuntimeException(sprintf('You have requested a synthetic service ("%s"). The service container does not know how to construct this service. The service will need to be set before it is first used.', $id));
         }
    @@ -299,6 +310,11 @@ protected function createService(array $definition, $id) {
           call_user_func($callable, $service);
         }
    
    +    if ($initial && defined('DEPENDENCY_DEBUG_FILE')) {
    +      file_put_contents(DEPENDENCY_DEBUG_FILE, implode(' -> ', $this->currentDependencyChain) . "\n", FILE_APPEND);
    +      $this->currentDependencyChain = [];
    +    }
    +
         return $service;
       }
    
    diff --git a/index.php b/index.php
    index 750dc282dc2..d4fe93a2b0a 100644
    --- a/index.php
    +++ b/index.php
    @@ -16,6 +16,9 @@
     $kernel = new DrupalKernel('prod', $autoloader);
    
     $request = Request::createFromGlobals();
    +define('DEPENDENCY_DEBUG_FILE', __DIR__ . '/dependencies/' . $request->server->get('REQUEST_TIME') . '-' . str_replace('/', '-', $request->getPathInfo()) .  '.txt');
    +touch(DEPENDENCY_DEBUG_FILE);
    +
     $response = $kernel->handle($request);
     $response->send();
    

    I attached the current state on head as a text file, it's a bit much as a snippet and wrapping makes it hard to read.

    In some cases, we'll want to specifically test dynamic page or even page cache hits, I'll do some testing with this in the middleware issue with those scenarios.

  • 🇬🇧United Kingdom catch

    Here's some from https://www.drupal.org/files/issues/2025-08-08/1754652863--.txt fthat look like low-hanging fruit to break/shorten:

    theme_handler -> extension.list.theme -> info_parser -> extension.list.theme_engine
    

    We don't need the info_parser until we parse info files.

    html_response.big_pipe_subscriber -> big_pipe -> logger.channel.php -> logger.factory -> logger.dblog -> logger.log_message_parser -> logger.drupaltodrush
    

    The logger is only needed for errors.

    html_response.subscriber -> html_response.attachments_processor -> asset.resolver -> library.discovery -> library.discovery.parser -> library.libraries_directory_file_finder -> extension.path.resolver -> plugin.manager.sdc -> Drupal\Core\Theme\ComponentNegotiator -> file_system -> Drupal\Core\Theme\Component\SchemaCompatibilityChecker -> 
    

    library.discovery.parser is only needed on cache misses.

    Drupal\Core\Template\IconsTwigExtension -> plugin.manager.icon_pack -> plugin.manager.icon_extractor 
    

    Not sure which one of these should be the service closure but again we don't need these unless there's an actual icon to render.

    theme.negotiator.system.batch -> batch.storage
    

    Don't need the batch storage unless we're on the batch route.

    drupal.proxy_original_service.node_preview -> tempstore.private
    

    Don't need the private tempstore unless we're on the node preview route.

    drupal.proxy_original_service.paramconverter.views_ui -> entity_type.manager -> string_translation -> string_translator.custom_strings -> entity.last_installed_schema.repository -> tempstore.shared
    

    Similarly don't need the tempstore unless views ui param converter is actually active.

    (all of these make me think we might want to rethink param converters and theme negotiators, maybe we should have a way to attach one to a specific route so they never get consulted unless we're on the route in the first place?)

    Drupal\Core\Template\Loader\ComponentLoader -> logger.channel.default
    

    Logger is only needed if we log.

    twig.extension.debug -> twig.extension.varDumper
    

    One of these can go.

  • 🇬🇧United Kingdom catch

    Also:

    session -> session_manager -> database -> session_manager.metadata_bag
    

    We should be able to avoid creating a database connection until we know we have a session cookie to check against. Dynamic page cache hits for anonymous users can theoretically be served without touching the database at all. Auth can if the an alternative session storage is used.

Production build 0.71.5 2024