diff --git a/core/modules/taxonomy/src/Hook/TaxonomyEntityHooks.php b/core/modules/taxonomy/src/Hook/TaxonomyEntityHooks.php
new file mode 100644
index 0000000000000000000000000000000000000000..4f7b4b416476339d58e511fd5119f7ad8439e8f1
--- /dev/null
+++ b/core/modules/taxonomy/src/Hook/TaxonomyEntityHooks.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace Drupal\taxonomy\Hook;
+
+use Drupal\taxonomy\Entity\Term;
+use Drupal\taxonomy\NodeIndex;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\Url;
+
+/**
+ * Hook implementations for taxonomy.
+ */
+class TaxonomyEntityHooks {
+
+  use StringTranslationTrait;
+
+  public function __construct(
+    protected ConfigFactoryInterface $configFactory,
+    protected Connection $database,
+    protected EntityTypeManagerInterface $entityTypeManager,
+    protected NodeIndex $nodeIndex,
+  ) {}
+
+  /**
+   * Implements hook_entity_operation().
+   */
+  #[Hook('entity_operation')]
+  public function entityOperation(EntityInterface $term): array {
+    $operations = [];
+    if ($term instanceof Term && $term->access('create')) {
+      $operations['add-child'] = [
+        'title' => $this->t('Add child'),
+        'weight' => 10,
+        'url' => Url::fromRoute('entity.taxonomy_term.add_form', [
+          'taxonomy_vocabulary' => $term->bundle(),
+        ], [
+          'query' => [
+            'parent' => $term->id(),
+          ],
+        ]),
+      ];
+    }
+    return $operations;
+  }
+
+  /**
+   * @defgroup taxonomy_index Taxonomy indexing
+   * @{
+   * Functions to maintain taxonomy indexing.
+   *
+   * Taxonomy uses default field storage to store canonical relationships
+   * between terms and fieldable entities. However its most common use case
+   * requires listing all content associated with a term or group of terms
+   * sorted by creation date. To avoid slow queries due to joining across
+   * multiple node and field tables with various conditions and order by
+   * criteria, we maintain a denormalized table with all relationships between
+   * terms, published nodes and common sort criteria such as status, sticky and
+   * created. When using other field storage engines or alternative methods of
+   * denormalizing this data you should set the
+   * taxonomy.settings:maintain_index_table to '0' to avoid unnecessary writes
+   * in SQL.
+   */
+
+  /**
+   * Implements hook_ENTITY_TYPE_insert() for node entities.
+   */
+  #[Hook('node_insert')]
+  public function nodeInsert(EntityInterface $node): void {
+    // Add taxonomy index entries for the node.
+    $this->nodeIndex->buildNodeIndex($node);
+  }
+
+  /**
+   * Implements hook_ENTITY_TYPE_update() for node entities.
+   */
+  #[Hook('node_update')]
+  public function nodeUpdate(EntityInterface $node): void {
+    // If we're not dealing with the default revision of the node, do not make any
+    // change to the taxonomy index.
+    if (!$node->isDefaultRevision()) {
+      return;
+    }
+    $this->nodeIndex->deleteNodeIndex($node);
+    $this->nodeIndex->buildNodeIndex($node);
+  }
+
+  /**
+   * Implements hook_ENTITY_TYPE_predelete() for node entities.
+   */
+  #[Hook('node_predelete')]
+  public function nodePredelete(EntityInterface $node): void {
+    // Clean up the {taxonomy_index} table when nodes are deleted.
+    $this->nodeIndex->deleteNodeIndex($node);
+  }
+
+  /**
+   * Implements hook_ENTITY_TYPE_delete() for taxonomy_term entities.
+   */
+  #[Hook('taxonomy_term_delete')]
+  public function taxonomyTermDelete(Term $term): void {
+    if ($this->nodeIndex->shouldMaintainIndexTable()) {
+      // Clean up the {taxonomy_index} table when terms are deleted.
+      $this->database->delete('taxonomy_index')->condition('tid', $term->id())->execute();
+    }
+  }
+
+}
diff --git a/core/modules/taxonomy/src/Hook/TaxonomyHooks.php b/core/modules/taxonomy/src/Hook/TaxonomyHelpHooks.php
similarity index 58%
rename from core/modules/taxonomy/src/Hook/TaxonomyHooks.php
rename to core/modules/taxonomy/src/Hook/TaxonomyHelpHooks.php
index 4cc0a6f6eb613ce32ebdeb0edf1dd40b14a25b49..9daa7908d379d4274440a7c6f39de8c37e9a8c38 100644
--- a/core/modules/taxonomy/src/Hook/TaxonomyHooks.php
+++ b/core/modules/taxonomy/src/Hook/TaxonomyHelpHooks.php
@@ -2,28 +2,29 @@
 
 namespace Drupal\taxonomy\Hook;
 
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\taxonomy\Entity\Term;
-use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
 use Drupal\Core\Url;
 use Drupal\Core\Routing\RouteMatchInterface;
 use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
 
 /**
- * Hook implementations for taxonomy.
+ * Help hook implementation for taxonomy().
  */
-class TaxonomyHooks {
+class TaxonomyHelpHooks {
 
   use StringTranslationTrait;
 
+  public function __construct(protected ModuleHandlerInterface $moduleHandler) {}
+
   /**
    * Implements hook_help().
    */
   #[Hook('help')]
-  public function help($route_name, RouteMatchInterface $route_match): ?string {
+  public function help($route_name, RouteMatchInterface $route_match): string {
     switch ($route_name) {
       case 'help.page.taxonomy':
-        $field_ui_url = \Drupal::moduleHandler()->moduleExists('field_ui') ? Url::fromRoute('help.page', ['name' => 'field_ui'])->toString() : '#';
+        $field_ui_url = $this->moduleHandler->moduleExists('field_ui') ? Url::fromRoute('help.page', ['name' => 'field_ui'])->toString() : '#';
         $output = '';
         $output .= '<h2>' . $this->t('About') . '</h2>';
         $output .= '<p>' . $this->t('The Taxonomy module allows users who have permission to create and edit content to categorize (tag) content of that type. Users who have the <em>Administer vocabularies and terms</em> <a href=":permissions" title="Taxonomy module permissions">permission</a> can add <em>vocabularies</em> that contain a set of related <em>terms</em>. The terms in a vocabulary can either be pre-set by an administrator or built gradually as content is added and edited. Terms may be organized hierarchically if desired.', [
@@ -69,114 +70,10 @@ public function help($route_name, RouteMatchInterface $route_match): ?string {
       case 'entity.taxonomy_vocabulary.collection':
         $output = '<p>' . $this->t('Taxonomy is for categorizing content. Terms are grouped into vocabularies. For example, a vocabulary called "Fruit" would contain the terms "Apple" and "Banana".') . '</p>';
         return $output;
-    }
-    return NULL;
-  }
-
-  /**
-   * Implements hook_theme().
-   */
-  #[Hook('theme')]
-  public function theme() : array {
-    return ['taxonomy_term' => ['render element' => 'elements']];
-  }
-
-  /**
-   * Implements hook_local_tasks_alter().
-   *
-   * @todo Evaluate removing as part of https://www.drupal.org/node/2358923.
-   */
-  #[Hook('local_tasks_alter')]
-  public function localTasksAlter(&$local_tasks): void {
-    $local_task_key = 'config_translation.local_tasks:entity.taxonomy_vocabulary.config_translation_overview';
-    if (isset($local_tasks[$local_task_key])) {
-      // The config_translation module expects the base route to be
-      // entity.taxonomy_vocabulary.edit_form like it is for other configuration
-      // entities. Taxonomy uses the overview_form as the base route.
-      $local_tasks[$local_task_key]['base_route'] = 'entity.taxonomy_vocabulary.overview_form';
-    }
-  }
-
-  /**
-   * Implements hook_entity_operation().
-   */
-  #[Hook('entity_operation')]
-  public function entityOperation(EntityInterface $term): array {
-    $operations = [];
-    if ($term instanceof Term && $term->access('create')) {
-      $operations['add-child'] = [
-        'title' => $this->t('Add child'),
-        'weight' => 10,
-        'url' => Url::fromRoute('entity.taxonomy_term.add_form', [
-          'taxonomy_vocabulary' => $term->bundle(),
-        ], [
-          'query' => [
-            'parent' => $term->id(),
-          ],
-        ]),
-      ];
-    }
-    return $operations;
-  }
 
-  /**
-   * @defgroup taxonomy_index Taxonomy indexing
-   * @{
-   * Functions to maintain taxonomy indexing.
-   *
-   * Taxonomy uses default field storage to store canonical relationships
-   * between terms and fieldable entities. However its most common use case
-   * requires listing all content associated with a term or group of terms
-   * sorted by creation date. To avoid slow queries due to joining across
-   * multiple node and field tables with various conditions and order by
-   * criteria, we maintain a denormalized table with all relationships between
-   * terms, published nodes and common sort criteria such as status, sticky and
-   * created. When using other field storage engines or alternative methods of
-   * denormalizing this data you should set the
-   * taxonomy.settings:maintain_index_table to '0' to avoid unnecessary writes
-   * in SQL.
-   */
-
-  /**
-   * Implements hook_ENTITY_TYPE_insert() for node entities.
-   */
-  #[Hook('node_insert')]
-  public function nodeInsert(EntityInterface $node): void {
-    // Add taxonomy index entries for the node.
-    taxonomy_build_node_index($node);
-  }
-
-  /**
-   * Implements hook_ENTITY_TYPE_update() for node entities.
-   */
-  #[Hook('node_update')]
-  public function nodeUpdate(EntityInterface $node): void {
-    // If we're not dealing with the default revision of the node, do not make
-    // any change to the taxonomy index.
-    if (!$node->isDefaultRevision()) {
-      return;
-    }
-    taxonomy_delete_node_index($node);
-    taxonomy_build_node_index($node);
-  }
-
-  /**
-   * Implements hook_ENTITY_TYPE_predelete() for node entities.
-   */
-  #[Hook('node_predelete')]
-  public function nodePredelete(EntityInterface $node): void {
-    // Clean up the {taxonomy_index} table when nodes are deleted.
-    taxonomy_delete_node_index($node);
-  }
-
-  /**
-   * Implements hook_ENTITY_TYPE_delete() for taxonomy_term entities.
-   */
-  #[Hook('taxonomy_term_delete')]
-  public function taxonomyTermDelete(Term $term): void {
-    if (\Drupal::config('taxonomy.settings')->get('maintain_index_table')) {
-      // Clean up the {taxonomy_index} table when terms are deleted.
-      \Drupal::database()->delete('taxonomy_index')->condition('tid', $term->id())->execute();
+      default:
+        $output = '';
+        return $output;
     }
   }
 
diff --git a/core/modules/taxonomy/src/Hook/TaxonomyMenuHooks.php b/core/modules/taxonomy/src/Hook/TaxonomyMenuHooks.php
new file mode 100644
index 0000000000000000000000000000000000000000..a5f457e76dbc4102077646519edb0f058b1f14d7
--- /dev/null
+++ b/core/modules/taxonomy/src/Hook/TaxonomyMenuHooks.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\taxonomy\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+
+/**
+ * Menu hook implementations for taxonomy.
+ */
+class TaxonomyMenuHooks {
+
+  /**
+   * Implements hook_local_tasks_alter().
+   *
+   * @todo Evaluate removing as part of https://www.drupal.org/node/2358923.
+   */
+  #[Hook('local_tasks_alter')]
+  public function localTasksAlter(&$local_tasks): void {
+    $local_task_key = 'config_translation.local_tasks:entity.taxonomy_vocabulary.config_translation_overview';
+    if (isset($local_tasks[$local_task_key])) {
+      // The config_translation module expects the base route to be
+      // entity.taxonomy_vocabulary.edit_form like it is for other configuration
+      // entities. Taxonomy uses the overview_form as the base route.
+      $local_tasks[$local_task_key]['base_route'] = 'entity.taxonomy_vocabulary.overview_form';
+    }
+  }
+
+}
diff --git a/core/modules/taxonomy/src/Hook/TaxonomyThemeHooks.php b/core/modules/taxonomy/src/Hook/TaxonomyThemeHooks.php
new file mode 100644
index 0000000000000000000000000000000000000000..a9b024b5f48e28049c626aefb70becab9ceb7e29
--- /dev/null
+++ b/core/modules/taxonomy/src/Hook/TaxonomyThemeHooks.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\taxonomy\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+
+/**
+ * Implements hook_theme().
+ */
+class TaxonomyThemeHooks {
+
+  /**
+   * Implements hook_theme().
+   */
+  #[Hook('theme')]
+  public function taxonomyTheme($existing, $type, $theme, $path): array {
+    return [
+      'taxonomy_term' => [
+        'render element' => 'elements',
+      ],
+    ];
+  }
+
+  /**
+   * Implements hook_theme_suggestions_HOOK().
+   */
+  #[Hook('theme_suggestions_taxonomy_term')]
+  public function themeSuggestionsTaxonomyTerm(array $variables): array {
+    $suggestions = [];
+
+    /** @var \Drupal\taxonomy\TermInterface $term */
+    $term = $variables['elements']['#taxonomy_term'];
+
+    $suggestions[] = 'taxonomy_term__' . $term->bundle();
+    $suggestions[] = 'taxonomy_term__' . $term->id();
+
+    return $suggestions;
+  }
+
+}
diff --git a/core/modules/taxonomy/src/NodeIndex.php b/core/modules/taxonomy/src/NodeIndex.php
new file mode 100644
index 0000000000000000000000000000000000000000..6627c677a173ae98948a2b9f0ba10f91e56d9ec5
--- /dev/null
+++ b/core/modules/taxonomy/src/NodeIndex.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Drupal\taxonomy;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
+use Drupal\node\NodeInterface;
+
+/**
+ * Defines a Controller class for maintaining the taxonomy node index.
+ */
+class NodeIndex {
+
+  public function __construct(
+    protected readonly ConfigFactoryInterface $configFactory,
+    protected readonly Connection $database,
+    protected readonly EntityTypeManagerInterface $entityTypeManager,
+  ) {}
+
+  /**
+   * Returns the maintain_index_table configuration value.
+   */
+  public function shouldMaintainIndexTable(): bool {
+    $taxonomy_config = $this->configFactory->get('taxonomy.settings');
+    $maintain_index_table = $taxonomy_config->get('maintain_index_table');
+    return (bool) $maintain_index_table;
+  }
+
+  /**
+   * Builds and inserts taxonomy index entries for a given node.
+   *
+   * The index lists all terms that are related to a given node entity, and is
+   * therefore maintained at the entity level.
+   *
+   * @param \Drupal\node\NodeInterface $node
+   *   The node entity.
+   */
+  public function buildNodeIndex(NodeInterface $node): void {
+    // We maintain a denormalized table of term/node relationships, containing
+    // only data for current, published nodes.
+    if (!$this->shouldMaintainIndexTable() || !($this->entityTypeManager->getStorage('node') instanceof SqlContentEntityStorage)) {
+      return;
+    }
+
+    $status = $node->isPublished();
+    $sticky = (int) $node->isSticky();
+    // We only maintain the taxonomy index for published nodes.
+    if ($status && $node->isDefaultRevision()) {
+      // Collect a unique list of all the term IDs from all node fields.
+      $tid_all = [];
+      $entity_reference_class = 'Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem';
+      foreach ($node->getFieldDefinitions() as $field) {
+        $field_name = $field->getName();
+        $class = $field->getItemDefinition()->getClass();
+        $is_entity_reference_class = ($class === $entity_reference_class) || is_subclass_of($class, $entity_reference_class);
+        if ($is_entity_reference_class && $field->getSetting('target_type') == 'taxonomy_term') {
+          foreach ($node->getTranslationLanguages() as $language) {
+            foreach ($node->getTranslation($language->getId())->$field_name as $item) {
+              if (!$item->isEmpty()) {
+                $tid_all[$item->target_id] = $item->target_id;
+              }
+            }
+          }
+        }
+      }
+      // Insert index entries for all the node's terms.
+      if (!empty($tid_all)) {
+        foreach ($tid_all as $tid) {
+          $this->database->merge('taxonomy_index')
+            ->keys(['nid' => $node->id(), 'tid' => $tid, 'status' => $node->isPublished()])
+            ->fields(['sticky' => $sticky, 'created' => $node->getCreatedTime()])
+            ->execute();
+        }
+      }
+    }
+  }
+
+  /**
+   * Deletes taxonomy index entries for a given node.
+   *
+   * @param \Drupal\node\NodeInterface $node
+   *   The node entity.
+   */
+  public function deleteNodeIndex(NodeInterface $node): void {
+    if ($this->shouldMaintainIndexTable()) {
+      $this->database->delete('taxonomy_index')->condition('nid', $node->id())->execute();
+    }
+  }
+
+}
diff --git a/core/modules/taxonomy/taxonomy.module b/core/modules/taxonomy/taxonomy.module
index 04c00e36b6c25dc3f18830edcedb8ad44be65d11..1bd7194c97b2d21ba56abde7483860bd60e1476d 100644
--- a/core/modules/taxonomy/taxonomy.module
+++ b/core/modules/taxonomy/taxonomy.module
@@ -5,25 +5,9 @@
  */
 
 use Drupal\Core\Entity\EntityInterface;
-use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
 use Drupal\Core\Render\Element;
 use Drupal\taxonomy\Entity\Term;
 
-/**
- * Implements hook_theme_suggestions_HOOK().
- */
-function taxonomy_theme_suggestions_taxonomy_term(array $variables): array {
-  $suggestions = [];
-
-  /** @var \Drupal\taxonomy\TermInterface $term */
-  $term = $variables['elements']['#taxonomy_term'];
-
-  $suggestions[] = 'taxonomy_term__' . $term->bundle();
-  $suggestions[] = 'taxonomy_term__' . $term->id();
-
-  return $suggestions;
-}
-
 /**
  * Prepares variables for taxonomy term templates.
  *
@@ -97,46 +81,14 @@ function taxonomy_term_is_page(Term $term) {
  *
  * @param \Drupal\node\NodeInterface $node
  *   The node entity.
+ *
+ * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use
+ * \Drupal::service('taxonomy.node_index')->buildNodeIndex($node);
+ * @see https://www.drupal.org/node/3515362
  */
 function taxonomy_build_node_index($node): void {
-  // We maintain a denormalized table of term/node relationships, containing
-  // only data for current, published nodes.
-  if (!\Drupal::config('taxonomy.settings')->get('maintain_index_table') || !(\Drupal::entityTypeManager()->getStorage('node') instanceof SqlContentEntityStorage)) {
-    return;
-  }
-
-  $status = $node->isPublished();
-  $sticky = (int) $node->isSticky();
-  // We only maintain the taxonomy index for published nodes.
-  if ($status && $node->isDefaultRevision()) {
-    // Collect a unique list of all the term IDs from all node fields.
-    $tid_all = [];
-    $entity_reference_class = 'Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem';
-    foreach ($node->getFieldDefinitions() as $field) {
-      $field_name = $field->getName();
-      $class = $field->getItemDefinition()->getClass();
-      $is_entity_reference_class = ($class === $entity_reference_class) || is_subclass_of($class, $entity_reference_class);
-      if ($is_entity_reference_class && $field->getSetting('target_type') == 'taxonomy_term') {
-        foreach ($node->getTranslationLanguages() as $language) {
-          foreach ($node->getTranslation($language->getId())->$field_name as $item) {
-            if (!$item->isEmpty()) {
-              $tid_all[$item->target_id] = $item->target_id;
-            }
-          }
-        }
-      }
-    }
-    // Insert index entries for all the node's terms.
-    if (!empty($tid_all)) {
-      $connection = \Drupal::database();
-      foreach ($tid_all as $tid) {
-        $connection->merge('taxonomy_index')
-          ->keys(['nid' => $node->id(), 'tid' => $tid, 'status' => $node->isPublished()])
-          ->fields(['sticky' => $sticky, 'created' => $node->getCreatedTime()])
-          ->execute();
-      }
-    }
-  }
+  @trigger_error('taxonomy_build_node_index() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use \Drupal::service(\'taxonomy.node_index\')->buildNodeIndex($node). See https://www.drupal.org/node/3515362', E_USER_DEPRECATED);
+  \Drupal::service('taxonomy.node_index')->buildNodeIndex($node);
 }
 
 /**
@@ -144,11 +96,14 @@ function taxonomy_build_node_index($node): void {
  *
  * @param \Drupal\Core\Entity\EntityInterface $node
  *   The node entity.
+ *
+ * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use
+ * \Drupal::service('taxonomy.node_index')->deleteNodeIndex($node) instead.
+ * @see https://www.drupal.org/node/3515362
  */
 function taxonomy_delete_node_index(EntityInterface $node): void {
-  if (\Drupal::config('taxonomy.settings')->get('maintain_index_table')) {
-    \Drupal::database()->delete('taxonomy_index')->condition('nid', $node->id())->execute();
-  }
+  @trigger_error('taxonomy_delete_node_index() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use \Drupal::service(\'taxonomy.node_index\')->deleteNodeIndex($node). See https://www.drupal.org/node/3515362', E_USER_DEPRECATED);
+  \Drupal::service('taxonomy.node_index')->deleteNodeIndex($node);
 }
 
 /**
diff --git a/core/modules/taxonomy/taxonomy.services.yml b/core/modules/taxonomy/taxonomy.services.yml
index 91a333ec9773243b37dc1e91f0b4657070adc667..2a5fa8fba95d3650daa30b5aa9e8f838f217c2ad 100644
--- a/core/modules/taxonomy/taxonomy.services.yml
+++ b/core/modules/taxonomy/taxonomy.services.yml
@@ -11,3 +11,9 @@ services:
     arguments: ['@current_route_match']
     tags:
       - { name: 'context_provider' }
+  taxonomy.node_index:
+    class: Drupal\taxonomy\NodeIndex
+    arguments: ['@config.factory', '@database', '@entity_type.manager']
+    tags:
+      - { name: 'node_index'}
+  Drupal\taxonomy\NodeIndex: '@taxonomy.node_index'