diff --git a/jsonapi.services.yml b/jsonapi.services.yml
index a9f0788..b52af4f 100644
--- a/jsonapi.services.yml
+++ b/jsonapi.services.yml
@@ -1,5 +1,26 @@
 services:
-  serializer.normalizer.htt_exception.jsonapi:
+  serializer.normalizer.sort.jsonapi:
+    class: Drupal\jsonapi\Normalizer\SortNormalizer
+    tags:
+      - { name: normalizer }
+  serializer.normalizer.offset_page.jsonapi:
+    class: Drupal\jsonapi\Normalizer\OffsetPageNormalizer
+    tags:
+      - { name: normalizer }
+  serializer.normalizer.entity_condition.jsonapi:
+    class: Drupal\jsonapi\Normalizer\EntityConditionNormalizer
+    tags:
+      - { name: normalizer }
+  serializer.normalizer.entity_condition_group.jsonapi:
+    class: Drupal\jsonapi\Normalizer\EntityConditionGroupNormalizer
+    tags:
+      - { name: normalizer }
+  serializer.normalizer.filter.jsonapi:
+    class: Drupal\jsonapi\Normalizer\FilterNormalizer
+    arguments: ['@serializer.normalizer.entity_condition.jsonapi', '@serializer.normalizer.entity_condition_group.jsonapi']
+    tags:
+      - { name: normalizer, priority: 1 }
+  serializer.normalizer.http_exception.jsonapi:
     class: Drupal\jsonapi\Normalizer\HttpExceptionNormalizer
     arguments: ['@current_user']
     tags:
@@ -69,12 +90,9 @@ services:
       - { name: route_enhancer }
   jsonapi.params.enhancer:
     class: Drupal\jsonapi\Routing\JsonApiParamEnhancer
-    arguments: ['@entity_field.manager']
+    arguments: ['@serializer.normalizer.filter.jsonapi', '@serializer.normalizer.sort.jsonapi', '@serializer.normalizer.offset_page.jsonapi']
     tags:
       - { name: route_enhancer }
-  jsonapi.query_builder:
-    class: Drupal\jsonapi\Query\QueryBuilder
-    arguments: ['@entity_type.manager', '@jsonapi.current_context', '@jsonapi.field_resolver']
   jsonapi.link_manager:
     class: Drupal\jsonapi\LinkManager\LinkManager
     arguments: ['@router.no_access_checks', '@url_generator']
diff --git a/src/Controller/EntityResource.php b/src/Controller/EntityResource.php
index 0a804e3..66e7fdc 100644
--- a/src/Controller/EntityResource.php
+++ b/src/Controller/EntityResource.php
@@ -18,13 +18,13 @@ use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
 use Drupal\jsonapi\Context\CurrentContext;
 use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
 use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
-use Drupal\jsonapi\Query\QueryBuilder;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\Query\Sort;
+use Drupal\jsonapi\Query\OffsetPage;
 use Drupal\jsonapi\Resource\EntityCollection;
 use Drupal\jsonapi\Resource\JsonApiDocumentTopLevel;
 use Drupal\jsonapi\ResourceResponse;
 use Drupal\jsonapi\ResourceType\ResourceType;
-use Drupal\jsonapi\Routing\Param\JsonApiParamBase;
-use Drupal\jsonapi\Routing\Param\OffsetPage;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -59,13 +59,6 @@ class EntityResource {
   protected $fieldManager;
 
   /**
-   * The query builder service.
-   *
-   * @var \Drupal\jsonapi\Query\QueryBuilder
-   */
-  protected $queryBuilder;
-
-  /**
    * The current context service.
    *
    * @var \Drupal\jsonapi\Context\CurrentContext
@@ -86,8 +79,6 @@ class EntityResource {
    *   The JSON API resource type.
    * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
    *   The entity type manager.
-   * @param \Drupal\jsonapi\Query\QueryBuilder $query_builder
-   *   The query builder.
    * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
    *   The entity type field manager.
    * @param \Drupal\jsonapi\Context\CurrentContext $current_context
@@ -95,10 +86,9 @@ class EntityResource {
    * @param \Drupal\Core\Field\FieldTypePluginManagerInterface $plugin_manager
    *   The plugin manager for fields.
    */
-  public function __construct(ResourceType $resource_type, EntityTypeManagerInterface $entity_type_manager, QueryBuilder $query_builder, EntityFieldManagerInterface $field_manager, CurrentContext $current_context, FieldTypePluginManagerInterface $plugin_manager) {
+  public function __construct(ResourceType $resource_type, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, CurrentContext $current_context, FieldTypePluginManagerInterface $plugin_manager) {
     $this->resourceType = $resource_type;
     $this->entityTypeManager = $entity_type_manager;
-    $this->queryBuilder = $query_builder;
     $this->fieldManager = $field_manager;
     $this->currentContext = $current_context;
     $this->pluginManager = $plugin_manager;
@@ -548,7 +538,7 @@ class EntityResource {
    *
    * @param string $entity_type_id
    *   The entity type for the entity query.
-   * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params
+   * @param array $params
    *   The parameters for the query.
    *
    * @return \Drupal\Core\Entity\Query\QueryInterface
@@ -556,8 +546,37 @@ class EntityResource {
    */
   protected function getCollectionQuery($entity_type_id, $params) {
     $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+    $entity_storage = $this->entityTypeManager->getStorage($entity_type_id);
+
+    $query = $entity_storage->getQuery();
+
+    // Ensure that access checking is performed on the query.
+    $query->accessCheck(TRUE);
+
+    // Compute and apply an entity query condition from the filter parameter.
+    if (isset($params[Filter::KEY_NAME]) && $filter = $params[Filter::KEY_NAME]) {
+      $query->condition($filter->queryCondition($query));
+    }
+
+    // Apply any sorts to the entity query.
+    if (isset($params[Sort::KEY_NAME]) && $sort = $params[Sort::KEY_NAME]) {
+      foreach ($sort->fields() as $field) {
+        $path = $field[Sort::PATH_KEY];
+        $direction = isset($field[Sort::DIRECTION_KEY]) ? $field[Sort::DIRECTION_KEY] : 'ASC';
+        $langcode = isset($field[Sort::LANGUAGE_KEY]) ? $field[Sort::LANGUAGE_KEY] : NULL;
+        $query->sort($path, $direction, $langcode);
+      }
+    }
 
-    $query = $this->queryBuilder->newQuery($entity_type, $params);
+    // Apply any pagination options to the query.
+    if (isset($params[OffsetPage::KEY_NAME])) {
+      $pagination = $params[OffsetPage::KEY_NAME];
+    }
+    else {
+      $pagination = new OffsetPage(OffsetPage::DEFAULT_OFFSET, OffsetPage::SIZE_MAX);
+    }
+    $query->range($pagination->offset(), $pagination->size() + 1);
+    $query->addMetaData('pager_size', (int) $pagination->size());
 
     // Limit this query to the bundle type for this resource.
     $bundle = $this->resourceType->getBundle();
@@ -575,7 +594,7 @@ class EntityResource {
    *
    * @param string $entity_type_id
    *   The entity type for the entity query.
-   * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params
+   * @param array $params
    *   The parameters for the query.
    *
    * @return \Drupal\Core\Entity\Query\QueryInterface
@@ -583,7 +602,7 @@ class EntityResource {
    */
   protected function getCollectionCountQuery($entity_type_id, $params) {
     // Override the pagination parameter to get all the available results.
-    $params[OffsetPage::KEY_NAME] = new JsonApiParamBase([]);
+    unset($params[OffsetPage::KEY_NAME]);
     return $this->getCollectionQuery($entity_type_id, $params);
   }
 
diff --git a/src/Controller/RequestHandler.php b/src/Controller/RequestHandler.php
index 7e30705..865a1a0 100644
--- a/src/Controller/RequestHandler.php
+++ b/src/Controller/RequestHandler.php
@@ -195,8 +195,6 @@ class RequestHandler implements ContainerAwareInterface, ContainerInjectionInter
     $resource_type_repository = $this->container->get('jsonapi.resource_type.repository');
     /* @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */
     $entity_type_manager = $this->container->get('entity_type.manager');
-    /* @var \Drupal\jsonapi\Query\QueryBuilder $query_builder */
-    $query_builder = $this->container->get('jsonapi.query_builder');
     /* @var \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager */
     $field_manager = $this->container->get('entity_field.manager');
     /* @var \Drupal\Core\Field\FieldTypePluginManagerInterface $plugin_manager */
@@ -206,7 +204,6 @@ class RequestHandler implements ContainerAwareInterface, ContainerInjectionInter
     $resource = new EntityResource(
       $resource_type_repository->get($route->getRequirement('_entity_type'), $route->getRequirement('_bundle')),
       $entity_type_manager,
-      $query_builder,
       $field_manager,
       $current_context,
       $plugin_manager,
diff --git a/src/LinkManager/LinkManager.php b/src/LinkManager/LinkManager.php
index 802e535..0502c2f 100644
--- a/src/LinkManager/LinkManager.php
+++ b/src/LinkManager/LinkManager.php
@@ -4,7 +4,7 @@ namespace Drupal\jsonapi\LinkManager;
 
 use Drupal\Core\Routing\UrlGeneratorInterface;
 use Drupal\jsonapi\ResourceType\ResourceType;
-use Drupal\jsonapi\Routing\Param\OffsetPage;
+use Drupal\jsonapi\Query\OffsetPage;
 use Symfony\Cmf\Component\Routing\RouteObjectInterface;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -107,14 +107,14 @@ class LinkManager {
   public function getPagerLinks(Request $request, array $link_context = []) {
     $params = $request->get('_json_api_params');
     if ($page_param = $params[OffsetPage::KEY_NAME]) {
-      /* @var \Drupal\jsonapi\Routing\Param\OffsetPage $page_param */
-      $offset = $page_param->getOffset();
-      $size = $page_param->getSize();
+      /* @var \Drupal\jsonapi\Query\OffsetPage $page_param */
+      $offset = $page_param->offset();
+      $size = $page_param->size();
     }
     else {
       // Apply the defaults.
-      $offset = 0;
-      $size = OffsetPage::$maxSize;
+      $offset = OffsetPage::DEFAULT_OFFSET;
+      $size = OffsetPage::SIZE_MAX;
     }
     if ($size <= 0) {
       throw new BadRequestHttpException(sprintf('The page size needs to be a positive integer.'));
diff --git a/src/Normalizer/EntityConditionGroupNormalizer.php b/src/Normalizer/EntityConditionGroupNormalizer.php
new file mode 100644
index 0000000..f0b5350
--- /dev/null
+++ b/src/Normalizer/EntityConditionGroupNormalizer.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\jsonapi\Query\EntityCondition;
+use Drupal\jsonapi\Query\EntityConditionGroup;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * The normalizer used for entity conditions.
+ */
+class EntityConditionGroupNormalizer implements DenormalizerInterface {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = EntityConditionGroup::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $formats = ['api_json'];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsDenormalization($data, $type, $format = null) {
+    return $type == $this->supportedInterfaceOrClass;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    return new EntityConditionGroup($data['conjunction'], $data['members']);
+  }
+
+}
diff --git a/src/Normalizer/EntityConditionNormalizer.php b/src/Normalizer/EntityConditionNormalizer.php
new file mode 100644
index 0000000..a3828f3
--- /dev/null
+++ b/src/Normalizer/EntityConditionNormalizer.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\jsonapi\Query\EntityCondition;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * The normalizer used for entity conditions.
+ */
+class EntityConditionNormalizer implements DenormalizerInterface {
+
+  /**
+   * The field key in the filter condition: filter[lorem][condition][<field>].
+   *
+   * @var string
+   */
+  const PATH_KEY = 'path';
+
+  /**
+   * The value key in the filter condition: filter[lorem][condition][<value>].
+   *
+   * @var string
+   */
+  const VALUE_KEY = 'value';
+
+  /**
+   * The operator key in the condition: filter[lorem][condition][<operator>].
+   *
+   * @var string
+   */
+  const OPERATOR_KEY = 'operator';
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = EntityCondition::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $formats = ['api_json'];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsDenormalization($data, $type, $format = null) {
+    return $type == $this->supportedInterfaceOrClass;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    $field = $data[static::PATH_KEY];
+    $value = $data[static::VALUE_KEY];
+    $operator = (isset($data[static::OPERATOR_KEY])) ? $data[static::OPERATOR_KEY] : NULL;
+    return new EntityCondition($field, $value, $operator);
+  }
+
+}
diff --git a/src/Normalizer/FilterNormalizer.php b/src/Normalizer/FilterNormalizer.php
new file mode 100644
index 0000000..f760714
--- /dev/null
+++ b/src/Normalizer/FilterNormalizer.php
@@ -0,0 +1,224 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\Normalizer\EntityConditionNormalizer;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * The normalizer used for JSON API filters.
+ */
+class FilterNormalizer implements DenormalizerInterface {
+
+  /**
+   * The key for the implicit root group.
+   */
+  const ROOT_ID = '@root';
+
+  /**
+   * Key in the filter[<key>] parameter for conditions.
+   *
+   * @var string
+   */
+  const CONDITION_KEY = 'condition';
+
+  /**
+   * Key in the filter[<key>] parameter for groups.
+   *
+   * @var string
+   */
+  const GROUP_KEY = 'group';
+
+  /**
+   * Key in the filter[<id>][<key>] parameter for group membership.
+   *
+   * @var string
+   */
+  const MEMBER_KEY = 'memberOf';
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = Filter::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $formats = ['api_json'];
+
+  /**
+   * The entity condition denormalizer.
+   *
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $conditionDenormalizer;
+
+  /**
+   * The entity condition group denormalizer.
+   *
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $groupDenormalizer;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(DenormalizerInterface $condition_denormalizer, DenormalizerInterface $group_denormalizer) {
+    $this->conditionDenormalizer = $condition_denormalizer;
+    $this->groupDenormalizer = $group_denormalizer;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsDenormalization($data, $type, $format = null) {
+    return $type == $this->supportedInterfaceOrClass;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    $expanded = $this->expand($data);
+    $denormalized = $this->denormalizeItems($expanded);
+    return new Filter($denormalized);
+  }
+
+  /**
+   * Expands any filter parameters using shorthand notation.
+   *
+   * @param array $original
+   *   The unexpanded filter data.
+   *
+   * @return array
+   *   The expanded filter data.
+   */
+  protected function expand(array $original) {
+    $expanded = [];
+    $other = [];
+    foreach ($original as $key => $item) {
+      // Throw an exception if the query uses the reserved filter id for the
+      // root group.
+      if ($key == static::ROOT_ID) {
+        $msg = sprintf("'%s' is a reserved filter id.", static::ROOT_ID);
+        throw new \UnexpectedValueException($msg);
+      }
+
+      // Add a memberOf key to all items.
+      if (isset($item[static::CONDITION_KEY][static::MEMBER_KEY])) {
+        $item[static::MEMBER_KEY] = $item[static::CONDITION_KEY][static::MEMBER_KEY];
+        unset($item[static::CONDITION_KEY][static::MEMBER_KEY]);
+      }
+      else if (isset($item[static::GROUP_KEY][static::MEMBER_KEY])) {
+        $item[static::MEMBER_KEY] = $item[static::GROUP_KEY][static::MEMBER_KEY];
+        unset($item[static::GROUP_KEY][static::MEMBER_KEY]);
+      }
+      else {
+        $item[static::MEMBER_KEY] = static::ROOT_ID;
+      }
+
+      // Add the filter id to all items.
+      $item['id'] = $key;
+
+      // Expands shorthand filters.
+      $expanded[$key] = $this->expandItem($key, $item);
+    }
+
+    return $expanded;
+  }
+
+  /**
+   * Expands a filter item in case a shortcut was used.
+   *
+   * Possible cases for the conditions:
+   *   1. filter[uuid][value]=1234.
+   *   2. filter[0][condition][field]=uuid&filter[0][condition][value]=1234.
+   *   3. filter[uuid][condition][value]=1234.
+   *   4. filter[uuid][value]=1234&filter[uuid][group]=my_group.
+   *
+   * @param string $filter_index
+   *   The index.
+   * @param array $filter_item
+   *   The raw filter item.
+   *
+   * @return array
+   *   The expanded filter item.
+   */
+  protected function expandItem($filter_index, array $filter_item) {
+    if (isset($filter_item[EntityConditionNormalizer::VALUE_KEY])) {
+      if (!isset($filter_item[EntityConditionNormalizer::PATH_KEY])) {
+        $filter_item[EntityConditionNormalizer::PATH_KEY] = $filter_index;
+      }
+
+      $filter_item = [
+        static::CONDITION_KEY => $filter_item,
+        static::MEMBER_KEY => $filter_item[static::MEMBER_KEY],
+      ];
+
+      if (!isset($filter_item[static::CONDITION_KEY][EntityConditionNormalizer::OPERATOR_KEY])) {
+        $filter_item[static::CONDITION_KEY][EntityConditionNormalizer::OPERATOR_KEY] = '=';
+      }
+    }
+
+    return $filter_item;
+  }
+
+  /**
+   * Denormalizes the given filter items into a single EntityConditionGroup.
+   *
+   * @param array $items
+   *   The normalized entity conditions and groups.
+   *
+   * @return \Drupal\jsonapi\Query\EntityConditionGroup
+   *   A root group containing all the denormalized conditions and groups.
+   */
+  protected function denormalizeItems(array $items) {
+    $root = [
+      'id' => static::ROOT_ID,
+      static::GROUP_KEY => ['conjunction' => 'AND'],
+    ];
+    return $this->buildTree($root, $items);
+  }
+
+  /**
+   * Organizes the flat, normalized filter items into a tree structure.
+   *
+   * @param array $items
+   *   The normalized entity conditions and groups.
+   *
+   * @return array
+   *   A structured multidimensional array.
+   */
+  protected function buildTree(array $root, $items) {
+    $id = $root['id'];
+
+    // Recursively build a tree of denormalized conditions and condition groups.
+    $members = [];
+    foreach ($items as $item) {
+      if ($item[static::MEMBER_KEY] == $id) {
+        if (isset($item[static::GROUP_KEY])) {
+          array_push($members, $this->buildTree($item, $items));
+        }
+        else if (isset($item[static::CONDITION_KEY])) {
+          $condition = $this->conditionDenormalizer->denormalize(
+            $item[static::CONDITION_KEY],
+            EntityCondition::class
+          );
+          array_push($members, $condition);
+        }
+      }
+    }
+
+    $root[static::GROUP_KEY]['members'] = $members;
+
+    // Denormalize the root into a condition group.
+    return $this->groupDenormalizer->denormalize(
+      $root[static::GROUP_KEY],
+      EntityConditionGroup::class
+    );
+  }
+
+}
diff --git a/src/Normalizer/OffsetPageNormalizer.php b/src/Normalizer/OffsetPageNormalizer.php
new file mode 100644
index 0000000..68617cf
--- /dev/null
+++ b/src/Normalizer/OffsetPageNormalizer.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\jsonapi\Query\OffsetPage;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * The normalizer used for JSON API pagination.
+ */
+class OffsetPageNormalizer implements DenormalizerInterface {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = OffsetPage::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsDenormalization($data, $type, $format = null) {
+    return $type == $this->supportedInterfaceOrClass;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    $expanded = $this->expand($data);
+    return new OffsetPage($expanded[OffsetPage::OFFSET_KEY], $expanded[OffsetPage::SIZE_KEY]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function expand($data) {
+    if (!is_array($data)) {
+      throw new BadRequestHttpException('The page parameter needs to be an array.');
+    }
+
+    $expanded =  $data + [
+      OffsetPage::OFFSET_KEY => OffsetPage::DEFAULT_OFFSET,
+      OffsetPage::SIZE_KEY => OffsetPage::SIZE_MAX,
+    ];
+
+    if ($expanded[OffsetPage::SIZE_KEY] > OffsetPage::SIZE_MAX) {
+      $expanded[OffsetPage::SIZE_KEY] = OffsetPage::SIZE_MAX;
+    }
+
+    return $expanded;
+  }
+
+}
diff --git a/src/Routing/Param/Sort.php b/src/Normalizer/SortNormalizer.php
similarity index 64%
rename from src/Routing/Param/Sort.php
rename to src/Normalizer/SortNormalizer.php
index eee57d9..0cedea1 100644
--- a/src/Routing/Param/Sort.php
+++ b/src/Normalizer/SortNormalizer.php
@@ -1,52 +1,42 @@
 <?php
 
-namespace Drupal\jsonapi\Routing\Param;
+namespace Drupal\jsonapi\Normalizer;
 
+use Drupal\jsonapi\Query\Sort;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
 use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
 
 /**
- * @internal
+ * The normalizer used for JSON API sorts.
  */
-class Sort extends JsonApiParamBase {
+class SortNormalizer implements DenormalizerInterface {
 
   /**
-   * {@inheritdoc}
-   */
-  const KEY_NAME = 'sort';
-
-  /**
-   * The field key in the sort parameter: sort[lorem][<field>].
-   *
-   * @var string
-   */
-  const FIELD_KEY = 'path';
-
-  /**
-   * The direction key in the sort parameter: sort[lorem][<direction>].
+   * The interface or class that this Normalizer supports.
    *
    * @var string
    */
-  const DIRECTION_KEY = 'direction';
+  protected $supportedInterfaceOrClass = Sort::class;
 
   /**
-   * The langcode key in the sort parameter: sort[lorem][<langcode>].
-   *
-   * @var string
+   * {@inheritdoc}
    */
-  const LANGUAGE_KEY = 'langcode';
+  public function supportsDenormalization($data, $type, $format = null) {
+    return $type == $this->supportedInterfaceOrClass;
+  }
 
   /**
-   * The conjunction key in the condition: filter[lorem][group][<conjunction>].
-   *
-   * @var string
+   * {@inheritdoc}
    */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    $expanded = $this->expand($data);
+    return new Sort($expanded);
+  }
 
   /**
    * {@inheritdoc}
    */
-  protected function expand() {
-    $sort = $this->original;
-
+  protected function expand($sort) {
     if (empty($sort)) {
       throw new BadRequestHttpException('You need to provide a value for the sort parameter.');
     }
@@ -79,12 +69,12 @@ class Sort extends JsonApiParamBase {
       $sort = [];
 
       if ($field[0] == '-') {
-        $sort[static::DIRECTION_KEY] = 'DESC';
-        $sort[static::FIELD_KEY] = substr($field, 1);
+        $sort[Sort::DIRECTION_KEY] = 'DESC';
+        $sort[Sort::PATH_KEY] = substr($field, 1);
       }
       else {
-        $sort[static::DIRECTION_KEY] = 'ASC';
-        $sort[static::FIELD_KEY] = $field;
+        $sort[Sort::DIRECTION_KEY] = 'ASC';
+        $sort[Sort::PATH_KEY] = $field;
       }
 
       return $sort;
@@ -104,18 +94,18 @@ class Sort extends JsonApiParamBase {
    */
   protected function expandItem($sort_index, array $sort_item) {
     $defaults = [
-      static::DIRECTION_KEY => 'ASC',
-      static::LANGUAGE_KEY => NULL,
+      Sort::DIRECTION_KEY => 'ASC',
+      Sort::LANGUAGE_KEY => NULL,
     ];
 
-    if (!isset($sort_item[static::FIELD_KEY])) {
+    if (!isset($sort_item[Sort::PATH_KEY])) {
       throw new BadRequestHttpException('You need to provide a field name for the sort parameter.');
     }
 
     $expected_keys = [
-      static::FIELD_KEY,
-      static::DIRECTION_KEY,
-      static::LANGUAGE_KEY,
+      Sort::PATH_KEY,
+      Sort::DIRECTION_KEY,
+      Sort::LANGUAGE_KEY,
     ];
 
     $expanded = array_merge($defaults, $sort_item);
diff --git a/src/Query/ConditionOption.php b/src/Query/ConditionOption.php
deleted file mode 100644
index 05e2d32..0000000
--- a/src/Query/ConditionOption.php
+++ /dev/null
@@ -1,101 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Query;
-
-/**
- * A ConditionOption represents an option which can be applied to a query.
- *
- * @internal
- */
-class ConditionOption implements QueryOptionInterface {
-
-  /**
-   * A unique key.
-   *
-   * @var string
-   */
-  protected $id;
-
-  /**
-   * A unique key representing the intended parent of this option.
-   *
-   * @var string|null
-   */
-  protected $parentId;
-
-  /**
-   * String representation of the entity field in to be checked.
-   *
-   * @var string
-   */
-  protected $field;
-
-  /**
-   * Value of the condition for the given field.
-   *
-   * @var string|string[]
-   */
-  protected $value;
-
-  /**
-   * Conditional operator with which to compare values.
-   *
-   * @var string
-   */
-  protected $operator;
-
-  /**
-   * The langcode of the field to check.
-   *
-   * @var string
-   */
-  protected $langcode;
-
-  /**
-   * Constructs a new ConditionOption.
-   *
-   * @param string $id
-   *   A unique string identifier for the option.
-   * @param string|\Drupal\jsonapi\Query\GroupOption $field
-   *   Either a field name or a GroupOption.
-   * @param mixed $value
-   *   Value for comparison.
-   * @param string $operator
-   *   Boolean operator.
-   * @param string $langcode
-   *   Language of entity to compare against.
-   */
-  public function __construct($id, $field, $value = NULL, $operator = NULL, $langcode = NULL, $parent_id = NULL) {
-    $this->id = $id;
-    $this->field = $field;
-    $this->value = $value;
-    $this->operator = $operator;
-    $this->langcode = $langcode;
-    $this->parentId = $parent_id;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function id() {
-    return $this->id;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function apply($query) {
-    return $query->condition($this->field, $this->value, $this->operator, $this->langcode);
-  }
-
-  /**
-   * Returns the id of this option's parent.
-   *
-   * @return string|null
-   *   Either the id of its parent or NULL.
-   */
-  public function parentId() {
-    return $this->parentId;
-  }
-
-}
diff --git a/src/Query/EntityCondition.php b/src/Query/EntityCondition.php
new file mode 100644
index 0000000..8ceb5b0
--- /dev/null
+++ b/src/Query/EntityCondition.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+class EntityCondition {
+
+  /**
+   * The allowed condition operators.
+   *
+   * @var string[]
+   */
+  protected static $allowedOperators = [
+    '=', '<>',
+    '>', '>=', '<', '<=',
+    'STARTS_WITH', 'CONTAINS', 'ENDS_WITH',
+    'IN', 'NOT IN',
+    'BETWEEN', 'NOT BETWEEN',
+  ];
+
+  /**
+   * The field to be evaluated.
+   *
+   * @var string
+   */
+  protected $field;
+
+  /**
+   * The condition operator.
+   *
+   * @var string
+   */
+  protected $operator;
+
+  /**
+   * The value against which the field should be evaluated.
+   *
+   * @var mixed
+   */
+  protected $value;
+
+  /**
+   * Constructs a new EntityCondition object.
+   */
+  public function __construct($field, $value, $operator = NULL) {
+    if (!is_null($operator) && !in_array($operator, static::$allowedOperators)) {
+      throw new \InvalidArgumentException("The '{$operator}' operator is not allowed.");
+    }
+    $this->field = $field;
+    $this->value = $value;
+    $this->operator = ($operator) ? $operator : '=';
+  }
+
+  /**
+   * The field to be evaluated.
+   *
+   * @return string
+   */
+  public function field() {
+    return $this->field;
+  }
+
+  /**
+   * The comporison operator to use for the evaluation.
+   *
+   * Can be '=', '<', '>', '>=', '<=', 'BETWEEN', 'NOT BETWEEN', 'IN', 'NOT IN',
+   * 'IS NULL', 'IS NOT NULL', 'LIKE', 'NOT LIKE', 'EXISTS', 'NOT EXISTS'
+   *
+   * @return string
+   */
+  public function operator() {
+    return $this->operator;
+  }
+
+  /**
+   * The value against which the condition should be evaluated.
+   *
+   * @return mixed
+   */
+  public function value() {
+    return $this->value;
+  }
+
+}
diff --git a/src/Query/EntityConditionGroup.php b/src/Query/EntityConditionGroup.php
new file mode 100644
index 0000000..e3c89af
--- /dev/null
+++ b/src/Query/EntityConditionGroup.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+class EntityConditionGroup {
+
+  /**
+   * The AND conjunction value.
+   */
+  protected static $allowedConjunctions = ['AND', 'OR'];
+
+  /**
+   * The conjunction.
+   *
+   * @var string
+   */
+  protected $conjunction;
+
+  /**
+   * The members of the condition group.
+   *
+   * @var \Drupal\jsonapi\Query\EntityCondition[]
+   */
+  protected $members;
+
+  /**
+   * Constructs a new condition group object.
+   *
+   * @param string $conjunction
+   *   The group conjunction to use.
+   * @param array $members
+   *   (optional) The group conjunction to use.
+   */
+  public function __construct($conjunction, $members = []) {
+    if (!in_array($conjunction, self::$allowedConjunctions)) {
+      throw new \InvalidArgumentException('Allowed conjunctions: AND, OR.');
+    }
+    $this->conjunction = $conjunction;
+    $this->members = $members;
+  }
+
+  /**
+   * The condition group conjunction. 
+   *
+   * @return string
+   */
+  public function conjunction() {
+    return $this->conjunction;
+  }
+
+  /**
+   * The members which belong to the the condition group.
+   *
+   * @return \Drupal\jsonapi\Query\EntityCondition[]
+   */
+  public function members() {
+    return $this->members;
+  }
+
+}
diff --git a/src/Query/Filter.php b/src/Query/Filter.php
new file mode 100644
index 0000000..ff70ff5
--- /dev/null
+++ b/src/Query/Filter.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+use Drupal\Core\Entity\Query\QueryInterface;
+use Drupal\jsonapi\Query\EntityCondition;
+use Drupal\jsonapi\Query\EntityConditionGroup;
+
+class Filter {
+
+  /**
+   * The JSON API filter key name.
+   *
+   * @var string
+   */
+  const KEY_NAME = 'filter';
+
+  /**
+   * The root condition group.
+   *
+   * @var string
+   */
+  protected $root;
+
+  /**
+   * Constructs a new Filter object.
+   *
+   * @param \Drupal\jsonapi\Query\EntityConditionGroup $root
+   *   An entity condition group which can be applied to an entity query.
+   */
+  public function __construct(EntityConditionGroup $root) {
+    $this->root = $root;
+  }
+
+  /**
+   * Gets the root condition group.
+   */
+  public function root() {
+    return $this->root;
+  }
+
+  /**
+   * Applies the root condition to the given query.
+   *
+   * @param \Drupal\Entity\Query\QueryInterface $query
+   *   The query for which the condition should be constructed.
+   *
+   * @return \Drupal\Entity\Query\ConditionInterface
+   *   The compiled entity query condition.
+   */
+  public function queryCondition(QueryInterface $query) {
+    $condition = $this->buildGroup($query, $this->root());
+    return $condition;
+  }
+
+  /**
+   * Applies the root condition to the given query.
+   *
+   * @param \Drupal\Entity\Query\QueryInterface $query
+   *   The query to which the filter should be applied.
+   *
+   * @return \Drupal\Entity\Query\QueryInterface
+   *   The query with the filter applied.
+   */
+  protected function buildGroup(QueryInterface $query, EntityConditionGroup $condition_group) {
+    // Create a condition group using the original query.
+    switch ($condition_group->conjunction()) {
+      case 'AND':
+        $group = $query->andConditionGroup();
+        break;
+      case 'OR':
+        $group = $query->orConditionGroup();
+        break;
+    }
+
+    // Get all children of the group.
+    $members = $condition_group->members();
+
+    foreach ($members as $member) {
+      // If the child is simply a condition, add it to the new group.
+      if ($member instanceof EntityCondition) {
+        $group->condition($member->field(), $member->value(), $member->operator());
+      }
+      // If the child is a group, then recursively construct a sub group.
+      else if ($member instanceof EntityConditionGroup) {
+        // Add the subgroup to this new group.
+        $subgroup = $this->buildGroup($query, $member);
+        $group->condition($subgroup);
+      }
+    }
+
+    // Return the constructed group so that it can be added to the query.
+    return $group;
+  }
+
+}
diff --git a/src/Query/GroupOption.php b/src/Query/GroupOption.php
deleted file mode 100644
index d51a20c..0000000
--- a/src/Query/GroupOption.php
+++ /dev/null
@@ -1,153 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Query;
-
-/**
- * A GroupOption can group other options before applying them to a query.
- *
- * @internal
- */
-class GroupOption implements QueryOptionInterface, QueryOptionTreeItemInterface {
-
-  /**
-   * A unique key.
-   *
-   * @var string
-   */
-  protected $id;
-
-  /**
-   * A unique key representing a parent condition group.
-   *
-   * @var string
-   */
-  protected $parentGroup;
-
-  /**
-   * An array of QueryOptions.
-   *
-   * @var \Drupal\jsonapi\Query\QueryOptionInterface[]
-   */
-  protected $childOptions;
-
-  /**
-   * An array of GroupOptions.
-   *
-   * @var \Drupal\jsonapi\Query\GroupOption[]
-   */
-  protected $childGroups;
-
-  /**
-   * Conjunction of the groups conditions.
-   *
-   * @var string
-   */
-  protected $conjunction;
-
-  /**
-   * Constructs a new GroupOption.
-   *
-   * @param string $id
-   *   A unique string identifier for the option.
-   * @param string $conjunction
-   *   Conjunction of the groups conditions.
-   * @param string $parent_group
-   *   A unique key representing a parent condition group.
-   */
-  public function __construct($id, $conjunction = 'AND', $parent_group = NULL) {
-    $this->id = $id;
-    $this->conjunction = $conjunction;
-    $this->parentGroup = $parent_group;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function id() {
-    return $this->id;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function parentId() {
-    return $this->parentGroup;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function insert($target_id, QueryOptionInterface $option) {
-    $find_proper_id = function ($child_id, $group_option) use ($target_id) {
-      if ($child_id) {
-        return $child_id;
-      };
-      return $group_option->hasChild($target_id) ?
-        $group_option->id() :
-        NULL;
-    };
-
-    if ($this->id() == $target_id) {
-      $prop = $option instanceof GroupOption ? 'childGroups' : 'childOptions';
-      $this->{$prop}[$option->id()] = $option;
-      return TRUE;
-    }
-    elseif ($proper_child = array_reduce($this->childGroups, $find_proper_id, NULL)) {
-      return $this->childGroups[$proper_child]->insert($target_id, $option);
-    }
-
-    return FALSE;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function apply($query) {
-    switch ($this->conjunction) {
-      case 'OR':
-        $group = $query->orConditionGroup();
-        break;
-
-      case 'AND':
-      default:
-        $group = $query->andConditionGroup();
-        break;
-    }
-
-    if (!empty($this->childOptions)) {
-      $group = array_reduce($this->childOptions, function ($group, $child) {
-        return $child->apply($group);
-      }, $group);
-    }
-
-    if (!empty($this->childGroups)) {
-      $group = array_reduce($this->childGroups, function ($group, $child) {
-        return $child->apply($group);
-      }, $group);
-    }
-
-    return $query->condition($group);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function hasChild($id) {
-    // Return FALSE if this node has no child.
-    if (!isset($this->childOptions) || empty($this->childOptions)) {
-      return FALSE;
-    }
-
-    // If any of the options have the specified id, return TRUE.
-    if (in_array($id, array_keys($this->childOptions))) {
-      return TRUE;
-    }
-
-    // If any child GroupOptions or their children have the id return TRUE.
-    return array_reduce($this->groupOptions, function ($has_child, $group) use ($id) {
-      // If we already know that we have the child, skip evaluation and return.
-      return $has_child || $group->id() == $id || $group->hasChild($id);
-    }, FALSE);
-  }
-
-}
diff --git a/src/Query/OffsetPage.php b/src/Query/OffsetPage.php
new file mode 100644
index 0000000..97a2e2f
--- /dev/null
+++ b/src/Query/OffsetPage.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * @internal
+ */
+class OffsetPage {
+
+  /**
+   * The JSON API pagination key name.
+   *
+   * @var string
+   */
+  const KEY_NAME = 'page';
+
+  /**
+   * The offset key in the page parameter: page[offset].
+   *
+   * @var string
+   */
+  const OFFSET_KEY = 'offset';
+
+  /**
+   * The size key in the page parameter: page[limit].
+   *
+   * @var string
+   */
+  const SIZE_KEY = 'limit';
+
+  /**
+   * Default offset.
+   *
+   * @var int
+   */
+  const DEFAULT_OFFSET = 0;
+
+  /**
+   * Max size.
+   *
+   * @var int
+   */
+  const SIZE_MAX = 50;
+
+  /**
+   * The offset for the query.
+   *
+   * @var int
+   */
+  protected $offset;
+
+  /**
+   * The size of the query.
+   *
+   * @var int
+   */
+  protected $size;
+
+  /**
+   * Instantiates an OffsetPage object.
+   *
+   * @param int $offset
+   *   The query offset.
+   * @param int $size
+   *   The query size limit
+   */
+  public function __construct($offset, $size) {
+    $this->offset = $offset;
+    $this->size = $size;
+  }
+
+  /**
+   * Returns the current offset.
+   *
+   * @return int
+   */
+  public function offset() {
+    return $this->offset;
+  }
+
+  /**
+   * Returns the page size.
+   *
+   * @return int
+   */
+  public function size() {
+    return $this->size;
+  }
+
+}
diff --git a/src/Query/OffsetPagerOption.php b/src/Query/OffsetPagerOption.php
deleted file mode 100644
index 9e08cf3..0000000
--- a/src/Query/OffsetPagerOption.php
+++ /dev/null
@@ -1,57 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Query;
-
-/**
- * @internal
- */
-class OffsetPagerOption implements QueryOptionInterface {
-
-  /**
-   * The size.
-   *
-   * @var int
-   */
-  protected $size;
-
-  /**
-   * The offset.
-   *
-   * @var int
-   */
-  protected $offset;
-
-  /**
-   * Creates a PagerOption object.
-   *
-   * @param int $size
-   *   The maximum number of items to return.
-   * @param int $offset
-   *   The starting element.
-   */
-  public function __construct($size, $offset = 0) {
-    $this->size = $size;
-    $this->offset = $offset ?: 0;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function id() {
-    return 'offset_pager';
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function apply($query) {
-    if (isset($this->offset) && isset($this->size)) {
-      // Request one extra entity to know if there is a next page.
-      $query->range($this->offset, $this->size + 1);
-      $query->addMetaData('pager_size', (int) $this->size);
-    }
-
-    return $query;
-  }
-
-}
diff --git a/src/Query/QueryBuilder.php b/src/Query/QueryBuilder.php
deleted file mode 100644
index ef39549..0000000
--- a/src/Query/QueryBuilder.php
+++ /dev/null
@@ -1,362 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Query;
-
-use Drupal\Core\Entity\EntityTypeInterface;
-use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Drupal\jsonapi\Routing\Param\OffsetPage;
-use Drupal\jsonapi\Routing\Param\Filter;
-use Drupal\jsonapi\Routing\Param\JsonApiParamInterface;
-use Drupal\jsonapi\Context\CurrentContext;
-use Drupal\jsonapi\Context\FieldResolver;
-use Drupal\jsonapi\Routing\Param\Sort;
-use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
-
-/**
- * @internal
- */
-class QueryBuilder {
-
-  /**
-   * The entity type object that should be used for the query.
-   */
-  protected $entityType;
-
-  /**
-   * The options to build with which to build a query.
-   */
-  protected $options = [];
-
-  /**
-   * The entity type manager.
-   *
-   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
-   */
-  protected $entityTypeManager;
-
-  /**
-   * The JSON API current context service.
-   *
-   * @var \Drupal\jsonapi\Context\CurrentContext
-   */
-  protected $currentContext;
-
-  /**
-   * The field resolver service.
-   *
-   * @var \Drupal\jsonapi\Context\FieldResolver
-   */
-  protected $fieldResolver;
-
-  /**
-   * Contructs a new QueryBuilder object.
-   *
-   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
-   *   An instance of a QueryFactory.
-   * @param \Drupal\jsonapi\Context\CurrentContext $current_context
-   *   An instance of the current context service.
-   * @param \Drupal\jsonapi\Context\FieldResolver $field_resolver
-   *   The field resolver service.
-   */
-  public function __construct(EntityTypeManagerInterface $entity_type_manager, CurrentContext $current_context, FieldResolver $field_resolver) {
-    $this->entityTypeManager = $entity_type_manager;
-    $this->currentContext = $current_context;
-    $this->fieldResolver = $field_resolver;
-  }
-
-  /**
-   * Creates a new Entity Query.
-   *
-   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
-   *   The entity type for which to create a query.
-   * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params
-   *   The JSON API parameters.
-   *
-   * @return \Drupal\Core\Entity\Query\QueryInterface
-   *   The new query.
-   */
-  public function newQuery(EntityTypeInterface $entity_type, array $params = []) {
-    $this->entityType = $entity_type;
-
-    $this->configureFromContext($params);
-
-    $query = $this->entityTypeManager
-      ->getStorage($this->entityType->id())
-      ->getQuery()
-      ->accessCheck(TRUE);
-
-    // This applies each option from the option tree to the query before
-    // returning it.
-    $applied_query = array_reduce($this->options, function ($query, $option) {
-      /* @var \Drupal\jsonapi\Query\QueryOptionInterface $option */
-      return $option->apply($query);
-    }, $query);
-
-    return $applied_query ? $applied_query : $query;
-  }
-
-  /**
-   * Configure the query from the current context and the provided parameters.
-   *
-   * To avoid using the global context so much use the passed in parameters
-   * over the ones in the current context.
-   *
-   * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params
-   *   The JSON API parameters.
-   */
-  protected function configureFromContext(array $params = []) {
-    // TODO: Explore the possibility to turn JsonApiParam into a plugin type.
-    $param_keys = [Filter::KEY_NAME, Sort::KEY_NAME];
-    foreach ($param_keys as $param_key) {
-      if (isset($params[$param_key])) {
-        $this->configureParam($param_key, $params[$param_key]);
-      }
-      elseif ($param = $this->currentContext->getJsonApiParameter($param_key)) {
-        $this->configureParam($param_key, $param);
-      }
-    }
-    // We always add a default pagination parameter.
-    $pager = isset($params[OffsetPage::KEY_NAME]) ?
-      $params[OffsetPage::KEY_NAME] :
-      new OffsetPage([]);
-    $this->configureParam(OffsetPage::KEY_NAME, $pager);
-  }
-
-  /**
-   * Configure a parameter based on the type parameter type.
-   *
-   * @param string $type
-   *   The parameter type.
-   * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param
-   *   The parameter to configure.
-   */
-  protected function configureParam($type, JsonApiParamInterface $param) {
-    switch ($type) {
-      case Filter::KEY_NAME:
-        $this->configureFilter($param);
-        break;
-
-      case Sort::KEY_NAME:
-        $this->configureSort($param);
-        break;
-
-      case OffsetPage::KEY_NAME:
-        $this->configurePager($param);
-        break;
-    }
-  }
-
-  /**
-   * Configures the query builder from a Filter parameter.
-   *
-   * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param
-   *   A Filter parameter from which to configure this query builder.
-   *
-   * @todo The nested closures passing parameters by reference may not be ideal.
-   */
-  protected function configureFilter(JsonApiParamInterface $param) {
-    $extracted = [];
-
-    foreach ($param->get() as $filter_index => $filter) {
-      foreach ($filter as $filter_type => $properties) {
-        switch ($filter_type) {
-          case Filter::CONDITION_KEY:
-            $extracted[] = $this->newCondtionOption($filter_index, $properties);
-            break;
-
-          case Filter::GROUP_KEY:
-            $extracted[] = $this->newGroupOption($filter_index, $properties);
-            break;
-
-          default:
-            throw new BadRequestHttpException(
-              sprintf('Invalid syntax in the filter parameter: %s.', $filter_index)
-            );
-        };
-      }
-    }
-
-    $this->buildTree($extracted);
-  }
-
-  /**
-   * Configures the query builder from a Sort parameter.
-   *
-   * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param
-   *   A Sort parameter from which to configure this query builder.
-   */
-  protected function configureSort(JsonApiParamInterface $param) {
-    $extracted = [];
-    foreach ($param->get() as $sort_index => $sort) {
-      $extracted[] = $this->newSortOption(sprintf('sort_%s', $sort_index), $sort);
-    }
-
-    $this->buildTree($extracted);
-  }
-
-  /**
-   * Configures the query builder from a Pager parameter.
-   *
-   * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param
-   *   A pager parameter from which to configure this query builder.
-   */
-  protected function configurePager(JsonApiParamInterface $param) {
-    $this->buildTree([$this->newPagerOption($param->get())]);
-  }
-
-  /**
-   * Returns a new ConditionOption.
-   *
-   * @param string $condition_id
-   *   A unique id for the option.
-   * @param array $properties
-   *   The condition properties.
-   *
-   * @return \Drupal\jsonapi\Query\ConditionOption
-   *   The condition object.
-   */
-  protected function newCondtionOption($condition_id, array $properties) {
-    $langcode_key = $this->getLangcodeKey();
-    $langcode = isset($properties[$langcode_key]) ? $properties[$langcode_key] : NULL;
-    $membership = isset($properties[Filter::MEMBER_KEY]) ? $properties[Filter::MEMBER_KEY] : NULL;
-    $field = isset($properties[Filter::PATH_KEY]) ? $properties[Filter::PATH_KEY] : NULL;
-    $value = isset($properties[Filter::VALUE_KEY]) ? $properties[Filter::VALUE_KEY] : NULL;
-    $operator = isset($properties[Filter::OPERATOR_KEY]) ? $properties[Filter::OPERATOR_KEY] : NULL;
-    return new ConditionOption(
-      $condition_id,
-      $this->fieldResolver->resolveInternal($field),
-      $value,
-      $operator,
-      $langcode,
-      $membership
-    );
-  }
-
-  /**
-   * Returns a new GroupOption.
-   *
-   * @param string $identifier
-   *   A unique id for the option.
-   * @param array $properties
-   *   The group properties.
-   *
-   * @return \Drupal\jsonapi\Query\GroupOption
-   *   The group object.
-   */
-  protected function newGroupOption($identifier, array $properties) {
-    $parent_group = isset($properties[Filter::MEMBER_KEY]) ? $properties[Filter::MEMBER_KEY] : NULL;
-    $conjunction = isset($properties[Filter::CONJUNCTION_KEY]) ? $properties[Filter::CONJUNCTION_KEY] : NULL;
-    return new GroupOption($identifier, $conjunction, $parent_group);
-  }
-
-  /**
-   * Returns a new SortOption.
-   *
-   * @param string $identifier
-   *   A unique id for the option.
-   * @param array $properties
-   *   The sort properties.
-   *
-   * @return \Drupal\jsonapi\Query\SortOption
-   *   The sort object.
-   */
-  protected function newSortOption($identifier, array $properties) {
-    $field = isset($properties[Sort::FIELD_KEY]) ? $properties[Sort::FIELD_KEY] : NULL;
-    $direction = isset($properties[Sort::DIRECTION_KEY]) ? $properties[Sort::DIRECTION_KEY] : NULL;
-    $langcode = isset($properties[Sort::LANGUAGE_KEY]) ? $properties[Sort::LANGUAGE_KEY] : NULL;
-    return new SortOption(
-      $identifier,
-      $this->fieldResolver->resolveInternal($field),
-      $direction,
-      $langcode
-    );
-  }
-
-  /**
-   * Returns a new SortOption.
-   *
-   * @param array $properties
-   *   The pager properties.
-   *
-   * @return \Drupal\jsonapi\Query\OffsetPagerOption
-   *   The sort object.
-   */
-  protected function newPagerOption(array $properties) {
-    // Add defaults to avoid unset warnings.
-    $properties += [
-      'limit' => NULL,
-      'offset' => 0,
-    ];
-    return new OffsetPagerOption($properties['limit'], $properties['offset']);
-  }
-
-  /**
-   * Builds a tree of QueryOptions.
-   *
-   * @param \Drupal\jsonapi\Query\QueryOptionInterface[] $options
-   *   An array of QueryOptions.
-   */
-  protected function buildTree(array $options) {
-    $remaining = $options;
-    while (!empty($remaining)) {
-      $insert = array_pop($remaining);
-      if (method_exists($insert, 'parentId') && $parent_id = $insert->parentId()) {
-        if (!$this->insert($parent_id, $insert)) {
-          array_unshift($remaining, $insert);
-        }
-      }
-      else {
-        $this->options[$insert->id()] = $insert;
-      }
-    }
-  }
-
-  /**
-   * Inserts a QueryOption into the appropriate child QueryOption.
-   *
-   * @param string $target_id
-   *   Unique ID of the intended QueryOption parent.
-   * @param \Drupal\jsonapi\Query\QueryOptionInterface $option
-   *   The QueryOption to insert.
-   *
-   * @return bool
-   *   Whether the option could be inserted or not.
-   */
-  protected function insert($target_id, QueryOptionInterface $option) {
-    if (!empty($this->options)) {
-      $find_target_child = function ($child, QueryOptionInterface $my_option) use ($target_id) {
-        if ($child) {
-          return $child;
-        }
-        if (
-          $my_option->id() == $target_id ||
-          (method_exists($my_option, 'hasChild') && $my_option->hasChild($target_id))
-        ) {
-          return $my_option->id();
-        }
-        return FALSE;
-      };
-
-      if ($appropriate_child = array_reduce($this->options, $find_target_child, NULL)) {
-        return $this->options[$appropriate_child]->insert($target_id, $option);
-      }
-    }
-
-    return FALSE;
-  }
-
-  /**
-   * Get the language code key.
-   *
-   * @return string
-   *   The key.
-   */
-  protected function getLangcodeKey() {
-    $entity_type_id = $this->currentContext->getResourceType()
-      ->getEntityTypeId();
-    return $this->entityTypeManager
-      ->getDefinition($entity_type_id)
-      ->getKey('langcode');
-  }
-
-}
diff --git a/src/Query/QueryOptionInterface.php b/src/Query/QueryOptionInterface.php
deleted file mode 100644
index 91110f0..0000000
--- a/src/Query/QueryOptionInterface.php
+++ /dev/null
@@ -1,29 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Query;
-
-/**
- * @internal
- */
-interface QueryOptionInterface {
-
-  /**
-   * Returns a unique id for this query.
-   *
-   * @return string
-   *   The ID for the query.
-   */
-  public function id();
-
-  /**
-   * Receives a QueryInterface and applies the current QueryOption to it.
-   *
-   * @param \Drupal\Core\Entity\Query\QueryInterface|\Drupal\Core\Entity\Query\ConditionInterface $query
-   *   A query or condition group to which this option should be applied.
-   *
-   * @return \Drupal\Core\Entity\Query\QueryInterface|\Drupal\Core\Entity\Query\ConditionInterface
-   *   A query or condition with the current option applied to it.
-   */
-  public function apply($query);
-
-}
diff --git a/src/Query/QueryOptionTreeItemInterface.php b/src/Query/QueryOptionTreeItemInterface.php
deleted file mode 100644
index 102921c..0000000
--- a/src/Query/QueryOptionTreeItemInterface.php
+++ /dev/null
@@ -1,28 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Query;
-
-/**
- * @internal
- */
-interface QueryOptionTreeItemInterface {
-
-  /**
-   * Insert the child into this object or one if its children objects.
-   *
-   * @param string $target_id
-   *   The QueryOption id of the intended parent.
-   * @param \Drupal\jsonapi\Query\QueryOptionInterface $option
-   *   The QueryOption to insert.
-   *
-   * @return bool
-   *   Whether or not the QueryOption could be inserted.
-   */
-  public function insert($target_id, QueryOptionInterface $option);
-
-  /**
-   * Returns whether or the given id is a (grand)child of the object.
-   */
-  public function hasChild($id);
-
-}
diff --git a/src/Query/Sort.php b/src/Query/Sort.php
new file mode 100644
index 0000000..da8dd9d
--- /dev/null
+++ b/src/Query/Sort.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Drupal\jsonapi\Query;
+
+class Sort {
+
+  /**
+   * The JSON API sort key name.
+   *
+   * @var string
+   */
+  const KEY_NAME = 'sort';
+
+  /**
+   * The field key in the sort parameter: sort[lorem][<field>].
+   *
+   * @var string
+   */
+  const PATH_KEY = 'path';
+
+  /**
+   * The direction key in the sort parameter: sort[lorem][<direction>].
+   *
+   * @var string
+   */
+  const DIRECTION_KEY = 'direction';
+
+  /**
+   * The langcode key in the sort parameter: sort[lorem][<langcode>].
+   *
+   * @var string
+   */
+  const LANGUAGE_KEY = 'langcode';
+
+  /**
+   * The fields on which to sort.
+   *
+   * @var string
+   */
+  protected $fields;
+
+  /**
+   * Constructs a new Sort object.
+   *
+   * Takes an array of sort fields. Example:
+   *   [
+   *     [
+   *       'path' => 'changed',
+   *       'direction' => 'DESC',
+   *     ],
+   *     [
+   *       'path' => 'title',
+   *       'direction' => 'ASC',
+   *       'langcode' => 'en-US',
+   *     ],
+   *   ]
+   *
+   * @param array $fields
+   *   The the entity query sort fields.
+   */
+  public function __construct(array $fields) {
+    $this->fields = $fields;
+  }
+
+  /**
+   * Gets the root condition group.
+   */
+  public function fields() {
+    return $this->fields;
+  }
+
+}
diff --git a/src/Query/SortOption.php b/src/Query/SortOption.php
deleted file mode 100644
index 54901f8..0000000
--- a/src/Query/SortOption.php
+++ /dev/null
@@ -1,71 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Query;
-
-/**
- * @internal
- */
-class SortOption implements QueryOptionInterface {
-
-  /**
-   * A unique key.
-   *
-   * @var string
-   */
-  protected $id;
-
-  /**
-   * The field by which to sort.
-   *
-   * @var string
-   */
-  protected $field;
-
-  /**
-   * The direction of the sort.
-   *
-   * @var string
-   */
-  protected $direction;
-
-  /**
-   * The langcode for the sort.
-   *
-   * @var string
-   */
-  protected $langcode;
-
-  /**
-   * Creates a SortOption object.
-   *
-   * @param string $id
-   *   An identifier for the sort options.
-   * @param string $field
-   *   The field by which to sort.
-   * @param string $field
-   *   The direction for the sort.
-   * @param string $langcode
-   *   The language variant of the field to sort by.
-   */
-  public function __construct($id, $field, $direction = 'ASC', $langcode = NULL) {
-    $this->id = $id;
-    $this->field = $field;
-    $this->direction = $direction;
-    $this->langcode = $langcode;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function id() {
-    return $this->id;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function apply($query) {
-    return $query->sort($this->field, $this->direction);
-  }
-
-}
diff --git a/src/Routing/JsonApiParamEnhancer.php b/src/Routing/JsonApiParamEnhancer.php
index b0274d9..bfac673 100644
--- a/src/Routing/JsonApiParamEnhancer.php
+++ b/src/Routing/JsonApiParamEnhancer.php
@@ -2,14 +2,14 @@
 
 namespace Drupal\jsonapi\Routing;
 
-use Drupal\Core\Entity\EntityFieldManagerInterface;
 use Drupal\Core\Routing\Enhancer\RouteEnhancerInterface;
-use Drupal\jsonapi\Routing\Param\OffsetPage;
-use Drupal\jsonapi\Routing\Param\Filter;
-use Drupal\jsonapi\Routing\Param\Sort;
+use Drupal\jsonapi\Query\OffsetPage;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\Query\Sort;
 use Symfony\Cmf\Component\Routing\RouteObjectInterface;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\Routing\Route;
+use \Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
 
 /**
  * @internal
@@ -17,20 +17,33 @@ use Symfony\Component\Routing\Route;
 class JsonApiParamEnhancer implements RouteEnhancerInterface {
 
   /**
-   * The field manager.
+   * The filter normalizer.
    *
-   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   * @var Symfony\Component\Serializer\Normalizer\DenormalizerInterface
    */
-  protected $fieldManager;
+  protected $filterNormalizer;
 
   /**
-   * Instantiates a JsonApiParamEnhancer object.
+   * The sort normalizer.
    *
-   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
-   *   The field manager.
+   * @var Symfony\Component\Serializer\Normalizer\DenormalizerInterface
    */
-  public function __construct(EntityFieldManagerInterface $field_manager) {
-    $this->fieldManager = $field_manager;
+  protected $sortNormalizer;
+
+  /**
+   * The page normalizer.
+   *
+   * @var Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $pageNormalizer;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(DenormalizerInterface $filter_normalizer, DenormalizerInterface $sort_normalizer, DenormalizerInterface $page_normalizer) {
+    $this->filterNormalizer = $filter_normalizer;
+    $this->sortNormalizer = $sort_normalizer;
+    $this->pageNormalizer = $page_normalizer;
   }
 
   /**
@@ -46,20 +59,22 @@ class JsonApiParamEnhancer implements RouteEnhancerInterface {
    */
   public function enhance(array $defaults, Request $request) {
     $options = [];
+
     if ($request->query->has('filter')) {
-      $entity_type_id = $defaults[RouteObjectInterface::ROUTE_OBJECT]->getRequirement('_entity_type');
-      $options['filter'] = new Filter($request->query->get('filter'), $entity_type_id, $this->fieldManager);
+      $filter = $request->query->get('filter');
+      $options['filter'] = $this->filterNormalizer->denormalize($filter, Filter::class);
     }
+
     if ($request->query->has('sort')) {
-      $options['sort'] = new Sort($request->query->get('sort'));
-    }
-    if ($request->query->has('page')) {
-      $options['page'] = new OffsetPage($request->query->get('page'), OffsetPage::$maxSize);
-    }
-    else {
-      $options['page'] = new OffsetPage(['start' => 0, 'limit' => OffsetPage::$maxSize], OffsetPage::$maxSize);
+      $sort = $request->query->get('sort');
+      $options['sort'] = $this->sortNormalizer->denormalize($sort, Sort::class);
     }
+
+    $page = ($request->query->has('page')) ? $request->query->get('page') : [];
+    $options['page'] = $this->pageNormalizer->denormalize($page, OffsetPage::class);
+
     $defaults['_json_api_params'] = $options;
+
     return $defaults;
   }
 
diff --git a/src/Routing/Param/Filter.php b/src/Routing/Param/Filter.php
deleted file mode 100644
index 4a789d5..0000000
--- a/src/Routing/Param/Filter.php
+++ /dev/null
@@ -1,116 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Routing\Param;
-
-use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
-
-/**
- * @internal
- */
-class Filter extends JsonApiParamBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  const KEY_NAME = 'filter';
-
-  /**
-   * Key in the filter[<key>] parameter for conditions.
-   *
-   * @var string
-   */
-  const CONDITION_KEY = 'condition';
-
-  /**
-   * Key in the filter[<key>] parameter for groups.
-   *
-   * @var string
-   */
-  const GROUP_KEY = 'group';
-
-  /**
-   * Key in the filter[<id>][<key>] parameter for group membership.
-   *
-   * @var string
-   */
-  const MEMBER_KEY = 'memberOf';
-
-  /**
-   * The field key in the filter condition: filter[lorem][condition][<field>].
-   *
-   * @var string
-   */
-  const PATH_KEY = 'path';
-
-  /**
-   * The value key in the filter condition: filter[lorem][condition][<value>].
-   *
-   * @var string
-   */
-  const VALUE_KEY = 'value';
-
-  /**
-   * The operator key in the condition: filter[lorem][condition][<operator>].
-   *
-   * @var string
-   */
-  const OPERATOR_KEY = 'operator';
-
-  /**
-   * The conjunction key in the condition: filter[lorem][group][<conjunction>].
-   *
-   * @var string
-   */
-  const CONJUNCTION_KEY = 'conjunction';
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function expand() {
-    // We should always get an array for the filter.
-    if (!is_array($this->original)) {
-      throw new BadRequestHttpException('Incorrect value passed to the filter parameter.');
-    }
-
-    $expanded = [];
-    foreach ($this->original as $filter_index => $filter_item) {
-      $expanded[$filter_index] = $this->expandItem($filter_index, $filter_item);
-    }
-    return $expanded;
-  }
-
-  /**
-   * Expands a filter item in case a shortcut was used.
-   *
-   * Possible cases for the conditions:
-   *   1. filter[uuid][value]=1234.
-   *   2. filter[0][condition][field]=uuid&filter[0][condition][value]=1234.
-   *   3. filter[uuid][condition][value]=1234.
-   *   4. filter[uuid][value]=1234&filter[uuid][group]=my_group.
-   *
-   * @param string $filter_index
-   *   The index.
-   * @param array $filter_item
-   *   The raw filter item.
-   *
-   * @return array
-   *   The expanded filter item.
-   */
-  protected function expandItem($filter_index, array $filter_item) {
-    if (isset($filter_item[static::VALUE_KEY])) {
-      if (!isset($filter_item[static::PATH_KEY])) {
-        $filter_item[static::PATH_KEY] = $filter_index;
-      }
-      $filter_item = [
-        static::CONDITION_KEY => $filter_item,
-      ];
-
-      if (!isset($filter_item[static::CONDITION_KEY][static::OPERATOR_KEY])) {
-        $filter_item[static::CONDITION_KEY][static::OPERATOR_KEY] = '=';
-      }
-    }
-
-    return $filter_item;
-  }
-
-}
diff --git a/src/Routing/Param/JsonApiParamBase.php b/src/Routing/Param/JsonApiParamBase.php
deleted file mode 100644
index 21cb503..0000000
--- a/src/Routing/Param/JsonApiParamBase.php
+++ /dev/null
@@ -1,69 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Routing\Param;
-
-/**
- * @internal
- */
-class JsonApiParamBase implements JsonApiParamInterface {
-
-  /**
-   * The original data.
-   *
-   * @var string|string[]
-   */
-  protected $original;
-
-  /**
-   * The expanded data.
-   *
-   * @var string|string[]
-   */
-  protected $data;
-
-  /**
-   * Create a parameter object.
-   *
-   * @param string|string[] $original
-   *   The user generated data.
-   */
-  public function __construct($original) {
-    $this->original = $original;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function get() {
-    if (!$this->data) {
-      $this->data = $this->expand();
-    }
-    return $this->data;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getOriginal() {
-    return $this->original;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getKey() {
-    return static::KEY_NAME;
-  }
-
-  /**
-   * Apply all necessary defaults and transformations to the parameter.
-   *
-   * @return string|string[]
-   *   The expanded data.
-   */
-  protected function expand() {
-    // The base implementation does no expansion.
-    return $this->original;
-  }
-
-}
diff --git a/src/Routing/Param/JsonApiParamInterface.php b/src/Routing/Param/JsonApiParamInterface.php
deleted file mode 100644
index 695aa37..0000000
--- a/src/Routing/Param/JsonApiParamInterface.php
+++ /dev/null
@@ -1,44 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Routing\Param;
-
-/**
- * @internal
- */
-interface JsonApiParamInterface {
-
-  /**
-   * The key name.
-   *
-   * This must be redefined with a unique value in each class that extends
-   * from JsonApiParamInterface.
-   *
-   * @var string
-   */
-  const KEY_NAME = NULL;
-
-  /**
-   * Gets the original parsed query string param.
-   *
-   * @return string|string[]
-   *   The original value.
-   */
-  public function getOriginal();
-
-  /**
-   * Gets the expanded value with defaults.
-   *
-   * @return string|string[]
-   *   The query string value.
-   */
-  public function get();
-
-  /**
-   * Gets the key of the parameter.
-   *
-   * @return string
-   *   The key.
-   */
-  public function getKey();
-
-}
diff --git a/src/Routing/Param/OffsetPage.php b/src/Routing/Param/OffsetPage.php
deleted file mode 100644
index 2de1b22..0000000
--- a/src/Routing/Param/OffsetPage.php
+++ /dev/null
@@ -1,74 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Routing\Param;
-
-use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
-
-/**
- * @internal
- */
-class OffsetPage extends JsonApiParamBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  const KEY_NAME = 'page';
-
-  /**
-   * Max size.
-   *
-   * @var int
-   */
-  public static $maxSize = 50;
-
-  /**
-   * Instantiates an OffsetPage object.
-   *
-   * @param string|\string[] $original
-   *   The original user generated data.
-   * @param int $max_size
-   *   The maximum size for the pager.
-   */
-  public function __construct($original, $max_size = NULL) {
-    parent::__construct($original);
-    if ($max_size) {
-      static::$maxSize = $max_size;
-    }
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function expand() {
-    if (!is_array($this->original)) {
-      throw new BadRequestHttpException('The page parameter needs to be an array.');
-    }
-    $output = $this->original + ['limit' => static::$maxSize];
-    $output['limit'] = $output['limit'] > static::$maxSize ?
-      static::$maxSize :
-      $output['limit'];
-    return $output;
-  }
-
-  /**
-   * Returns the current offset.
-   *
-   * @return int
-   */
-  public function getOffset() {
-    $data = $this->get();
-    return isset($data['offset']) ? $data['offset'] : 0;
-  }
-
-  /**
-   * Returns the page size.
-   *
-   * @return int
-   */
-  public function getSize() {
-    $data = $this->get();
-    $size = isset($data['limit']) ? $data['limit'] : static::$maxSize;
-    return $size > static::$maxSize ? static::$maxSize : $size;
-  }
-
-}
diff --git a/tests/src/Functional/JsonApiFunctionalTest.php b/tests/src/Functional/JsonApiFunctionalTest.php
index 80b62af..182586d 100644
--- a/tests/src/Functional/JsonApiFunctionalTest.php
+++ b/tests/src/Functional/JsonApiFunctionalTest.php
@@ -4,7 +4,7 @@ namespace Drupal\Tests\jsonapi\Functional;
 
 use Drupal\Component\Serialization\Json;
 use Drupal\Core\Url;
-use Drupal\jsonapi\Routing\Param\OffsetPage;
+use Drupal\jsonapi\Query\OffsetPage;
 
 /**
  * @group jsonapi
@@ -22,7 +22,7 @@ class JsonApiFunctionalTest extends JsonApiFunctionalTestBase {
     // 1. Load all articles (1st page).
     $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article'));
     $this->assertSession()->statusCodeEquals(200);
-    $this->assertEquals(OffsetPage::$maxSize, count($collection_output['data']));
+    $this->assertEquals(OffsetPage::SIZE_MAX, count($collection_output['data']));
     $this->assertSession()
       ->responseHeaderEquals('Content-Type', 'application/vnd.api+json');
     // 2. Load all articles (Offset 3).
@@ -30,7 +30,7 @@ class JsonApiFunctionalTest extends JsonApiFunctionalTestBase {
       'query' => ['page' => ['offset' => 3]],
     ]));
     $this->assertSession()->statusCodeEquals(200);
-    $this->assertEquals(OffsetPage::$maxSize, count($collection_output['data']));
+    $this->assertEquals(OffsetPage::SIZE_MAX, count($collection_output['data']));
     $this->assertContains('page[offset]=53', $collection_output['links']['next']);
     // 3. Load all articles (1st page, 2 items)
     $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [
diff --git a/tests/src/Kernel/Controller/EntityResourceTest.php b/tests/src/Kernel/Controller/EntityResourceTest.php
index 982c816..4170fca 100644
--- a/tests/src/Kernel/Controller/EntityResourceTest.php
+++ b/tests/src/Kernel/Controller/EntityResourceTest.php
@@ -11,9 +11,11 @@ use Drupal\jsonapi\Context\CurrentContext;
 use Drupal\jsonapi\Controller\EntityResource;
 use Drupal\jsonapi\Resource\EntityCollection;
 use Drupal\jsonapi\Resource\JsonApiDocumentTopLevel;
-use Drupal\jsonapi\Routing\Param\Filter;
-use Drupal\jsonapi\Routing\Param\Sort;
-use Drupal\jsonapi\Routing\Param\OffsetPage;
+use Drupal\jsonapi\Query\EntityCondition;
+use Drupal\jsonapi\Query\EntityConditionGroup;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\Query\Sort;
+use Drupal\jsonapi\Query\OffsetPage;
 use Drupal\node\Entity\Node;
 use Drupal\node\Entity\NodeType;
 use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
@@ -106,6 +108,7 @@ class EntityResourceTest extends JsonapiKernelTestBase {
     ]);
     $this->createEntityReferenceField('node', 'article', 'field_relationships', 'Relationship', 'node', 'default', ['target_bundles' => ['article']], FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
     $this->user->save();
+
     $this->node = Node::create([
       'title' => 'dummy_title',
       'type' => 'article',
@@ -206,7 +209,7 @@ class EntityResourceTest extends JsonapiKernelTestBase {
    */
   public function testGetFilteredCollection() {
     $field_manager = $this->container->get('entity_field.manager');
-    $filter = new Filter(['type' => ['value' => 'article']], 'node_type', $field_manager);
+    $filter = new Filter(new EntityConditionGroup('AND', [new EntityCondition('type', 'article')]));
     // The fake route.
     $route = new Route(NULL, [], [
       '_entity_type' => 'node',
@@ -237,7 +240,6 @@ class EntityResourceTest extends JsonapiKernelTestBase {
     $entity_resource = new EntityResource(
       $this->container->get('jsonapi.resource_type.repository')->get('node_type', 'node_type'),
       $this->container->get('entity_type.manager'),
-      $this->container->get('jsonapi.query_builder'),
       $field_manager,
       $current_context,
       $this->container->get('plugin.manager.field.field_type'),
@@ -265,7 +267,7 @@ class EntityResourceTest extends JsonapiKernelTestBase {
       '_entity_type' => 'node',
       '_bundle' => 'article',
     ]);
-    $sort = new Sort('-type');
+    $sort = new Sort([['path' => 'type', 'direction' => 'DESC']]);
     // The request.
     $request = new Request([], [], [
       '_route_params' => [
@@ -291,7 +293,6 @@ class EntityResourceTest extends JsonapiKernelTestBase {
     $entity_resource = new EntityResource(
       $this->container->get('jsonapi.resource_type.repository')->get('node_type', 'node_type'),
       $this->container->get('entity_type.manager'),
-      $this->container->get('jsonapi.query_builder'),
       $field_manager,
       $current_context,
       $this->container->get('plugin.manager.field.field_type'),
@@ -320,7 +321,7 @@ class EntityResourceTest extends JsonapiKernelTestBase {
       '_entity_type' => 'node',
       '_bundle' => 'article',
     ]);
-    $pager = new OffsetPage(['offset' => 1, 'limit' => 1]);
+    $pager = new OffsetPage(1, 1);
     // The request.
     $request = new Request([], [], [
       '_route_params' => [
@@ -346,7 +347,6 @@ class EntityResourceTest extends JsonapiKernelTestBase {
     $entity_resource = new EntityResource(
       $this->container->get('jsonapi.resource_type.repository')->get('node', 'article'),
       $this->container->get('entity_type.manager'),
-      $this->container->get('jsonapi.query_builder'),
       $field_manager,
       $current_context,
       $this->container->get('plugin.manager.field.field_type'),
@@ -369,11 +369,7 @@ class EntityResourceTest extends JsonapiKernelTestBase {
    * @covers ::getCollection
    */
   public function testGetEmptyCollection() {
-    $filter = new Filter(
-      ['uuid' => ['value' => 'invalid']],
-      'node',
-      $this->container->get('entity_field.manager')
-    );
+    $filter = new Filter(new EntityConditionGroup('AND', [new EntityCondition('uuid', 'invalid')]));
     $request = new Request([], [], [
       '_route_params' => [
         '_json_api_params' => [
@@ -870,7 +866,6 @@ class EntityResourceTest extends JsonapiKernelTestBase {
     return new EntityResource(
       new ResourceType($entity_type_id, $bundle, NULL),
       $this->container->get('entity_type.manager'),
-      $this->container->get('jsonapi.query_builder'),
       $this->container->get('entity_field.manager'),
       $current_context,
       $this->container->get('plugin.manager.field.field_type'),
diff --git a/tests/src/Kernel/JsonapiKernelTestBase.php b/tests/src/Kernel/JsonapiKernelTestBase.php
index fd39cc1..4b220b7 100644
--- a/tests/src/Kernel/JsonapiKernelTestBase.php
+++ b/tests/src/Kernel/JsonapiKernelTestBase.php
@@ -67,4 +67,41 @@ abstract class JsonapiKernelTestBase extends KernelTestBase {
     }
   }
 
+  /**
+   * Creates a field of an entity reference field storage on the bundle.
+   *
+   * @param string $entity_type
+   *   The type of entity the field will be attached to.
+   * @param string $bundle
+   *   The bundle name of the entity the field will be attached to.
+   * @param string $field_name
+   *   The name of the field; if it exists, a new instance of the existing.
+   *   field will be created.
+   * @param string $field_label
+   *   The label of the field.
+   * @param int $cardinality
+   *   The cardinality of the field.
+   *
+   * @see \Drupal\Core\Entity\Plugin\EntityReferenceSelection\SelectionBase::buildConfigurationForm()
+   */
+  protected function createTextField($entity_type, $bundle, $field_name, $field_label, $cardinality = 1) {
+    // Look for or add the specified field to the requested entity bundle.
+    if (!FieldStorageConfig::loadByName($entity_type, $field_name)) {
+      FieldStorageConfig::create([
+        'field_name' => $field_name,
+        'type' => 'text',
+        'entity_type' => $entity_type,
+        'cardinality' => $cardinality,
+      ])->save();
+    }
+    if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) {
+      FieldConfig::create([
+        'field_name' => $field_name,
+        'entity_type' => $entity_type,
+        'bundle' => $bundle,
+        'label' => $field_label,
+      ])->save();
+    }
+  }
+
 }
diff --git a/tests/src/Kernel/Normalizer/EntityConditionGroupNormalizerTest.php b/tests/src/Kernel/Normalizer/EntityConditionGroupNormalizerTest.php
new file mode 100644
index 0000000..f89b9a9
--- /dev/null
+++ b/tests/src/Kernel/Normalizer/EntityConditionGroupNormalizerTest.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Normalizer;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\jsonapi\Normalizer\EntityConditionGroupNormalizer;
+use Drupal\jsonapi\Query\EntityConditionGroup;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\EntityConditionGroupNormalizer
+ * @group jsonapi
+ * @group jsonapi_normalizers
+ */
+class EntityConditionGroupNormalizerTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'serialization',
+    'system',
+    'jsonapi',
+  ];
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeProvider
+   */
+  public function testDenormalize($case) {
+    $normalizer = $this->container->get('serializer.normalizer.entity_condition_group.jsonapi');
+
+    $normalized = $normalizer->denormalize($case, EntityConditionGroup::class);
+
+    $this->assertEquals($case['conjunction'], $normalized->conjunction());
+
+    foreach ($normalized->members() as $key => $condition) {
+      $this->assertEquals($case['members'][$key]['path'], $condition->field());
+      $this->assertEquals($case['members'][$key]['value'], $condition->value());
+    }
+  }
+
+  /**
+   * @covers ::denormalize
+   * @expectedException InvalidArgumentException
+   */
+  public function testDenormalize_exception() {
+    $normalizer = $this->container->get('serializer.normalizer.entity_condition_group.jsonapi');
+    $data = ['conjunction' => 'NOT_ALLOWED', 'members' => []];
+    $normalized = $normalizer->denormalize($data, EntityConditionGroup::class);
+  }
+
+  public function denormalizeProvider() {
+    return [
+      [['conjunction' => 'AND', 'members' => []]],
+      [['conjunction' => 'OR', 'members' => []]],
+    ];
+  }
+
+}
diff --git a/tests/src/Kernel/Normalizer/EntityConditionNormalizerTest.php b/tests/src/Kernel/Normalizer/EntityConditionNormalizerTest.php
new file mode 100644
index 0000000..3f4027c
--- /dev/null
+++ b/tests/src/Kernel/Normalizer/EntityConditionNormalizerTest.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Normalizer;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\jsonapi\Normalizer\EntityConditionNormalizer;
+use Drupal\jsonapi\Query\EntityCondition;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\EntityConditionNormalizer
+ * @group jsonapi
+ * @group jsonapi_normalizers
+ */
+class EntityConditionNormalizerTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'serialization',
+    'system',
+    'jsonapi',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+    $this->normalizer = $this->container->get('serializer.normalizer.entity_condition.jsonapi');
+  }
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeProvider
+   */
+  public function testDenormalize($case) {
+    $normalized = $this->normalizer->denormalize($case, EntityCondition::class);
+    $this->assertEquals($case['path'], $normalized->field());
+    $this->assertEquals($case['value'], $normalized->value());
+    if (isset($case['operator'])) {
+      $this->assertEquals($case['operator'], $normalized->operator());
+    }
+  }
+
+  /**
+   * @covers ::denormalize
+   * @expectedException InvalidArgumentException
+   * @expectedExceptionMessage The 'NOT_ALLOWED' operator is not allowed.
+   */
+  public function testDenormalize_exception() {
+    $this->normalizer->denormalize([
+      'path' => 'some_field',
+      'operator' => 'NOT_ALLOWED',
+      'value' => 'some_string',
+    ], EntityCondition::class);
+  }
+
+  public function denormalizeProvider() {
+    return [
+      [['path' => 'some_field', 'value' => NULL]],
+      [['path' => 'some_field', 'operator' => '=', 'value' => 'some_string']],
+      [['path' => 'some_field', 'operator' => 'NOT BETWEEN', 'value' => 'some_string']],
+      [['path' => 'some_field', 'operator' => NULL, 'value' => 'some_string']],
+      [['path' => 'some_field', 'operator' => NULL, 'value' => ['some_string']]],
+    ];
+  }
+
+}
diff --git a/tests/src/Kernel/Normalizer/FilterNormalizerTest.php b/tests/src/Kernel/Normalizer/FilterNormalizerTest.php
new file mode 100644
index 0000000..7ed2cd4
--- /dev/null
+++ b/tests/src/Kernel/Normalizer/FilterNormalizerTest.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Normalizer;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\jsonapi\Normalizer\FilterNormalizer;
+use Drupal\jsonapi\Query\Filter;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\FilterNormalizer
+ * @group jsonapi
+ * @group jsonapi_normalizers
+ */
+class FilterNormalizerTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'serialization',
+    'system',
+    'jsonapi',
+  ];
+
+  /**
+   * The filter denormalizer.
+   *
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $normalizer;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+    $this->normalizer = $this->container->get('serializer.normalizer.filter.jsonapi');
+  }
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeProvider
+   */
+  public function testDenormalize($normalized, $expected) {
+    $actual = $this->normalizer->denormalize($normalized, Filter::class);
+    $conditions = $actual->root()->members();
+    for ($i = 0; $i < count($normalized); $i++) {
+      $this->assertEquals($expected[$i]['path'], $conditions[$i]->field());
+      $this->assertEquals($expected[$i]['value'], $conditions[$i]->value());
+      $this->assertEquals($expected[$i]['operator'], $conditions[$i]->operator());
+    }
+  }
+
+  /**
+   * Data provider for testDenormalize.
+   */
+  public function denormalizeProvider() {
+    return [
+      [['uid' => ['value' => 1]], [['path' => 'uid', 'value' => 1, 'operator' => '=']]],
+    ];
+  }
+
+  /**
+   * @covers ::denormalize
+   */
+  public function testDenormalize_nested() {
+    $normalized = [
+      'or-group' => ['group' => ['conjunction' => 'OR']],
+      'nested-or-group' => ['group' => ['conjunction' => 'OR', 'memberOf' => 'or-group']],
+      'nested-and-group' => ['group' => ['conjunction' => 'AND', 'memberOf' => 'or-group']],
+      'condition-0' => ['condition' => ['path' => 'field0', 'value' => 'value0', 'memberOf' => 'nested-or-group']],
+      'condition-1' => ['condition' => ['path' => 'field1', 'value' => 'value1', 'memberOf' => 'nested-or-group']],
+      'condition-2' => ['condition' => ['path' => 'field2', 'value' => 'value2', 'memberOf' => 'nested-and-group']],
+      'condition-3' => ['condition' => ['path' => 'field3', 'value' => 'value3', 'memberOf' => 'nested-and-group']],
+    ];
+    $filter = $this->normalizer->denormalize($normalized, Filter::class);
+    $root = $filter->root();
+
+    // Make sure the implicit root group was added.
+    $this->assertEquals($root->conjunction(), 'AND');
+
+    // Ensure the or-group and the and-group were added correctly. 
+    $members = $root->members();
+
+    // Ensure the OR group was added.
+    $or_group = $members[0];
+    $this->assertEquals($or_group->conjunction(), 'OR');
+    $or_group_members = $or_group->members();
+
+    // Make sure the nested OR group was added with the right conditions.
+    $nested_or_group = $or_group_members[0];
+    $this->assertEquals($nested_or_group->conjunction(), 'OR');
+    $nested_or_group_members = $nested_or_group->members();
+    $this->assertEquals($nested_or_group_members[0]->field(), 'field0');
+    $this->assertEquals($nested_or_group_members[1]->field(), 'field1');
+
+    // Make sure the nested AND group was added with the right conditions.
+    $nested_and_group = $or_group_members[1];
+    $this->assertEquals($nested_and_group->conjunction(), 'AND');
+    $nested_and_group_members = $nested_and_group->members();
+    $this->assertEquals($nested_and_group_members[0]->field(), 'field2');
+    $this->assertEquals($nested_and_group_members[1]->field(), 'field3');
+  }
+
+}
diff --git a/tests/src/Kernel/Normalizer/OffsetPageNormalizerTest.php b/tests/src/Kernel/Normalizer/OffsetPageNormalizerTest.php
new file mode 100644
index 0000000..9527b03
--- /dev/null
+++ b/tests/src/Kernel/Normalizer/OffsetPageNormalizerTest.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Normalizer;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\jsonapi\Query\OffsetPage;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\OffsetPageNormalizer
+ * @group jsonapi
+ * @group jsonapi_normalizers
+ */
+class OffsetPageNormalizerTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'serialization',
+    'system',
+    'jsonapi',
+  ];
+
+  /**
+   * The filter denormalizer.
+   *
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $normalizer;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+    $this->normalizer = $this->container->get('serializer.normalizer.offset_page.jsonapi');
+  }
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeProvider
+   */
+  public function testDenormalize($original, $expected) {
+    $actual = $this->normalizer->denormalize($original, OffsetPage::class);
+    $this->assertEquals($expected['offset'], $actual->offset());
+    $this->assertEquals($expected['limit'], $actual->size());
+  }
+
+  /**
+   * Data provider for testGet.
+   */
+  public function denormalizeProvider() {
+    return [
+      [['offset' => 12, 'limit' => 20], ['offset' => 12, 'limit' => 20]],
+      [['offset' => 12, 'limit' => 60], ['offset' => 12, 'limit' => 50]],
+      [['offset' => 12], ['offset' => 12, 'limit' => 50]],
+      [['offset' => 0], ['offset' => 0, 'limit' => 50]],
+      [[], ['offset' => 0, 'limit' => 50]],
+    ];
+  }
+
+  /**
+   * @covers ::denormalize
+   * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   */
+  public function testDenormalizeFail() {
+    $this->normalizer->denormalize('lorem', OffsetPage::class);
+  }
+
+
+}
diff --git a/tests/src/Kernel/Normalizer/SortNormalizerTest.php b/tests/src/Kernel/Normalizer/SortNormalizerTest.php
new file mode 100644
index 0000000..c91b03c
--- /dev/null
+++ b/tests/src/Kernel/Normalizer/SortNormalizerTest.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Normalizer;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\jsonapi\Query\Sort;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\SortNormalizer
+ * @group jsonapi
+ * @group jsonapi_normalizers
+ */
+class SortNormalizerTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'serialization',
+    'system',
+    'jsonapi',
+  ];
+
+  /**
+   * The filter denormalizer.
+   *
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $normalizer;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+    $this->normalizer = $this->container->get('serializer.normalizer.sort.jsonapi');
+  }
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeProvider
+   */
+  public function testDenormalize($input, $expected) {
+    $sort = $this->normalizer->denormalize($input, Sort::class);
+    foreach ($sort->fields() as $index => $sort_field) {
+      $this->assertEquals($expected[$index]['path'], $sort_field['path']);
+      $this->assertEquals($expected[$index]['direction'], $sort_field['direction']);
+      $this->assertEquals($expected[$index]['langcode'], $sort_field['langcode']);
+    }
+  }
+
+  /**
+   * Provides a suite of shortcut sort pamaters and their expected expansions.
+   */
+  public function denormalizeProvider() {
+    return [
+      ['lorem', [['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL]]],
+      ['-lorem', [['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL]]],
+      ['-lorem,ipsum', [
+        ['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL],
+        ['path' => 'ipsum', 'direction' => 'ASC', 'langcode' => NULL],
+      ],
+      ],
+      ['-lorem,-ipsum', [
+        ['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL],
+        ['path' => 'ipsum', 'direction' => 'DESC', 'langcode' => NULL],
+      ],
+      ],
+      [[
+        ['path' => 'lorem', 'langcode' => NULL],
+        ['path' => 'ipsum', 'langcode' => 'ca'],
+        ['path' => 'dolor', 'direction' => 'ASC', 'langcode' => 'ca'],
+        ['path' => 'sit', 'direction' => 'DESC', 'langcode' => 'ca'],
+      ], [
+        ['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL],
+        ['path' => 'ipsum', 'direction' => 'ASC', 'langcode' => 'ca'],
+        ['path' => 'dolor', 'direction' => 'ASC', 'langcode' => 'ca'],
+        ['path' => 'sit', 'direction' => 'DESC', 'langcode' => 'ca'],
+      ],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::denormalize
+   * @dataProvider denormalizeFailProvider
+   * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   */
+  public function testDenormalizeFail($input) {
+    $sort = $this->normalizer->denormalize($input, Sort::class);
+  }
+
+  /**
+   * Data provider for testDenormalizeFail.
+   */
+  public function denormalizeFailProvider() {
+    return [
+      [[['lorem']]],
+      [''],
+    ];
+  }
+
+}
diff --git a/tests/src/Kernel/Query/FilterTest.php b/tests/src/Kernel/Query/FilterTest.php
new file mode 100644
index 0000000..62b0a3f
--- /dev/null
+++ b/tests/src/Kernel/Query/FilterTest.php
@@ -0,0 +1,167 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Query;
+
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\jsonapi\Kernel\JsonapiKernelTestBase;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * @coversDefaultClass \Drupal\jsonapi\Query\Filter
+ * @group jsonapi
+ * @group jsonapi_query
+ */
+class FilterTest extends JsonapiKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'field',
+    'jsonapi',
+    'node',
+    'serialization',
+    'system',
+    'text',
+    'user',
+  ];
+
+  /**
+   * The filter denormalizer.
+   *
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
+   */
+  protected $normalizer;
+
+  /**
+   * A node storage instance.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface;
+   */
+  protected $nodeStorage;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+
+    $this->setUpSchemas();
+
+    $this->savePaintingType();
+
+    // ((RED or CIRCLE) or (YELLOW and SQUARE))
+    $this->savePaintings([
+      ['colors' => ['red'], 'shapes' => ['triangle'], 'title' => 'FIND'],
+      ['colors' => ['orange'], 'shapes' => ['circle'], 'title' => 'FIND'],
+      ['colors' => ['orange'], 'shapes' => ['triangle'], 'title' => 'DONT_FIND'],
+      ['colors' => ['yellow'], 'shapes' => ['square'], 'title' => 'FIND'],
+      ['colors' => ['yellow'], 'shapes' => ['triangle'], 'title' => 'DONT_FIND'],
+      ['colors' => ['orange'], 'shapes' => ['square'], 'title' => 'DONT_FIND'],
+    ]);
+
+    $this->normalizer = $this->container->get('serializer.normalizer.filter.jsonapi');
+    $this->nodeStorage = $this->container->get('entity_type.manager')->getStorage('node');
+  }
+
+  /**
+   * @covers ::queryCondition
+   */
+  public function testQueryCondition() {
+    // Can't use a data provider because we need access to the container.
+    $data = $this->queryConditionData();
+
+    foreach ($data as $case) {
+      $normalized = $case[0];
+      $expected_query = $case[1];
+      // Denormalize the test filter into the object we want to test.
+      $filter = $this->normalizer->denormalize($normalized, Filter::class);
+
+      $query = $this->nodeStorage->getQuery();
+
+      // Get the query condition parsed from the input.
+      $condition = $filter->queryCondition($query);
+
+      // Apply it to the query.
+      $query->condition($condition);
+
+      // Compare the results.
+      $this->assertEquals($expected_query->execute(), $query->execute());
+    }
+  }
+
+  /**
+   * Simply provides test data to keep the actual test method tidy.
+   */
+  protected function queryConditionData() {
+    // ((RED or CIRCLE) or (YELLOW and SQUARE))
+    $query = $this->nodeStorage->getQuery();
+
+    $or_group = $query->orConditionGroup();
+
+    $nested_or_group = $query->orConditionGroup();
+    $nested_or_group->condition('colors', 'red', 'CONTAINS');
+    $nested_or_group->condition('shapes', 'circle', 'CONTAINS');
+    $or_group->condition($nested_or_group);
+
+    $nested_and_group = $query->andConditionGroup();
+    $nested_and_group->condition('colors', 'yellow', 'CONTAINS');
+    $nested_and_group->condition('shapes', 'square', 'CONTAINS');
+    $or_group->condition($nested_and_group);
+
+    $query->condition($or_group);
+
+    return [
+      [
+        [
+          'or-group' => ['group' => ['conjunction' => 'OR']],
+          'nested-or-group' => ['group' => ['conjunction' => 'OR', 'memberOf' => 'or-group']],
+          'nested-and-group' => ['group' => ['conjunction' => 'AND', 'memberOf' => 'or-group']],
+          'condition-0' => ['condition' => ['path' => 'colors', 'value' => 'red', 'operator' => 'CONTAINS', 'memberOf' => 'nested-or-group']],
+          'condition-1' => ['condition' => ['path' => 'shapes', 'value' => 'circle', 'operator' => 'CONTAINS', 'memberOf' => 'nested-or-group']],
+          'condition-2' => ['condition' => ['path' => 'colors', 'value' => 'yellow', 'operator' => 'CONTAINS', 'memberOf' => 'nested-and-group']],
+          'condition-3' => ['condition' => ['path' => 'shapes', 'value' => 'square', 'operator' => 'CONTAINS', 'memberOf' => 'nested-and-group']],
+        ],
+        $query
+      ],
+    ];
+  }
+
+  protected function setUpSchemas() {
+    $this->installSchema('system', ['sequences']);
+    $this->installSchema('node', ['node_access']);
+    $this->installSchema('user', ['users_data']);
+
+    $this->installSchema('user', []);
+    foreach (['user', 'node'] as $entity_type_id) {
+      $this->installEntitySchema($entity_type_id);
+    }
+  }
+
+  protected function savePaintingType() {
+    NodeType::create([
+      'type' => 'painting',
+    ])->save();
+    $this->createTextField(
+      'node', 'painting',
+      'colors', 'Colors',
+      FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
+    );
+    $this->createTextField(
+      'node', 'painting',
+      'shapes', 'Shapes',
+      FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
+    );
+  }
+
+  protected function savePaintings($paintings) {
+    foreach ($paintings as $painting) {
+      Node::create(array_merge([
+        'type' => 'painting',
+      ], $painting))->save();
+    }
+  }
+
+}
diff --git a/tests/src/Unit/Context/CurrentContextTest.php b/tests/src/Unit/Context/CurrentContextTest.php
index b0f465d..34cfc25 100644
--- a/tests/src/Unit/Context/CurrentContextTest.php
+++ b/tests/src/Unit/Context/CurrentContextTest.php
@@ -6,9 +6,10 @@ use Drupal\Core\Routing\CurrentRouteMatch;
 use Drupal\jsonapi\Context\CurrentContext;
 use Drupal\jsonapi\ResourceType\ResourceType;
 use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
-use Drupal\jsonapi\Routing\Param\Filter;
-use Drupal\jsonapi\Routing\Param\Sort;
-use Drupal\jsonapi\Routing\Param\OffsetPage;
+use Drupal\jsonapi\Query\EntityConditionGroup;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\Query\Sort;
+use Drupal\jsonapi\Query\OffsetPage;
 use Drupal\Core\Entity\EntityFieldManagerInterface;
 use Drupal\node\NodeInterface;
 use Drupal\Tests\UnitTestCase;
@@ -38,13 +39,6 @@ class CurrentContextTest extends UnitTestCase {
   protected $resourceTypeRepository;
 
   /**
-   * A mock for the entity field manager.
-   *
-   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
-   */
-  protected $fieldManager;
-
-  /**
    * A request stack.
    *
    * @var \Symfony\Component\HttpFoundation\RequestStack
@@ -60,9 +54,6 @@ class CurrentContextTest extends UnitTestCase {
    * {@inheritdoc}
    */
   public function setUp() {
-    // Create a mock for the entity field manager.
-    $this->fieldManager = $this->prophesize(EntityFieldManagerInterface::CLASS)->reveal();
-
     // Create a mock for the current route match.
     $this->currentRoute = new Route(
       '/jsonapi/articles',
@@ -79,9 +70,9 @@ class CurrentContextTest extends UnitTestCase {
     $this->requestStack = new RequestStack();
     $this->requestStack->push(new Request([], [], [
       '_json_api_params' => [
-        'filter' => new Filter([], 'node', $this->fieldManager),
+        'filter' => new Filter(new EntityConditionGroup('AND', [])),
         'sort' => new Sort([]),
-        'page' => new OffsetPage([]),
+        'page' => new OffsetPage(OffsetPage::DEFAULT_OFFSET, OffsetPage::SIZE_MAX),
         // 'include' => new IncludeParam([]),
         // 'fields' => new Fields([]),.
       ],
@@ -109,10 +100,9 @@ class CurrentContextTest extends UnitTestCase {
   public function testGetJsonApiParameter() {
     $request_context = new CurrentContext($this->resourceTypeRepository, $this->requestStack, $this->routeMatcher);
 
-    $expected = new Sort([]);
     $actual = $request_context->getJsonApiParameter('sort');
 
-    $this->assertEquals($expected, $actual);
+    $this->assertTrue($actual instanceof Sort);
   }
 
   /**
diff --git a/tests/src/Unit/LinkManager/LinkManagerTest.php b/tests/src/Unit/LinkManager/LinkManagerTest.php
index db44273..5e913d2 100644
--- a/tests/src/Unit/LinkManager/LinkManagerTest.php
+++ b/tests/src/Unit/LinkManager/LinkManagerTest.php
@@ -4,7 +4,7 @@ namespace Drupal\Tests\jsonapi\Unit\LinkManager;
 
 use Drupal\Core\Routing\UrlGeneratorInterface;
 use Drupal\jsonapi\LinkManager\LinkManager;
-use Drupal\jsonapi\Routing\Param\OffsetPage;
+use Drupal\jsonapi\Query\OffsetPage;
 use Drupal\Tests\UnitTestCase;
 use Prophecy\Argument;
 use Symfony\Cmf\Component\Routing\ChainRouterInterface;
@@ -54,8 +54,8 @@ class LinkManagerTest extends UnitTestCase {
     $request = $this->prophesize(Request::class);
     // Have the request return the desired page parameter.
     $page_param = $this->prophesize(OffsetPage::class);
-    $page_param->getOffset()->willReturn($offset);
-    $page_param->getSize()->willReturn($size);
+    $page_param->offset()->willReturn($offset);
+    $page_param->size()->willReturn($size);
     $request->get('_json_api_params')->willReturn(['page' => $page_param->reveal()]);
     $request->query = new ParameterBag();
 
@@ -159,8 +159,8 @@ class LinkManagerTest extends UnitTestCase {
     $request = $this->prophesize(Request::class);
     // Have the request return the desired page parameter.
     $page_param = $this->prophesize(OffsetPage::class);
-    $page_param->getOffset()->willReturn(NULL);
-    $page_param->getSize()->willReturn(NULL);
+    $page_param->offset()->willReturn(NULL);
+    $page_param->size()->willReturn(NULL);
     $request->get('_json_api_params')->willReturn(['page' => $page_param->reveal()]);
     $request->query = new ParameterBag(['amet' => 'pax']);
 
diff --git a/tests/src/Unit/Routing/JsonApiParamEnhancerTest.php b/tests/src/Unit/Routing/JsonApiParamEnhancerTest.php
index 060d40a..a7e2521 100644
--- a/tests/src/Unit/Routing/JsonApiParamEnhancerTest.php
+++ b/tests/src/Unit/Routing/JsonApiParamEnhancerTest.php
@@ -4,9 +4,9 @@ namespace Drupal\Tests\jsonapi\Unit\Routing;
 
 use Drupal\Core\Entity\EntityFieldManagerInterface;
 use Drupal\jsonapi\Routing\JsonApiParamEnhancer;
-use Drupal\jsonapi\Routing\Param\OffsetPage;
-use Drupal\jsonapi\Routing\Param\Filter;
-use Drupal\jsonapi\Routing\Param\Sort;
+use Drupal\jsonapi\Query\OffsetPage;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\Query\Sort;
 use Drupal\jsonapi\Routing\Routes;
 use Drupal\Tests\UnitTestCase;
 use Prophecy\Argument;
@@ -15,6 +15,7 @@ use Symfony\Cmf\Component\Routing\RouteObjectInterface;
 use Symfony\Component\HttpFoundation\ParameterBag;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\Routing\Route;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
 
 /**
  * @coversDefaultClass \Drupal\jsonapi\Routing\JsonApiParamEnhancer
@@ -27,7 +28,8 @@ class JsonApiParamEnhancerTest extends UnitTestCase {
    * @covers ::applies
    */
   public function testApplies() {
-    $object = new JsonApiParamEnhancer($this->prophesize(EntityFieldManagerInterface::class)->reveal());
+    list($filter_normalizer, $sort_normalizer, $page_normalizer) = $this->getMockNormalizers();
+    $object = new JsonApiParamEnhancer($filter_normalizer, $sort_normalizer, $page_normalizer);
     $route = $this->prophesize(Route::class);
     $route->getDefault(RouteObjectInterface::CONTROLLER_NAME)->will(new ReturnPromise([Routes::FRONT_CONTROLLER, 'lorem']));
 
@@ -39,7 +41,8 @@ class JsonApiParamEnhancerTest extends UnitTestCase {
    * @covers ::enhance
    */
   public function testEnhanceFilter() {
-    $object = new JsonApiParamEnhancer($this->prophesize(EntityFieldManagerInterface::class)->reveal());
+    list($filter_normalizer, $sort_normalizer, $page_normalizer) = $this->getMockNormalizers();
+    $object = new JsonApiParamEnhancer($filter_normalizer, $sort_normalizer, $page_normalizer);
     $request = $this->prophesize(Request::class);
     $query = $this->prophesize(ParameterBag::class);
     $query->get('filter')->willReturn(['filed1' => 'lorem']);
@@ -61,7 +64,8 @@ class JsonApiParamEnhancerTest extends UnitTestCase {
    * @covers ::enhance
    */
   public function testEnhancePage() {
-    $object = new JsonApiParamEnhancer($this->prophesize(EntityFieldManagerInterface::class)->reveal());
+    list($filter_normalizer, $sort_normalizer, $page_normalizer) = $this->getMockNormalizers();
+    $object = new JsonApiParamEnhancer($filter_normalizer, $sort_normalizer, $page_normalizer);
     $request = $this->prophesize(Request::class);
     $query = $this->prophesize(ParameterBag::class);
     $query->get('page')->willReturn(['cursor' => 'lorem']);
@@ -79,7 +83,8 @@ class JsonApiParamEnhancerTest extends UnitTestCase {
    * @covers ::enhance
    */
   public function testEnhanceSort() {
-    $object = new JsonApiParamEnhancer($this->prophesize(EntityFieldManagerInterface::class)->reveal());
+    list($filter_normalizer, $sort_normalizer, $page_normalizer) = $this->getMockNormalizers();
+    $object = new JsonApiParamEnhancer($filter_normalizer, $sort_normalizer, $page_normalizer);
     $request = $this->prophesize(Request::class);
     $query = $this->prophesize(ParameterBag::class);
     $query->get('sort')->willReturn('-lorem');
@@ -93,4 +98,24 @@ class JsonApiParamEnhancerTest extends UnitTestCase {
     $this->assertTrue(empty($defaults['_json_api_params']['filter']));
   }
 
+  /**
+   * Builds mock normalizers.
+   */
+  public function getMockNormalizers() {
+    $filter_normalizer = $this->prophesize(DenormalizerInterface::class);
+    $filter_normalizer->denormalize(Argument::any(), Filter::class)->willReturn($this->prophesize(Filter::class)->reveal());
+
+    $sort_normalizer = $this->prophesize(DenormalizerInterface::class);
+    $sort_normalizer->denormalize(Argument::any(), Sort::class)->willReturn($this->prophesize(Sort::class)->reveal());
+
+    $page_normalizer = $this->prophesize(DenormalizerInterface::class);
+    $page_normalizer->denormalize(Argument::any(), OffsetPage::class)->willReturn($this->prophesize(OffsetPage::class)->reveal());
+
+    return [
+      $filter_normalizer->reveal(),
+      $sort_normalizer->reveal(),
+      $page_normalizer->reveal(),
+    ];
+  }
+
 }
diff --git a/tests/src/Unit/Routing/Param/FilterTest.php b/tests/src/Unit/Routing/Param/FilterTest.php
deleted file mode 100644
index 4c17d1a..0000000
--- a/tests/src/Unit/Routing/Param/FilterTest.php
+++ /dev/null
@@ -1,111 +0,0 @@
-<?php
-
-namespace Drupal\Tests\jsonapi\Unit\Routing\Param;
-
-use Drupal\Core\Entity\EntityFieldManagerInterface;
-use Drupal\jsonapi\Routing\Param\Filter;
-use Drupal\Tests\UnitTestCase;
-
-/**
- * @coversDefaultClass \Drupal\jsonapi\Routing\Param\Filter
- * @group jsonapi
- * @group jsonapi_params
- */
-class FilterTest extends UnitTestCase {
-
-  /**
-   * @covers ::get
-   * @dataProvider getProvider
-   */
-  public function testGet($original, $expected) {
-    $pager = new Filter(
-      $original,
-      'lorem',
-      $this->prophesize(EntityFieldManagerInterface::class)->reveal());
-    $this->assertEquals($expected, $pager->get());
-  }
-
-  /**
-   * Data provider for testGet.
-   */
-  public function getProvider() {
-    return [
-    // Tests filter[0][field]=foo&filter[0][value]=bar.
-      [
-        [['path' => 'foo', 'value' => 'bar']],
-        [['condition' => ['path' => 'foo', 'value' => 'bar', 'operator' => '=']]],
-      ],
-// Tests filter[foo][value]=bar.
-      [
-        ['foo' => ['value' => 'bar']],
-        ['foo' => ['condition' => ['path' => 'foo', 'value' => 'bar', 'operator' => '=']]],
-      ],
-      // Tests filter[foo][value]=bar&filter[foo][operator]=>.
-      [
-        ['foo' => ['value' => 'bar', 'operator' => '>']],
-        ['foo' => ['condition' => ['path' => 'foo', 'value' => 'bar', 'operator' => '>']]],
-      ],
-      // Tests filter[foo][value][]=1&filter[foo][value][]=2&filter[foo][value][]=3&filter[foo][operator]=NOT IN.
-      [
-        ['foo' => ['value' => ['1', '2', '3'], 'operator' => 'NOT IN']],
-        ['foo' => ['condition' => ['path' => 'foo', 'value' => ['1', '2', '3'], 'operator' => 'NOT IN']]],
-      ],
-      // Tests filter[foo][value][]=1&filter[foo][value][]=10&filter[foo][operator]=BETWEEN.
-      [
-        ['foo' => ['value' => ['1', '10'], 'operator' => 'BETWEEN']],
-        ['foo' => ['condition' => ['path' => 'foo', 'value' => ['1', '10'], 'operator' => 'BETWEEN']]],
-      ],
-      // Tests filter[0][field]=foo&filter[0][value]=1&filter[0][operator]=>.
-      [
-        [['path' => 'foo', 'value' => '1', 'operator' => '>']],
-        [['condition' => ['path' => 'foo', 'value' => '1', 'operator' => '>']]],
-      ],
-      // Tests filter[0][condition][field]=foo&filter[0][condition][value]=1&filter[0][condition][operator]=>.
-      [
-        [['condition' => ['path' => 'foo', 'value' => '1', 'operator' => '>']]],
-        [['condition' => ['path' => 'foo', 'value' => '1', 'operator' => '>']]],
-      ],
-      // Tests filter[0][field]=foo&filter[0][value][]=bar&filter[0][value][]=baz.
-      [
-        [['path' => 'foo', 'value' => ['bar', 'baz']]],
-        [['condition' => ['path' => 'foo', 'value' => ['bar', 'baz'], 'operator' => '=']]],
-      ],
-      [
-      // Tests filter[0][field]=foo&filter[0][value]=bar&filter[1][condition][field]=baz&filter[1][condition][value]=zab&filter[1][condition][operator]=<>.
-        [
-          0 => ['path' => 'foo', 'value' => 'bar'],
-          1 => ['condition' => ['path' => 'baz', 'value' => 'zab', 'operator' => '<>']],
-        ],
-        [
-          0 => ['condition' => ['path' => 'foo', 'value' => 'bar', 'operator' => '=']],
-          1 => ['condition' => ['path' => 'baz', 'value' => 'zab', 'operator' => '<>']],
-        ],
-      ],
-      [
-      // Tests filter[zero][field]=foo&filter[zero][value]=bar&filter[one][condition][field]=baz&filter[one][condition][value]=zab&filter[one][condition][operator]=<>.
-        [
-          'zero' => ['path' => 'foo', 'value' => 'bar'],
-          'one' => ['condition' => ['path' => 'baz', 'value' => 'zab', 'operator' => '<>']],
-        ],
-        [
-          'zero' => ['condition' => ['path' => 'foo', 'value' => 'bar', 'operator' => '=']],
-          'one' => ['condition' => ['path' => 'baz', 'value' => 'zab', 'operator' => '<>']],
-        ],
-      ],
-    ];
-  }
-
-  /**
-   * @covers ::get
-   * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
-   */
-  public function testGetFail() {
-    $pager = new Filter(
-      'lorem',
-      'ipsum',
-      $this->prophesize(EntityFieldManagerInterface::class)->reveal()
-    );
-    $pager->get();
-  }
-
-}
diff --git a/tests/src/Unit/Routing/Param/OffsetPageTest.php b/tests/src/Unit/Routing/Param/OffsetPageTest.php
deleted file mode 100644
index c4afea5..0000000
--- a/tests/src/Unit/Routing/Param/OffsetPageTest.php
+++ /dev/null
@@ -1,45 +0,0 @@
-<?php
-
-namespace Drupal\Tests\jsonapi\Unit\Routing\Param;
-
-use Drupal\jsonapi\Routing\Param\OffsetPage;
-use Drupal\Tests\UnitTestCase;
-
-/**
- * @coversDefaultClass \Drupal\jsonapi\Routing\Param\OffsetPage
- * @group jsonapi
- */
-class OffsetPageTest extends UnitTestCase {
-
-  /**
-   * @covers ::get
-   * @dataProvider getProvider
-   */
-  public function testGet($original, $max_page, $expected) {
-    $pager = new OffsetPage($original, $max_page);
-    $this->assertEquals($expected, $pager->get());
-  }
-
-  /**
-   * Data provider for testGet.
-   */
-  public function getProvider() {
-    return [
-      [['offset' => 12, 'limit' => 20], 50, ['offset' => 12, 'limit' => 20]],
-      [['offset' => 12, 'limit' => 60], 50, ['offset' => 12, 'limit' => 50]],
-      [['offset' => 12], 50, ['offset' => 12, 'limit' => 50]],
-      [['offset' => 0], 50, ['offset' => 0, 'limit' => 50]],
-      [[], 50, ['limit' => 50]],
-    ];
-  }
-
-  /**
-   * @covers ::get
-   * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
-   */
-  public function testGetFail() {
-    $pager = new OffsetPage('lorem');
-    $pager->get();
-  }
-
-}
diff --git a/tests/src/Unit/Routing/Param/SortTest.php b/tests/src/Unit/Routing/Param/SortTest.php
deleted file mode 100644
index 845811d..0000000
--- a/tests/src/Unit/Routing/Param/SortTest.php
+++ /dev/null
@@ -1,75 +0,0 @@
-<?php
-
-namespace Drupal\Tests\jsonapi\Unit\Routing\Param;
-
-use Drupal\jsonapi\Routing\Param\Sort;
-use Drupal\Tests\UnitTestCase;
-
-/**
- * @coversDefaultClass \Drupal\jsonapi\Routing\Param\Sort
- * @group jsonapi
- */
-class SortTest extends UnitTestCase {
-
-  /**
-   * @covers ::get
-   * @dataProvider getProvider
-   */
-  public function testGet($original, $expected) {
-    $sort = new Sort($original);
-    $this->assertEquals($expected, $sort->get());
-  }
-
-  /**
-   * Data provider for testGet.
-   */
-  public function getProvider() {
-    return [
-      ['lorem', [['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL]]],
-      ['-lorem', [['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL]]],
-      ['-lorem,ipsum', [
-        ['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL],
-        ['path' => 'ipsum', 'direction' => 'ASC', 'langcode' => NULL],
-      ],
-      ],
-      ['-lorem,-ipsum', [
-        ['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL],
-        ['path' => 'ipsum', 'direction' => 'DESC', 'langcode' => NULL],
-      ],
-      ],
-      [[
-        ['path' => 'lorem', 'langcode' => NULL],
-        ['path' => 'ipsum', 'langcode' => 'ca'],
-        ['path' => 'dolor', 'direction' => 'ASC', 'langcode' => 'ca'],
-        ['path' => 'sit', 'direction' => 'DESC', 'langcode' => 'ca'],
-      ], [
-        ['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL],
-        ['path' => 'ipsum', 'direction' => 'ASC', 'langcode' => 'ca'],
-        ['path' => 'dolor', 'direction' => 'ASC', 'langcode' => 'ca'],
-        ['path' => 'sit', 'direction' => 'DESC', 'langcode' => 'ca'],
-      ],
-      ],
-    ];
-  }
-
-  /**
-   * @covers ::get
-   * @dataProvider getFailProvider
-   * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
-   */
-  public function testGetFail($input) {
-    $sort = new Sort($input);
-    $sort->get();
-  }
-
-  /**
-   * Data provider for testGetFail.
-   */
-  public function getFailProvider() {
-    return [
-      [[['lorem']]],
-      [''],
-    ];
-  }
-
-}
