diff --git a/core/modules/aggregator/aggregator.module b/core/modules/aggregator/aggregator.module
index 9755428..3588035 100644
--- a/core/modules/aggregator/aggregator.module
+++ b/core/modules/aggregator/aggregator.module
@@ -152,9 +152,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',
@@ -264,17 +262,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 6248012..9951b53 100644
--- a/core/modules/aggregator/aggregator.pages.inc
+++ b/core/modules/aggregator/aggregator.pages.inc
@@ -368,41 +368,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 d4f1f6c..b2d278e 100644
--- a/core/modules/aggregator/aggregator.routing.yml
+++ b/core/modules/aggregator/aggregator.routing.yml
@@ -32,3 +32,10 @@ aggregator_opml_add:
     _form: '\Drupal\aggregator\Form\OpmlFeedAdd'
   requirements:
     _permission: 'administer news feeds'
+
+aggregator_categories:
+  pattern: '/aggregator/categories'
+  defaults:
+    _controller: '\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 66f1fbd..7f0ac4b 100644
--- a/core/modules/aggregator/lib/Drupal/aggregator/Routing/AggregatorController.php
+++ b/core/modules/aggregator/lib/Drupal/aggregator/Routing/AggregatorController.php
@@ -7,9 +7,11 @@
 
 namespace Drupal\aggregator\Routing;
 
-use Symfony\Component\DependencyInjection\ContainerInterface;
 use Drupal\Core\ControllerInterface;
+use Drupal\Core\Config\ConfigFactory;
+use Drupal\Core\Database\Connection;
 use Drupal\Core\Entity\EntityManager;
+use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
  * Returns responses for aggregator module routes.
@@ -24,20 +26,44 @@ class AggregatorController implements ControllerInterface {
   protected $entityManager;
 
   /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $database;
+
+  /**
+   * The config factory.
+   *
+   * @var \Drupal\Core\Config\ConfigFactory
+   */
+  protected $configFactory;
+
+  /**
    * Constructs a \Drupal\aggregator\Routing\AggregatorController object.
    *
    * @param \Drupal\Core\Entity\EntityManager $entity_manager
    *   The Entity manager.
+   * @param \Drupal\Core\Database\Connection $database
+   *   The database connection.
+   * @param \Drupal\Core\Config\ConfigFactory $config_factory
+   *   The config factory.
    */
-  public function __construct(EntityManager $entity_manager) {
+  public function __construct(EntityManager $entity_manager, Connection $database, ConfigFactory $config_factory) {
     $this->entityManager = $entity_manager;
+    $this->database = $database;
+    $this->configFactory = $config_factory;
   }
 
   /**
    * {inheritdoc}
    */
   public static function create(ContainerInterface $container) {
-    return new static($container->get('plugin.manager.entity'));
+    return new static(
+      $container->get('plugin.manager.entity'),
+      $container->get('database'),
+      $container->get('config.factory')
+    );
   }
 
   /**
@@ -56,4 +82,41 @@ public function feedAdd() {
     return entity_get_form($feed);
   }
 
+  /**
+   * Displays all the categories used by the Aggregator module.
+   *
+   * @return array
+   *   A render array.
+   */
+  public function categories() {
+
+    // @todo remove this once all controller conversions are complete.
+    module_load_include('inc', 'aggregator', '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;
+  }
+
 }
