diff --git a/core/modules/aggregator/aggregator.module b/core/modules/aggregator/aggregator.module
index 221b921..57fefc8 100644
--- a/core/modules/aggregator/aggregator.module
+++ b/core/modules/aggregator/aggregator.module
@@ -145,9 +145,7 @@ function aggregator_menu() {
   );
   $items['aggregator/categories'] = array(
     'title' => 'Categories',
-    'page callback' => 'aggregator_page_categories',
-    'access callback' => '_aggregator_has_categories',
-    'file' => 'aggregator.pages.inc',
+    'route_name' => 'aggregator_categories',
   );
   $items['aggregator/rss'] = array(
     'title' => 'RSS feed',
@@ -257,17 +255,6 @@ function _aggregator_category_title($category) {
 }
 
 /**
- * Access callback: Determines whether there are any aggregator categories.
- *
- * @return
- *   TRUE if there is at least one category and the user has access to them;
- *   FALSE otherwise.
- */
-function _aggregator_has_categories() {
-  return user_access('access news feeds') && (bool) db_query_range('SELECT 1 FROM {aggregator_category}', 0, 1)->fetchField();
-}
-
-/**
  * Implements hook_permission().
  */
 function aggregator_permission() {
diff --git a/core/modules/aggregator/aggregator.pages.inc b/core/modules/aggregator/aggregator.pages.inc
index 32509ce..76ec65d 100644
--- a/core/modules/aggregator/aggregator.pages.inc
+++ b/core/modules/aggregator/aggregator.pages.inc
@@ -359,41 +359,6 @@ function aggregator_page_sources() {
 }
 
 /**
- * Page callback: Displays all the categories used by the Aggregator module.
- *
- * @return string
- *   An HTML formatted string.
- *
- * @see aggregator_menu()
- */
-function aggregator_page_categories() {
-  $result = db_query('SELECT c.cid, c.title, c.description FROM {aggregator_category} c LEFT JOIN {aggregator_category_item} ci ON c.cid = ci.cid LEFT JOIN {aggregator_item} i ON ci.iid = i.iid GROUP BY c.cid, c.title, c.description');
-
-  $build = array(
-    '#type' => 'container',
-    '#attributes' => array('class' => array('aggregator-wrapper')),
-    '#sorted' => TRUE,
-  );
-  $aggregator_summary_items = config('aggregator.settings')->get('source.list_max');
-  foreach ($result as $category) {
-    $summary_items = array();
-    if ($aggregator_summary_items) {
-      if ($items = aggregator_load_feed_items('category', $category, $aggregator_summary_items)) {
-        $summary_items = entity_view_multiple($items, 'summary');
-      }
-    }
-    $category->url = url('aggregator/categories/' . $category->cid);
-    $build[$category->cid] = array(
-      '#theme' => 'aggregator_summary_items',
-      '#summary_items' => $summary_items,
-      '#source' => $category,
-    );
-  }
-
-  return $build;
-}
-
-/**
  * Page callback: Generates an RSS 0.92 feed of aggregator items or categories.
  *
  * @return string
diff --git a/core/modules/aggregator/aggregator.routing.yml b/core/modules/aggregator/aggregator.routing.yml
index a285eec..4d9ec93 100644
--- a/core/modules/aggregator/aggregator.routing.yml
+++ b/core/modules/aggregator/aggregator.routing.yml
@@ -53,3 +53,10 @@ aggregator_page_last:
     _controller: '\Drupal\aggregator\Routing\AggregatorController::pageLast'
   requirements:
     _permission: 'access news feeds'
+
+aggregator_categories:
+  pattern: '/aggregator/categories'
+  defaults:
+    _content: '\Drupal\aggregator\Routing\AggregatorController::categories'
+  requirements:
+    _access_aggregator_categories: 'TRUE'
diff --git a/core/modules/aggregator/aggregator.services.yml b/core/modules/aggregator/aggregator.services.yml
index 639ed2d..cf74241 100644
--- a/core/modules/aggregator/aggregator.services.yml
+++ b/core/modules/aggregator/aggregator.services.yml
@@ -8,3 +8,8 @@ services:
   plugin.manager.aggregator.processor:
     class: Drupal\aggregator\Plugin\AggregatorPluginManager
     arguments: [processor, '@container.namespaces']
+  access_check.aggregator.categories:
+    class: Drupal\aggregator\Access\CategoriesAccessCheck
+    arguments: ['@database']
+    tags:
+      - { name: access_check }
diff --git a/core/modules/aggregator/lib/Drupal/aggregator/Access/CategoriesAccessCheck.php b/core/modules/aggregator/lib/Drupal/aggregator/Access/CategoriesAccessCheck.php
new file mode 100644
index 0000000..576495e
--- /dev/null
+++ b/core/modules/aggregator/lib/Drupal/aggregator/Access/CategoriesAccessCheck.php
@@ -0,0 +1,53 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\aggregator\Access\CategoriesAccess.
+ */
+
+namespace Drupal\aggregator\Access;
+
+use Drupal\Core\Access\AccessCheckInterface;
+use Drupal\Core\Database\Connection;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Routing\Route;
+
+/**
+ * Provides an access check for aggregator categories routes.
+ */
+class CategoriesAccessCheck implements AccessCheckInterface {
+
+  /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $database;
+
+  /**
+   * Constructs a CategoriesAccessCheck object.
+   *
+   * @param \Drupal\Core\Database\Connection
+   *   The database connection.
+   */
+  public function __construct(Connection $database) {
+    $this->database = $database;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function applies(Route $route) {
+    return array_key_exists('_access_aggregator_categories', $route->getRequirements());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function access(Route $route, Request $request) {
+    // @todo Replace user_access() with a correctly injected and session-using
+    // alternative.
+    return user_access('access news feeds') && (bool) $this->database->queryRange('SELECT 1 FROM {aggregator_category}', 0, 1)->fetchField();
+  }
+
+}
diff --git a/core/modules/aggregator/lib/Drupal/aggregator/Routing/AggregatorController.php b/core/modules/aggregator/lib/Drupal/aggregator/Routing/AggregatorController.php
index abef5af..92240fb 100644
--- a/core/modules/aggregator/lib/Drupal/aggregator/Routing/AggregatorController.php
+++ b/core/modules/aggregator/lib/Drupal/aggregator/Routing/AggregatorController.php
@@ -9,7 +9,7 @@
 
 use Drupal\aggregator\FeedInterface;
 use Drupal\Core\Config\ConfigFactory;
-use Drupal\Core\ControllerInterface;
+use Drupal\Core\Controller\ControllerInterface;
 use Drupal\Core\Database\Connection;
 use Drupal\Core\Entity\EntityManager;
 use Drupal\Core\Extension\ModuleHandlerInterface;
@@ -210,6 +210,41 @@ public function adminOverview() {
   }
 
   /**
+   * Displays all the categories used by the Aggregator module.
+   *
+   * @return array
+   *   A render array.
+   */
+  public function categories() {
+    // @todo Refactor this once all controller conversions are complete.
+    $this->moduleHandler->loadInclude('aggregator', 'inc', 'aggregator.pages');
+
+    $result = $this->database->query('SELECT c.cid, c.title, c.description FROM {aggregator_category} c LEFT JOIN {aggregator_category_item} ci ON c.cid = ci.cid LEFT JOIN {aggregator_item} i ON ci.iid = i.iid GROUP BY c.cid, c.title, c.description');
+
+    $build = array(
+      '#type' => 'container',
+      '#attributes' => array('class' => array('aggregator-wrapper')),
+      '#sorted' => TRUE,
+    );
+    $aggregator_summary_items = $this->configFactory->get('aggregator.settings')->get('source.list_max');
+    foreach ($result as $category) {
+      $summary_items = array();
+      if ($aggregator_summary_items) {
+        if ($items = aggregator_load_feed_items('category', $category, $aggregator_summary_items)) {
+          $summary_items = $this->entityManager->getRenderController('aggregator_item')->viewMultiple($items, 'summary');
+        }
+      }
+      $category->url = url('aggregator/categories/' . $category->cid);
+      $build[$category->cid] = array(
+        '#theme' => 'aggregator_summary_items',
+        '#summary_items' => $summary_items,
+        '#source' => $category,
+      );
+    }
+    return $build;
+  }
+
+  /**
    * Displays the most recent items gathered from any feed.
    *
    * @return string
