diff --git a/jsonapi.services.yml b/jsonapi.services.yml
index d98a1bd..287805d 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: ['@jsonapi.field_resolver', '@serializer.normalizer.entity_condition.jsonapi', '@serializer.normalizer.entity_condition_group.jsonapi']
+    tags:
+      - { name: normalizer }
+  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/Context/FieldResolver.php b/src/Context/FieldResolver.php
index a7a708d..edeea5b 100644
--- a/src/Context/FieldResolver.php
+++ b/src/Context/FieldResolver.php
@@ -73,14 +73,18 @@ class FieldResolver {
    * Example:
    *   'author.firstName' -> 'field_author.entity.field_first_name'.
    *
-   * @param string $field_name
+   * @param string $entity_type_id
+   *   The type of the entity for which to resolve the field name.
+   * @param string $bundle
+   *   The bundle of the entity for which to resolve the field name.
+   * @param string $external_field_name
    *   The public field name to map to a Drupal field name.
    *
    * @return string
    *   The mapped field name.
    */
-  public function resolveInternal($external_field_name) {
-    $resource_type = $this->currentContext->getResourceType();
+  public function resolveInternal($entity_type_id, $bundle, $external_field_name) {
+    $resource_type = $this->resourceTypeRepository->get($entity_type_id, $bundle);
     if (empty($external_field_name)) {
       throw new BadRequestHttpException('No field name was provided for the filter.');
     }
@@ -93,7 +97,6 @@ class FieldResolver {
     // 'uid.entity.field_category.entity.name'. This may be too simple, but it
     // works for the time being.
     $parts = explode('.', $external_field_name);
-    $entity_type_id = $this->currentContext->getResourceType()->getEntityTypeId();
     $reference_breadcrumbs = [];
     $resource_types = [$resource_type];
     while ($field_name = array_shift($parts)) {
diff --git a/src/Controller/EntityResource.php b/src/Controller/EntityResource.php
index 6fbb175..3536e6c 100644
--- a/src/Controller/EntityResource.php
+++ b/src/Controller/EntityResource.php
@@ -18,14 +18,14 @@ 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\Filter;
+use Drupal\jsonapi\Query\Sort;
+use Drupal\jsonapi\Query\OffsetPage;
 use Drupal\jsonapi\LinkManager\LinkManager;
-use Drupal\jsonapi\Query\QueryBuilder;
 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.
    *
@@ -94,8 +87,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
@@ -105,10 +96,9 @@ class EntityResource {
    * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager
    *   The link manager service.
    */
-  public function __construct(ResourceType $resource_type, EntityTypeManagerInterface $entity_type_manager, QueryBuilder $query_builder, EntityFieldManagerInterface $field_manager, CurrentContext $current_context, FieldTypePluginManagerInterface $plugin_manager, LinkManager $link_manager) {
+  public function __construct(ResourceType $resource_type, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, CurrentContext $current_context, FieldTypePluginManagerInterface $plugin_manager, LinkManager $link_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;
@@ -587,7 +577,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
@@ -595,8 +585,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();
@@ -614,7 +633,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
@@ -622,7 +641,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 d032b2c..73cd1e2 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 a6fd201..27c37b1 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;
@@ -116,14 +116,14 @@ class LinkManager {
     }
     $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/Query/OffsetPagerOption.php b/src/Normalizer/EntityConditionGroupNormalizer.php
similarity index 13%
rename from src/Query/OffsetPagerOption.php
rename to src/Normalizer/EntityConditionGroupNormalizer.php
index 9e08cf3..e8e5d55 100644
--- a/src/Query/OffsetPagerOption.php
+++ b/src/Normalizer/EntityConditionGroupNormalizer.php
@@ -1,57 +1,40 @@
 <?php
 
-namespace Drupal\jsonapi\Query;
+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.
+ *
  * @internal
  */
-class OffsetPagerOption implements QueryOptionInterface {
-
-  /**
-   * The size.
-   *
-   * @var int
-   */
-  protected $size;
+class EntityConditionGroupNormalizer implements DenormalizerInterface {
 
   /**
-   * The offset.
-   *
-   * @var int
+   * {@inheritdoc}
    */
-  protected $offset;
+  protected $supportedInterfaceOrClass = EntityConditionGroup::class;
 
   /**
-   * Creates a PagerOption object.
-   *
-   * @param int $size
-   *   The maximum number of items to return.
-   * @param int $offset
-   *   The starting element.
+   * {@inheritdoc}
    */
-  public function __construct($size, $offset = 0) {
-    $this->size = $size;
-    $this->offset = $offset ?: 0;
-  }
+  protected $formats = ['api_json'];
 
   /**
    * {@inheritdoc}
    */
-  public function id() {
-    return 'offset_pager';
+  public function supportsDenormalization($data, $type, $format = null) {
+    return $type === $this->supportedInterfaceOrClass;
   }
 
   /**
    * {@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;
+  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..3668760
--- /dev/null
+++ b/src/Normalizer/EntityConditionNormalizer.php
@@ -0,0 +1,93 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\jsonapi\Query\EntityCondition;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+
+/**
+ * The normalizer used for entity conditions.
+ *
+ * @internal
+ */
+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';
+
+  /**
+   * {@inheritdoc}
+   */
+  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 = []) {
+    $this->validate($data);
+    $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);
+  }
+
+  /**
+   * Validates the filter has the required fields.
+   */
+  protected function validate($data) {
+    if (!in_array($data[static::OPERATOR_KEY], EntityCondition::$allowedOperators)) {
+      $reason = "The '" . $data[static::OPERATOR_KEY] . "' operator is not allowed in a filter parameter.";
+      throw new BadRequestHttpException($reason);
+    }
+
+    if (count(array_diff([static::PATH_KEY, static::VALUE_KEY], array_keys($data)))) {
+      $reason = "All filters must provide at least a valid path and value.";
+
+      if (!isset($data[static::PATH_KEY])) {
+        $reason .= " Missing '" . static::PATH_KEY . "' key.";
+      }
+      elseif (!isset($data[static::VALUE_KEY])) {
+        $reason .= " Missing '" . static::VALUE . "' key.";
+      }
+
+      throw new BadRequestHttpException($reason);
+    }
+
+    if (in_array($data[static::OPERATOR_KEY], ['IS NULL', 'IS NOT NULL']) && isset($data[static::VALUE_KEY])) {
+      $reason = "Filters using the '" . $data[static::OPERATOR_KEY] . "' operator should not provide a value.";
+      throw new BadRequestHttpException($reason);
+    }
+  }
+
+}
diff --git a/src/Routing/Param/Filter.php b/src/Normalizer/FilterNormalizer.php
similarity index 11%
rename from src/Routing/Param/Filter.php
rename to src/Normalizer/FilterNormalizer.php
index 1fd14dd..0d76dcc 100644
--- a/src/Routing/Param/Filter.php
+++ b/src/Normalizer/FilterNormalizer.php
@@ -1,19 +1,23 @@
 <?php
 
-namespace Drupal\jsonapi\Routing\Param;
+namespace Drupal\jsonapi\Normalizer;
 
-use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
-use Drupal\Component\Render\FormattableMarkup;
+use Drupal\jsonapi\Context\FieldResolver;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\Normalizer\EntityConditionNormalizer;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
 
 /**
+ * The normalizer used for JSON API filters.
+ *
  * @internal
  */
-class Filter extends JsonApiParamBase {
+class FilterNormalizer implements DenormalizerInterface {
 
   /**
-   * {@inheritdoc}
+   * The key for the implicit root group.
    */
-  const KEY_NAME = 'filter';
+  const ROOT_ID = '@root';
 
   /**
    * Key in the filter[<key>] parameter for conditions.
@@ -37,48 +41,105 @@ class Filter extends JsonApiParamBase {
   const MEMBER_KEY = 'memberOf';
 
   /**
-   * The field key in the filter condition: filter[lorem][condition][<field>].
+   * The interface or class that this Normalizer supports.
    *
    * @var string
    */
-  const PATH_KEY = 'path';
+  protected $supportedInterfaceOrClass = Filter::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $formats = ['api_json'];
 
   /**
-   * The value key in the filter condition: filter[lorem][condition][<value>].
+   * The entity condition denormalizer.
    *
-   * @var string
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
    */
-  const VALUE_KEY = 'value';
+  protected $conditionDenormalizer;
 
   /**
-   * The operator key in the condition: filter[lorem][condition][<operator>].
+   * The entity condition group denormalizer.
    *
-   * @var string
+   * @var \Symfony\Component\Serializer\Normalizer\DenormalizerInterface
    */
-  const OPERATOR_KEY = 'operator';
+  protected $groupDenormalizer;
 
   /**
-   * The conjunction key in the condition: filter[lorem][group][<conjunction>].
+   * The field resolver service.
    *
-   * @var string
+   * @var \Drupal\jsonapi\Context\FieldResolver
    */
-  const CONJUNCTION_KEY = 'conjunction';
+  protected $fieldResolver;
 
   /**
    * {@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.');
-    }
+  public function __construct(FieldResolver $field_resolver, DenormalizerInterface $condition_denormalizer, DenormalizerInterface $group_denormalizer) {
+    $this->fieldResolver = $field_resolver;
+    $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, $context);
+    $denormalized = $this->denormalizeItems($expanded);
+    return new Filter($denormalized);
+  }
+
+  /**
+   * Expands any filter parameters using shorthand notation.
+   *
+   * @param array $original
+   *   The unexpanded filter data.
+   * @param array $context
+   *   The denormalization context.
+   *
+   * @return array
+   *   The expanded filter data.
+   */
+  protected function expand(array $original, array $context) {
     $expanded = [];
-    foreach ($this->original as $filter_index => $filter_item) {
-      $expanded_item = $this->expandItem($filter_index, $filter_item);
-      $this->validateItem($filter_index, $expanded_item);
-      $expanded[$filter_index] = $expanded_item;
+    $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, $context);
     }
+
     return $expanded;
   }
 
@@ -87,224 +148,100 @@ class Filter extends JsonApiParamBase {
    *
    * Possible cases for the conditions:
    *   1. filter[uuid][value]=1234.
-   *   2. filter[0][condition][path]=uuid&filter[0][condition][value]=1234.
-   *   3. filter[uuid][value]=1234&filter[uuid][path]=uuid&
-   *      filter[uuid][operator]==&filter[uuid][memberOf]=my_group.
+   *   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 mixed $filter_item
+   * @param array $filter_item
    *   The raw filter item.
+   * @param array $context
+   *   The denormalization context.
    *
    * @return array
    *   The expanded filter item.
    */
-  protected function expandItem($filter_index, $filter_item) {
-    // Expand shorthand.
-    if (isset($filter_item[static::VALUE_KEY])) {
-      if (!isset($filter_item[static::PATH_KEY])) {
-        $filter_item[static::PATH_KEY] = $filter_index;
+  protected function expandItem($filter_index, array $filter_item, array $context) {
+    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][static::OPERATOR_KEY])) {
-        $filter_item[static::CONDITION_KEY][static::OPERATOR_KEY] = '=';
-      }
-    }
-    // Expand IS (NOT) NULL items.
-    elseif (
-      isset($filter_item[static::CONDITION_KEY][static::OPERATOR_KEY]) &&
-      in_array($filter_item[static::CONDITION_KEY][static::OPERATOR_KEY], ['IS NULL', 'IS NOT NULL'])
-    ) {
-      // This is not strictly necessary, but it simplifies validation.
-      $filter_item[static::CONDITION_KEY][static::VALUE_KEY] = NULL;
-    }
-
-    return $filter_item;
-  }
-
-  /**
-   * Makes sure every filter item contains valid data.
-   *
-   * @param string $filter_index
-   *   The index.
-   * @param mixed $filter_item
-   *   The expanded filter item.
-   *
-   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
-   *   If the filter is malformed.
-   */
-  protected function validateItem($filter_index, $filter_item) {
-    // Make sure the current filter item is an array. So far we don't allow
-    // filter queries like filter[nid]=1.
-    if (!is_array($filter_item)) {
-      $message = new FormattableMarkup(
-        'Filter query for "@index" must be array.',
-        ['@index' => $filter_index]
-      );
-      throw new BadRequestHttpException($message);
     }
 
-    // We do not allow having both "condition" and "group" for the same filter
-    // item. So for example this condition should fail:
-    // - filter[id][condition][path]=nid
-    // - filter[id][condition][value]=123
-    // - filter[id][condition][operator]==
-    // - filter[id][group][conjunction]=AND.
-    $filter_keys = array_keys($filter_item);
-    $allowed_filter_keys = [static::CONDITION_KEY, static::GROUP_KEY];
-    if (count($filter_keys) > 1 || !in_array($filter_keys[0], $allowed_filter_keys)) {
-      $message = new FormattableMarkup(
-        'Filter query for "@index" should only contain either "@condkey" or "@groupkey" as a top-level key, but not both.',
-        [
-          '@index' => $filter_index,
-          '@condkey' => static::CONDITION_KEY,
-          '@groupkey' => static::GROUP_KEY,
-        ]
-      );
-      throw new BadRequestHttpException($message);
+    if (!isset($filter_item[static::CONDITION_KEY][EntityConditionNormalizer::OPERATOR_KEY])) {
+      $filter_item[static::CONDITION_KEY][EntityConditionNormalizer::OPERATOR_KEY] = '=';
     }
 
-    // Handle full canonical form:
-    // - filter[id][condition][path]=nid
-    // - filter[id][condition][value]=123
-    // - filter[id][condition][operator]==
-    // - filter[id][condition][memberOf]=some-group.
-    if (isset($filter_item[static::CONDITION_KEY])) {
-      $this->validateConditionItem($filter_index, $filter_item);
-    }
-    // Validating filter groups. Example of group:
-    // filter[and-group][group][conjunction]=AND
-    // filter[and-group][group][memberOf]=another-group.
-    elseif (isset($filter_item[static::GROUP_KEY])) {
-      $this->validateGroupItem($filter_index, $filter_item);
+    if (isset($filter_item[static::CONDITION_KEY][EntityConditionNormalizer::PATH_KEY])) {
+      $filter_item[static::CONDITION_KEY][EntityConditionNormalizer::PATH_KEY] = $this->fieldResolver->resolveInternal(
+          $context['entity_type_id'],
+          $context['bundle'],
+          $filter_item[static::CONDITION_KEY][EntityConditionNormalizer::PATH_KEY]
+        );
     }
 
+    return $filter_item;
   }
 
   /**
-   * Makes sure a condition in a filter item contains valid data.
+   * Denormalizes the given filter items into a single EntityConditionGroup.
    *
-   * @param string $filter_index
-   *   The index.
-   * @param mixed $filter_item
-   *   The expanded filter item.
+   * @param array $items
+   *   The normalized entity conditions and groups.
    *
-   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
-   *   If the filter is malformed.
+   * @return \Drupal\jsonapi\Query\EntityConditionGroup
+   *   A root group containing all the denormalized conditions and groups.
    */
-  protected function validateConditionItem($filter_index, $filter_item) {
-    // List of allowed keys for adding a new condition to the query.
-    $expected_keys = [
-      static::PATH_KEY,
-      static::VALUE_KEY,
-      static::OPERATOR_KEY,
-      static::MEMBER_KEY,
+  protected function denormalizeItems(array $items) {
+    $root = [
+      'id' => static::ROOT_ID,
+      static::GROUP_KEY => ['conjunction' => 'AND'],
     ];
-
-    // Get keys sent by the client.
-    $item_keys = array_keys($filter_item[static::CONDITION_KEY]);
-
-    // If the client sent any keys outside of allowed, we should return an
-    // error to indicate that the desired set of params is not going to work.
-    $unexpected_keys = array_diff($item_keys, $expected_keys);
-    if (!empty($unexpected_keys)) {
-      $message = new FormattableMarkup(
-        'Filter query for "@index" contains not expected arguments: @invalid_args. Valid arguments: @valid_args',
-        [
-          '@index' => $filter_index,
-          '@invalid_args' => implode(',', $unexpected_keys),
-          '@valid_args' => implode(',', $expected_keys),
-        ]
-      );
-      throw new BadRequestHttpException($message);
-    }
-
-    // It is required to set the next keys for full canonical filter
-    // representation.
-    $mandatory_keys = [static::PATH_KEY, static::VALUE_KEY];
-
-    // If any mandatory key is missing - report back to the client.
-    $missing_mandatory_keys = array_diff($mandatory_keys, $item_keys);
-    if (!empty($missing_mandatory_keys)) {
-      $message = new FormattableMarkup(
-        'Filter query for "@index" missing mandatory params: @missing_params.',
-        [
-          '@index' => $filter_index,
-          '@missing_params' => implode(',', $missing_mandatory_keys),
-        ]
-      );
-      throw new BadRequestHttpException($message);
-    }
+    return $this->buildTree($root, $items);
   }
 
   /**
-   * Makes sure a condition in a filter item contains valid data.
+   * Organizes the flat, normalized filter items into a tree structure.
    *
-   * @param string $filter_index
-   *   The index.
-   * @param mixed $filter_item
-   *   The expanded filter item.
+   * @param array $items
+   *   The normalized entity conditions and groups.
    *
-   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
-   *   If the filter is malformed.
+   * @return array
+   *   A structured multidimensional array.
    */
-  protected function validateGroupItem($filter_index, $filter_item) {
-    // List of allowed keys for adding a new filter group.
-    $expected_keys = [static::CONJUNCTION_KEY, static::MEMBER_KEY];
-
-    // If the client sent any keys outside of allowed, we should return an
-    // error to indicate that the desired set of params is not going to work.
-    $item_keys = array_keys($filter_item[static::GROUP_KEY]);
-    $unexpected_keys = array_diff($item_keys, $expected_keys);
-    if (!empty($unexpected_keys)) {
-      $message = new FormattableMarkup(
-        'Filter query for "@index" contains unexpected arguments: @invalid_args. Valid arguments: @valid_args',
-        [
-          '@index' => $filter_index,
-          '@invalid_args' => implode(',', $unexpected_keys),
-          '@valid_args' => implode(',', $expected_keys),
-        ]
-      );
-      throw new BadRequestHttpException($message);
-    }
-
-    // If any mandatory key is missing - report back to the client.
-    $mandatory_keys = [static::CONJUNCTION_KEY];
-    $missing_mandatory_keys = array_diff($mandatory_keys, $item_keys);
-    if (!empty($missing_mandatory_keys)) {
-      $message = new FormattableMarkup(
-        'Filter query for "@index" missing mandatory params: @missing_params.',
-        [
-          '@index' => $filter_index,
-          '@missing_params' => implode(',', $missing_mandatory_keys),
-        ]
-      );
-      throw new BadRequestHttpException($message);
+  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);
+        }
+      }
     }
 
-    // Make sure the conjunction value is correct.
-    $allowed_conjunction = ['AND', 'OR', 'and', 'or'];
-    if (!in_array($filter_item[static::GROUP_KEY][static::CONJUNCTION_KEY], $allowed_conjunction)) {
-      $message = new FormattableMarkup(
-        'Filter query for "@index" contains invalid conjunction operator. Allowed values: AND, OR.',
-        ['@index' => $filter_index]
-      );
-      throw new BadRequestHttpException($message);
-    }
+    $root[static::GROUP_KEY]['members'] = $members;
 
-    // We do not allow having anything other than "group" in the filter query.
-    // So for example this condition should fail:
-    // - filter[][group][conjunction]=AND
-    // - filter[][nid][value]=123.
-    if (count($filter_item) > 1) {
-      $message = new FormattableMarkup(
-        'Filter query for "@index" should contain only "@key" as a top-level key.',
-        ['@index' => $filter_index, '@key' => static::GROUP_KEY]);
-      throw new BadRequestHttpException($message);
-    }
+    // Denormalize the root into a condition group.
+    return $this->groupDenormalizer->denormalize(
+      $root[static::GROUP_KEY],
+      EntityConditionGroup::class
+    );
   }
 
 }
diff --git a/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php b/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
index 00fa121..221ad0c 100644
--- a/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
+++ b/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
@@ -245,7 +245,7 @@ class JsonApiDocumentTopLevelNormalizer extends NormalizerBase implements Denorm
     // Translate ALL the includes from the public field names to the internal.
     $includes = array_filter(explode(',', $request->query->get('include')));
     $public_includes = array_map(function ($include_str) use ($resource_type) {
-      $resolved = $this->fieldResolver->resolveInternal($include_str);
+      $resolved = $this->fieldResolver->resolveInternal($resource_type->getEntityTypeId(), $resource_type->getBundle(), $include_str);
       // We don't need the entity information for the includes. Clean it.
       return preg_replace('/\.entity\./', '.', $resolved);
     }, $includes);
diff --git a/src/Routing/Param/JsonApiParamBase.php b/src/Normalizer/OffsetPageNormalizer.php
similarity index 11%
rename from src/Routing/Param/JsonApiParamBase.php
rename to src/Normalizer/OffsetPageNormalizer.php
index 21cb503..373d439 100644
--- a/src/Routing/Param/JsonApiParamBase.php
+++ b/src/Normalizer/OffsetPageNormalizer.php
@@ -1,69 +1,58 @@
 <?php
 
-namespace Drupal\jsonapi\Routing\Param;
+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.
+ *
  * @internal
  */
-class JsonApiParamBase implements JsonApiParamInterface {
-
-  /**
-   * The original data.
-   *
-   * @var string|string[]
-   */
-  protected $original;
+class OffsetPageNormalizer implements DenormalizerInterface {
 
   /**
-   * The expanded data.
+   * The interface or class that this Normalizer supports.
    *
-   * @var string|string[]
+   * @var string
    */
-  protected $data;
-
-  /**
-   * Create a parameter object.
-   *
-   * @param string|string[] $original
-   *   The user generated data.
-   */
-  public function __construct($original) {
-    $this->original = $original;
-  }
+  protected $supportedInterfaceOrClass = OffsetPage::class;
 
   /**
    * {@inheritdoc}
    */
-  public function get() {
-    if (!$this->data) {
-      $this->data = $this->expand();
-    }
-    return $this->data;
+  public function supportsDenormalization($data, $type, $format = null) {
+    return $type == $this->supportedInterfaceOrClass;
   }
 
   /**
    * {@inheritdoc}
    */
-  public function getOriginal() {
-    return $this->original;
+  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}
    */
-  public function getKey() {
-    return static::KEY_NAME;
-  }
+  protected function expand($data) {
+    if (!is_array($data)) {
+      throw new BadRequestHttpException('The page parameter needs to be an array.');
+    }
 
-  /**
-   * 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;
+    $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 65%
rename from src/Routing/Param/Sort.php
rename to src/Normalizer/SortNormalizer.php
index eee57d9..edc3743 100644
--- a/src/Routing/Param/Sort.php
+++ b/src/Normalizer/SortNormalizer.php
@@ -1,52 +1,44 @@
 <?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;
 
 /**
+ * The normalizer used for JSON API sorts.
+ *
  * @internal
  */
-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 +71,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 +96,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/EntityCondition.php
similarity index 17%
rename from src/Query/ConditionOption.php
rename to src/Query/EntityCondition.php
index 05e2d32..d1c2672 100644
--- a/src/Query/ConditionOption.php
+++ b/src/Query/EntityCondition.php
@@ -2,100 +2,81 @@
 
 namespace Drupal\jsonapi\Query;
 
-/**
- * A ConditionOption represents an option which can be applied to a query.
- *
- * @internal
- */
-class ConditionOption implements QueryOptionInterface {
+class EntityCondition {
 
   /**
-   * A unique key.
+   * The allowed condition operators.
    *
-   * @var string
-   */
-  protected $id;
-
-  /**
-   * A unique key representing the intended parent of this option.
-   *
-   * @var string|null
+   * @var string[]
    */
-  protected $parentId;
+  public static $allowedOperators = [
+    '=', '<>',
+    '>', '>=', '<', '<=',
+    'STARTS_WITH', 'CONTAINS', 'ENDS_WITH',
+    'IN', 'NOT IN',
+    'BETWEEN', 'NOT BETWEEN',
+    'IS NULL', 'IS NOT NULL',
+  ];
 
   /**
-   * String representation of the entity field in to be checked.
+   * The field to be evaluated.
    *
    * @var string
    */
   protected $field;
 
   /**
-   * Value of the condition for the given field.
-   *
-   * @var string|string[]
-   */
-  protected $value;
-
-  /**
-   * Conditional operator with which to compare values.
+   * The condition operator.
    *
    * @var string
    */
   protected $operator;
 
   /**
-   * The langcode of the field to check.
+   * The value against which the field should be evaluated.
    *
-   * @var string
+   * @var mixed
    */
-  protected $langcode;
+  protected $value;
 
   /**
-   * 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.
+   * Constructs a new EntityCondition object.
    */
-  public function __construct($id, $field, $value = NULL, $operator = NULL, $langcode = NULL, $parent_id = NULL) {
-    $this->id = $id;
+  public function __construct($field, $value, $operator = NULL) {
     $this->field = $field;
     $this->value = $value;
-    $this->operator = $operator;
-    $this->langcode = $langcode;
-    $this->parentId = $parent_id;
+    $this->operator = ($operator) ? $operator : '=';
   }
 
   /**
-   * {@inheritdoc}
+   * The field to be evaluated.
+   *
+   * @return string
    */
-  public function id() {
-    return $this->id;
+  public function field() {
+    return $this->field;
   }
 
   /**
-   * {@inheritdoc}
+   * The comparison operator to use for the evaluation.
+   *
+   * For a list of allowed operators:
+   *
+   * @see \Drupal\jsonapi\Query\EntityCondition::allowedOperators
+   *
+   * @return string
    */
-  public function apply($query) {
-    return $query->condition($this->field, $this->value, $this->operator, $this->langcode);
+  public function operator() {
+    return $this->operator;
   }
 
   /**
-   * Returns the id of this option's parent.
+   * The value against which the condition should be evaluated.
    *
-   * @return string|null
-   *   Either the id of its parent or NULL.
+   * @return mixed
    */
-  public function parentId() {
-    return $this->parentId;
+  public function value() {
+    return $this->value;
   }
 
 }
diff --git a/src/Routing/Param/JsonApiParamInterface.php b/src/Query/EntityConditionGroup.php
similarity index 10%
rename from src/Routing/Param/JsonApiParamInterface.php
rename to src/Query/EntityConditionGroup.php
index 695aa37..2723565 100644
--- a/src/Routing/Param/JsonApiParamInterface.php
+++ b/src/Query/EntityConditionGroup.php
@@ -1,44 +1,60 @@
 <?php
 
-namespace Drupal\jsonapi\Routing\Param;
+namespace Drupal\jsonapi\Query;
 
-/**
- * @internal
- */
-interface JsonApiParamInterface {
+class EntityConditionGroup {
 
   /**
-   * The key name.
-   *
-   * This must be redefined with a unique value in each class that extends
-   * from JsonApiParamInterface.
+   * The AND conjunction value.
+   */
+  protected static $allowedConjunctions = ['AND', 'OR'];
+
+  /**
+   * The conjunction.
    *
    * @var string
    */
-  const KEY_NAME = NULL;
+  protected $conjunction;
 
   /**
-   * Gets the original parsed query string param.
+   * The members of the condition group.
    *
-   * @return string|string[]
-   *   The original value.
+   * @var \Drupal\jsonapi\Query\EntityCondition[]
    */
-  public function getOriginal();
+  protected $members;
 
   /**
-   * Gets the expanded value with defaults.
+   * Constructs a new condition group object.
    *
-   * @return string|string[]
-   *   The query string value.
+   * @param string $conjunction
+   *   The group conjunction to use.
+   * @param array $members
+   *   (optional) The group conjunction to use.
    */
-  public function get();
+  public function __construct($conjunction, $members = []) {
+    if (!in_array($conjunction, self::$allowedConjunctions)) {
+      throw new \InvalidArgumentException('Allowed conjunctions: AND, OR.');
+    }
+    $this->conjunction = $conjunction;
+    $this->members = $members;
+  }
 
   /**
-   * Gets the key of the parameter.
+   * The condition group conjunction.
    *
    * @return string
-   *   The key.
    */
-  public function getKey();
+  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/GroupOption.php b/src/Query/Filter.php
similarity index 10%
rename from src/Query/GroupOption.php
rename to src/Query/Filter.php
index d51a20c..78880f6 100644
--- a/src/Query/GroupOption.php
+++ b/src/Query/Filter.php
@@ -2,152 +2,103 @@
 
 namespace Drupal\jsonapi\Query;
 
-/**
- * A GroupOption can group other options before applying them to a query.
- *
- * @internal
- */
-class GroupOption implements QueryOptionInterface, QueryOptionTreeItemInterface {
+use Drupal\Core\Entity\Query\QueryInterface;
+use Drupal\jsonapi\Query\EntityCondition;
+use Drupal\jsonapi\Query\EntityConditionGroup;
 
-  /**
-   * A unique key.
-   *
-   * @var string
-   */
-  protected $id;
+class Filter {
 
   /**
-   * A unique key representing a parent condition group.
+   * The JSON API filter key name.
    *
    * @var string
    */
-  protected $parentGroup;
+  const KEY_NAME = 'filter';
 
   /**
-   * 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.
+   * The root condition group.
    *
    * @var string
    */
-  protected $conjunction;
+  protected $root;
 
   /**
-   * Constructs a new GroupOption.
+   * Constructs a new Filter object.
    *
-   * @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.
+   * @param \Drupal\jsonapi\Query\EntityConditionGroup $root
+   *   An entity condition group which can be applied to an entity query.
    */
-  public function __construct($id, $conjunction = 'AND', $parent_group = NULL) {
-    $this->id = $id;
-    $this->conjunction = $conjunction;
-    $this->parentGroup = $parent_group;
+  public function __construct(EntityConditionGroup $root) {
+    $this->root = $root;
   }
 
   /**
-   * {@inheritdoc}
+   * Gets the root condition group.
    */
-  public function id() {
-    return $this->id;
+  public function root() {
+    return $this->root;
   }
 
   /**
-   * {@inheritdoc}
+   * 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 parentId() {
-    return $this->parentGroup;
+  public function queryCondition(QueryInterface $query) {
+    $condition = $this->buildGroup($query, $this->root());
+    return $condition;
   }
 
   /**
-   * {@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}
+   * 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.
    */
-  public function apply($query) {
-    switch ($this->conjunction) {
-      case 'OR':
-        $group = $query->orConditionGroup();
-        break;
-
+  protected function buildGroup(QueryInterface $query, EntityConditionGroup $condition_group) {
+    // Create a condition group using the original query.
+    switch ($condition_group->conjunction()) {
       case 'AND':
-      default:
         $group = $query->andConditionGroup();
         break;
+      case 'OR':
+        $group = $query->orConditionGroup();
+        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;
+    // 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) {
+        if ($member->operator() == 'IS NULL') {
+          $group->notExists($member->field());
+        }
+        else if ($member->operator() == 'IS NOT NULL') {
+          $group->exists($member->field());
+        }
+        else {
+          $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);
+      }
     }
 
-    // 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);
+    // Return the constructed group so that it can be added to the query.
+    return $group;
   }
 
 }
diff --git a/src/Routing/Param/OffsetPage.php b/src/Query/OffsetPage.php
similarity index 26%
rename from src/Routing/Param/OffsetPage.php
rename to src/Query/OffsetPage.php
index 2de1b22..97a2e2f 100644
--- a/src/Routing/Param/OffsetPage.php
+++ b/src/Query/OffsetPage.php
@@ -1,53 +1,74 @@
 <?php
 
-namespace Drupal\jsonapi\Routing\Param;
+namespace Drupal\jsonapi\Query;
 
 use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
 
 /**
  * @internal
  */
-class OffsetPage extends JsonApiParamBase {
+class OffsetPage {
 
   /**
-   * {@inheritdoc}
+   * 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
    */
-  public static $maxSize = 50;
+  const SIZE_MAX = 50;
 
   /**
-   * Instantiates an OffsetPage object.
+   * The offset for the query.
    *
-   * @param string|\string[] $original
-   *   The original user generated data.
-   * @param int $max_size
-   *   The maximum size for the pager.
+   * @var int
    */
-  public function __construct($original, $max_size = NULL) {
-    parent::__construct($original);
-    if ($max_size) {
-      static::$maxSize = $max_size;
-    }
-  }
+  protected $offset;
+
+  /**
+   * The size of the query.
+   *
+   * @var int
+   */
+  protected $size;
 
   /**
-   * {@inheritdoc}
+   * Instantiates an OffsetPage object.
+   *
+   * @param int $offset
+   *   The query offset.
+   * @param int $size
+   *   The query size limit
    */
-  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;
+  public function __construct($offset, $size) {
+    $this->offset = $offset;
+    $this->size = $size;
   }
 
   /**
@@ -55,9 +76,8 @@ class OffsetPage extends JsonApiParamBase {
    *
    * @return int
    */
-  public function getOffset() {
-    $data = $this->get();
-    return isset($data['offset']) ? $data['offset'] : 0;
+  public function offset() {
+    return $this->offset;
   }
 
   /**
@@ -65,10 +85,8 @@ class OffsetPage extends JsonApiParamBase {
    *
    * @return int
    */
-  public function getSize() {
-    $data = $this->get();
-    $size = isset($data['limit']) ? $data['limit'] : static::$maxSize;
-    return $size > static::$maxSize ? static::$maxSize : $size;
+  public function size() {
+    return $this->size;
   }
 
 }
diff --git a/src/Query/QueryBuilder.php b/src/Query/QueryBuilder.php
deleted file mode 100644
index 6a01b36..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->options = [];
-    $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/SortOption.php b/src/Query/Sort.php
similarity index 19%
rename from src/Query/SortOption.php
rename to src/Query/Sort.php
index 54901f8..da8dd9d 100644
--- a/src/Query/SortOption.php
+++ b/src/Query/Sort.php
@@ -2,70 +2,71 @@
 
 namespace Drupal\jsonapi\Query;
 
-/**
- * @internal
- */
-class SortOption implements QueryOptionInterface {
+class Sort {
 
   /**
-   * A unique key.
+   * The JSON API sort key name.
    *
    * @var string
    */
-  protected $id;
+  const KEY_NAME = 'sort';
 
   /**
-   * The field by which to sort.
+   * The field key in the sort parameter: sort[lorem][<field>].
    *
    * @var string
    */
-  protected $field;
+  const PATH_KEY = 'path';
 
   /**
-   * The direction of the sort.
+   * The direction key in the sort parameter: sort[lorem][<direction>].
    *
    * @var string
    */
-  protected $direction;
+  const DIRECTION_KEY = 'direction';
 
   /**
-   * The langcode for the sort.
+   * The langcode key in the sort parameter: sort[lorem][<langcode>].
    *
    * @var string
    */
-  protected $langcode;
+  const LANGUAGE_KEY = 'langcode';
 
   /**
-   * Creates a SortOption object.
+   * The fields on which to sort.
    *
-   * @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.
+   * @var string
    */
-  public function __construct($id, $field, $direction = 'ASC', $langcode = NULL) {
-    $this->id = $id;
-    $this->field = $field;
-    $this->direction = $direction;
-    $this->langcode = $langcode;
-  }
+  protected $fields;
 
   /**
-   * {@inheritdoc}
+   * 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 id() {
-    return $this->id;
+  public function __construct(array $fields) {
+    $this->fields = $fields;
   }
 
   /**
-   * {@inheritdoc}
+   * Gets the root condition group.
    */
-  public function apply($query) {
-    return $query->sort($this->field, $this->direction);
+  public function fields() {
+    return $this->fields;
   }
 
 }
diff --git a/src/Routing/JsonApiParamEnhancer.php b/src/Routing/JsonApiParamEnhancer.php
index b0274d9..c43bfbb 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,28 @@ class JsonApiParamEnhancer implements RouteEnhancerInterface {
    */
   public function enhance(array $defaults, Request $request) {
     $options = [];
+
+    $route = $defaults[RouteObjectInterface::ROUTE_OBJECT];
+    $context = [
+      'entity_type_id' => $route->getRequirement('_entity_type'),
+      'bundle' => $route->getRequirement('_bundle'),
+    ];
+
     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, NULL, $context);
     }
+
     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/tests/src/Functional/JsonApiFunctionalTest.php b/tests/src/Functional/JsonApiFunctionalTest.php
index 4236086..a0e38f5 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%5Boffset%5D=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/Context/FieldResolverTest.php b/tests/src/Kernel/Context/FieldResolverTest.php
index 441119e..932c1aa 100644
--- a/tests/src/Kernel/Context/FieldResolverTest.php
+++ b/tests/src/Kernel/Context/FieldResolverTest.php
@@ -145,30 +145,26 @@ class FieldResolverTest extends JsonapiKernelTestBase {
   }
 
   public function testResolveInternal() {
-    $request = Request::create('/jsonapi/entity_test_with_bundle/bundle1');
-    $route = \Drupal::service('router.route_provider')->getRouteByName('jsonapi.entity_test_with_bundle--bundle1.collection');
-    $request->attributes->set(RouteObjectInterface::ROUTE_NAME, 'jsonapi.entity_test_with_bundle--bundle1.collection');
-    $request->attributes->set(RouteObjectInterface::ROUTE_OBJECT, $route);
-
-    \Drupal::requestStack()->push($request);
-
-    $this->assertEquals('field_test1', $this->sut->resolveInternal('field_test1'));
-    $this->assertEquals('field_test2', $this->sut->resolveInternal('field_test2'));
-    $this->assertEquals('field_test3', $this->sut->resolveInternal('field_test3'));
-
-    $this->assertEquals('field_test_ref1.entity.field_test1', $this->sut->resolveInternal('field_test_ref1.field_test1'));
-    $this->assertEquals('field_test_ref1.entity.field_test2', $this->sut->resolveInternal('field_test_ref1.field_test2'));
-    $this->assertEquals('field_test_ref2.entity.field_test1', $this->sut->resolveInternal('field_test_ref2.field_test1'));
-    $this->assertEquals('field_test_ref2.entity.field_test2', $this->sut->resolveInternal('field_test_ref2.field_test2'));
-    $this->assertEquals('field_test_ref3.entity.field_test1', $this->sut->resolveInternal('field_test_ref3.field_test1'));
-    $this->assertEquals('field_test_ref3.entity.field_test2', $this->sut->resolveInternal('field_test_ref3.field_test2'));
-
-    $this->assertEquals('field_test_ref1.entity.field_test_text', $this->sut->resolveInternal('field_test_ref1.field_test_text'));
-    $this->assertEquals('field_test_ref1.entity.field_test_text.value', $this->sut->resolveInternal('field_test_ref1.field_test_text.value'));
-    $this->assertEquals('field_test_ref1.entity.field_test_text.format', $this->sut->resolveInternal('field_test_ref1.field_test_text.format'));
-    $this->assertEquals('field_test_ref2.entity.field_test_text', $this->sut->resolveInternal('field_test_ref2.field_test_text'));
-    $this->assertEquals('field_test_ref2.entity.field_test_text.value', $this->sut->resolveInternal('field_test_ref2.field_test_text.value'));
-    $this->assertEquals('field_test_ref2.entity.field_test_text.format', $this->sut->resolveInternal('field_test_ref2.field_test_text.format'));
+    $entity_type_id = 'entity_test_with_bundle';
+    $bundle = 'bundle1';
+
+    $this->assertEquals('field_test1', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test1'));
+    $this->assertEquals('field_test2', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test2'));
+    $this->assertEquals('field_test3', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test3'));
+
+    $this->assertEquals('field_test_ref1.entity.field_test1', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref1.field_test1'));
+    $this->assertEquals('field_test_ref1.entity.field_test2', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref1.field_test2'));
+    $this->assertEquals('field_test_ref2.entity.field_test1', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref2.field_test1'));
+    $this->assertEquals('field_test_ref2.entity.field_test2', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref2.field_test2'));
+    $this->assertEquals('field_test_ref3.entity.field_test1', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref3.field_test1'));
+    $this->assertEquals('field_test_ref3.entity.field_test2', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref3.field_test2'));
+
+    $this->assertEquals('field_test_ref1.entity.field_test_text', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref1.field_test_text'));
+    $this->assertEquals('field_test_ref1.entity.field_test_text.value', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref1.field_test_text.value'));
+    $this->assertEquals('field_test_ref1.entity.field_test_text.format', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref1.field_test_text.format'));
+    $this->assertEquals('field_test_ref2.entity.field_test_text', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref2.field_test_text'));
+    $this->assertEquals('field_test_ref2.entity.field_test_text.value', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref2.field_test_text.value'));
+    $this->assertEquals('field_test_ref2.entity.field_test_text.format', $this->sut->resolveInternal($entity_type_id, $bundle, 'field_test_ref2.field_test_text.format'));
   }
 
   /**
@@ -179,21 +175,10 @@ class FieldResolverTest extends JsonapiKernelTestBase {
    * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
    */
   public function testResolveInternalError() {
-    $request = Request::create('/jsonapi/entity_test_with_bundle/bundle1');
-    $route = \Drupal::service('router.route_provider')
-      ->getRouteByName('jsonapi.entity_test_with_bundle--bundle1.collection');
-    $request->attributes->set(
-      RouteObjectInterface::ROUTE_NAME,
-      'jsonapi.entity_test_with_bundle--bundle1.collection'
-    );
-    $request->attributes->set(RouteObjectInterface::ROUTE_OBJECT, $route);
-
-    \Drupal::requestStack()->push($request);
-
     $original = 'host.fail!!.deep';
     $not_expected = 'host.entity.fail!!.entity.deep';
 
-    $this->assertEquals($not_expected, $this->sut->resolveInternal($original));
+    $this->assertEquals($not_expected, $this->sut->resolveInternal('entity_test_with_bundle', 'bundle1', $original));
   }
 
 }
diff --git a/tests/src/Kernel/Controller/EntityResourceTest.php b/tests/src/Kernel/Controller/EntityResourceTest.php
index ae8aeef..0690d2e 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']]);
+    $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,7 +369,7 @@ class EntityResourceTest extends JsonapiKernelTestBase {
    * @covers ::getCollection
    */
   public function testGetEmptyCollection() {
-    $filter = new Filter(['uuid' => ['value' => 'invalid']]);
+    $filter = new Filter(new EntityConditionGroup('AND', [new EntityCondition('uuid', 'invalid')]));
     $request = new Request([], [], [
       '_route_params' => [
         '_json_api_params' => [
@@ -869,7 +869,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..8b50336
--- /dev/null
+++ b/tests/src/Kernel/Normalizer/EntityConditionGroupNormalizerTest.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\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..b160246
--- /dev/null
+++ b/tests/src/Kernel/Normalizer/EntityConditionNormalizerTest.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\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 \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   * @expectedExceptionMessage The 'NOT_ALLOWED' operator is not allowed in a filter parameter.
+   */
+  public function testDenormalize_not_allowed_exception() {
+    $this->normalizer->denormalize([
+      'path' => 'some_field',
+      'operator' => 'NOT_ALLOWED',
+      'value' => 'some_value',
+    ], EntityCondition::class);
+  }
+
+  /**
+   * @covers ::denormalize
+   * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   * @expectedExceptionMessage Filters using the 'IS NULL' operator should not provide a value.
+   */
+  public function testDenormalize_is_null_exception() {
+    $this->normalizer->denormalize([
+      'path' => 'some_field',
+      'operator' => 'IS NULL',
+      'value' => 'should_not_be_here',
+    ], EntityCondition::class);
+  }
+
+  /**
+   * @covers ::denormalize
+   * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   * @expectedExceptionMessage Filters using the 'IS NOT NULL' operator should not provide a value.
+   */
+  public function testDenormalize_is_not_null_exception() {
+    $this->normalizer->denormalize([
+      'path' => 'some_field',
+      'operator' => 'IS NOT NULL',
+      'value' => 'should_not_be_here',
+    ], EntityCondition::class);
+  }
+
+  public function denormalizeProvider() {
+    return [
+      [['path' => 'some_field', 'value' => NULL, 'operator' => '=']],
+      [['path' => 'some_field', 'operator' => '=', 'value' => 'some_string']],
+      [['path' => 'some_field', 'operator' => '<>', 'value' => 'some_string']],
+      [['path' => 'some_field', 'operator' => 'NOT BETWEEN', 'value' => 'some_string']],
+      [['path' => 'some_field', 'operator' => 'BETWEEN', '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..fdc276c
--- /dev/null
+++ b/tests/src/Kernel/Normalizer/FilterNormalizerTest.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\jsonapi\Normalizer\FilterNormalizer;
+use Drupal\jsonapi\Query\Filter;
+use Drupal\jsonapi\Context\FieldResolver;
+use \Prophecy\Argument;
+
+/**
+ * @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->container->set('jsonapi.field_resolver', $this->getFieldResolver('foo', 'bar'));
+    $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, NULL, ['entity_type_id' => 'foo', 'bundle' => 'bar']);
+    $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, NULL, ['entity_type_id' => 'foo', 'bundle' => 'bar']);
+    $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');
+  }
+
+  /**
+   * Provides a mock field resolver.
+   */
+  protected function getFieldResolver($entity_type_id, $bundle) {
+    $field_resolver = $this->prophesize(FieldResolver::class);
+    $field_resolver->resolveInternal('foo', 'bar', Argument::any())->willReturnArgument(2);
+    return $field_resolver->reveal();
+  }
+
+}
diff --git a/tests/src/Unit/Routing/Param/OffsetPageTest.php b/tests/src/Kernel/Normalizer/OffsetPageNormalizerTest.php
similarity index 12%
rename from tests/src/Unit/Routing/Param/OffsetPageTest.php
rename to tests/src/Kernel/Normalizer/OffsetPageNormalizerTest.php
index c4afea5..cc2ff38 100644
--- a/tests/src/Unit/Routing/Param/OffsetPageTest.php
+++ b/tests/src/Kernel/Normalizer/OffsetPageNormalizerTest.php
@@ -1,45 +1,71 @@
 <?php
 
-namespace Drupal\Tests\jsonapi\Unit\Routing\Param;
+namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
 
-use Drupal\jsonapi\Routing\Param\OffsetPage;
-use Drupal\Tests\UnitTestCase;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\jsonapi\Query\OffsetPage;
 
 /**
- * @coversDefaultClass \Drupal\jsonapi\Routing\Param\OffsetPage
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\OffsetPageNormalizer
  * @group jsonapi
+ * @group jsonapi_normalizers
  */
-class OffsetPageTest extends UnitTestCase {
+class OffsetPageNormalizerTest extends KernelTestBase {
 
   /**
-   * @covers ::get
-   * @dataProvider getProvider
+   * {@inheritdoc}
    */
-  public function testGet($original, $max_page, $expected) {
-    $pager = new OffsetPage($original, $max_page);
-    $this->assertEquals($expected, $pager->get());
+  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 getProvider() {
+  public function denormalizeProvider() {
     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]],
+      [['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 ::get
+   * @covers ::denormalize
    * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
    */
-  public function testGetFail() {
-    $pager = new OffsetPage('lorem');
-    $pager->get();
+  public function testDenormalizeFail() {
+    $this->normalizer->denormalize('lorem', OffsetPage::class);
   }
 
+
 }
diff --git a/tests/src/Unit/Routing/Param/SortTest.php b/tests/src/Kernel/Normalizer/SortNormalizerTest.php
similarity index 46%
rename from tests/src/Unit/Routing/Param/SortTest.php
rename to tests/src/Kernel/Normalizer/SortNormalizerTest.php
index 845811d..7491bb1 100644
--- a/tests/src/Unit/Routing/Param/SortTest.php
+++ b/tests/src/Kernel/Normalizer/SortNormalizerTest.php
@@ -1,29 +1,58 @@
 <?php
 
-namespace Drupal\Tests\jsonapi\Unit\Routing\Param;
+namespace Drupal\Tests\jsonapi\Kernel\Normalizer;
 
-use Drupal\jsonapi\Routing\Param\Sort;
-use Drupal\Tests\UnitTestCase;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\jsonapi\Query\Sort;
 
 /**
- * @coversDefaultClass \Drupal\jsonapi\Routing\Param\Sort
+ * @coversDefaultClass \Drupal\jsonapi\Normalizer\SortNormalizer
  * @group jsonapi
+ * @group jsonapi_normalizers
  */
-class SortTest extends UnitTestCase {
+class SortNormalizerTest extends KernelTestBase {
 
   /**
-   * @covers ::get
-   * @dataProvider getProvider
+   * {@inheritdoc}
    */
-  public function testGet($original, $expected) {
-    $sort = new Sort($original);
-    $this->assertEquals($expected, $sort->get());
+  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']);
+    }
   }
 
   /**
-   * Data provider for testGet.
+   * Provides a suite of shortcut sort pamaters and their expected expansions.
    */
-  public function getProvider() {
+  public function denormalizeProvider() {
     return [
       ['lorem', [['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL]]],
       ['-lorem', [['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL]]],
@@ -53,19 +82,18 @@ class SortTest extends UnitTestCase {
   }
 
   /**
-   * @covers ::get
-   * @dataProvider getFailProvider
+   * @covers ::denormalize
+   * @dataProvider denormalizeFailProvider
    * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
    */
-  public function testGetFail($input) {
-    $sort = new Sort($input);
-    $sort->get();
+  public function testDenormalizeFail($input) {
+    $sort = $this->normalizer->denormalize($input, Sort::class);
   }
 
   /**
-   * Data provider for testGetFail.
+   * Data provider for testDenormalizeFail.
    */
-  public function getFailProvider() {
+  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..4d23773
--- /dev/null
+++ b/tests/src/Kernel/Query/FilterTest.php
@@ -0,0 +1,170 @@
+<?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, NULL, [
+        'entity_type_id' => 'node',
+        'bundle' => 'painting',
+      ]);
+
+      $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 3579c5c..0e2b545 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;
@@ -37,13 +38,6 @@ class CurrentContextTest extends UnitTestCase {
    */
   protected $resourceTypeRepository;
 
-  /**
-   * A mock for the entity field manager.
-   *
-   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
-   */
-  protected $fieldManager;
-
   /**
    * A request stack.
    *
@@ -79,9 +73,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 +103,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 7f0f416..a9503a9 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();
 
@@ -166,8 +166,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..8ad3748 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']);
@@ -49,6 +52,7 @@ class JsonApiParamEnhancerTest extends UnitTestCase {
 
     $route = $this->prophesize(Route::class);
     $route->getRequirement('_entity_type')->willReturn('dolor');
+    $route->getRequirement('_bundle')->willReturn('sit');
     $defaults = $object->enhance([
       RouteObjectInterface::ROUTE_OBJECT => $route->reveal(),
     ], $request->reveal());
@@ -61,7 +65,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']);
@@ -69,7 +74,12 @@ class JsonApiParamEnhancerTest extends UnitTestCase {
     $query->has('page')->willReturn(TRUE);
     $request->query = $query->reveal();
 
-    $defaults = $object->enhance([], $request->reveal());
+    $route = $this->prophesize(Route::class);
+    $route->getRequirement('_entity_type')->willReturn('dolor');
+    $route->getRequirement('_bundle')->willReturn('sit');
+    $defaults = $object->enhance([
+      RouteObjectInterface::ROUTE_OBJECT => $route->reveal(),
+    ], $request->reveal());
     $this->assertInstanceOf(OffsetPage::class, $defaults['_json_api_params']['page']);
     $this->assertTrue(empty($defaults['_json_api_params']['filter']));
     $this->assertTrue(empty($defaults['_json_api_params']['sort']));
@@ -79,7 +89,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');
@@ -87,10 +98,40 @@ class JsonApiParamEnhancerTest extends UnitTestCase {
     $query->has('sort')->willReturn(TRUE);
     $request->query = $query->reveal();
 
-    $defaults = $object->enhance([], $request->reveal());
+    $route = $this->prophesize(Route::class);
+    $route->getRequirement('_entity_type')->willReturn('dolor');
+    $route->getRequirement('_bundle')->willReturn('sit');
+    $defaults = $object->enhance([
+      RouteObjectInterface::ROUTE_OBJECT => $route->reveal(),
+    ], $request->reveal());
     $this->assertInstanceOf(Sort::class, $defaults['_json_api_params']['sort']);
     $this->assertInstanceOf(OffsetPage::class, $defaults['_json_api_params']['page']);
     $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,
+      Argument::any(),
+      Argument::any()
+    )->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 40d85a3..0000000
--- a/tests/src/Unit/Routing/Param/FilterTest.php
+++ /dev/null
@@ -1,349 +0,0 @@
-<?php
-
-namespace Drupal\Tests\jsonapi\Unit\Routing\Param;
-
-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
-   * @covers ::expand
-   * @covers ::expandItem
-   * @covers ::validateItem
-   * @dataProvider validFiltersDataProvider
-   */
-  public function testValidFilters($original, $expected) {
-    $filter = new Filter($original);
-    $this->assertEquals($expected, $filter->get());
-  }
-
-  /**
-   * @covers ::get
-   * @covers ::expand
-   * @covers ::expandItem
-   * @covers ::validateItem
-   * @expectedException \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
-   * @dataProvider invalidFiltersDataProvider
-   */
-  public function testInvalidFilters($original) {
-    $filter = new Filter($original);
-    $filter->get();
-  }
-
-  /**
-   * Data provider for testValidFilters().
-   */
-  public function validFiltersDataProvider() {
-    return [
-      // Test case:
-      // filter[foo][value]=bar.
-      [
-        ['foo' => ['value' => 'bar']],
-        ['foo' => ['condition' => ['path' => 'foo', 'value' => 'bar', 'operator' => '=']]],
-      ],
-      // Test case:
-      // filter[0][path]=foo
-      // filter[0][value]=bar.
-      [
-        [0 => ['path' => 'foo', 'value' => 'bar']],
-        [0 => ['condition' => ['path' => 'foo', 'value' => 'bar', 'operator' => '=']]],
-      ],
-      // Test case:
-      // filter[foo][value]=bar
-      // filter[foo][operator]=>.
-      [
-        ['foo' => ['value' => 'bar', 'operator' => '>']],
-        ['foo' => ['condition' => ['path' => 'foo', 'value' => 'bar', 'operator' => '>']]],
-      ],
-      // Test case:
-      // filter[0][path]=foo
-      // filter[0][value]=1
-      // filter[0][operator]=>.
-      [
-        [0 => ['path' => 'foo', 'value' => '1', 'operator' => '>']],
-        [0 => ['condition' => ['path' => 'foo', 'value' => '1', 'operator' => '>']]],
-      ],
-      // Test case:
-      // 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']]],
-      ],
-      // Test case:
-      // 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']]],
-      ],
-      // Test case:
-      // filter[0][condition][path]=foo
-      // filter[0][condition][value]=1
-      // filter[0][condition][operator]=>.
-      [
-        [0 => ['condition' => ['path' => 'foo', 'value' => '1', 'operator' => '>']]],
-        [0 => ['condition' => ['path' => 'foo', 'value' => '1', 'operator' => '>']]],
-      ],
-      // Test case:
-      // filter[0][path]=foo
-      // filter[0][value][]=bar
-      // filter[0][value][]=baz.
-      [
-        [0 => ['path' => 'foo', 'value' => ['bar', 'baz']]],
-        [0 => ['condition' => ['path' => 'foo', 'value' => ['bar', 'baz'], 'operator' => '=']]],
-      ],
-      // Test case:
-      // filter[0][path]=foo
-      // filter[0][value][]=bar
-      // filter[0][value][]=baz
-      // filter[0][memberOf]=or-group
-      // filter[or-group][group][conjunction]=OR.
-      [
-        [0 => ['path' => 'foo', 'value' => ['bar', 'baz'], 'memberOf' => 'or-group'], 'or-group' => ['group' => ['conjunction' => 'OR']]],
-        [0 => ['condition' => ['path' => 'foo', 'value' => ['bar', 'baz'], 'operator' => '=', 'memberOf' => 'or-group']], 'or-group' => ['group' => ['conjunction' => 'OR']]],
-      ],
-      // Test case:
-      // filter[0][path]=foo
-      // filter[0][value]=bar
-      // filter[1][condition][path]=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' => '<>']],
-        ],
-      ],
-      // Test case:
-      // filter[zero][path]=foo
-      // filter[zero][value]=bar
-      // filter[one][condition][path]=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' => '<>']],
-        ],
-      ],
-      // Test case:
-      // filter[and-group][group][conjunction]=AND
-      // filter[or-group][group][conjunction]=OR
-      // filter[or-group][group][memberOf]=and-group
-      // filter[admin-filter][path]=uid.name
-      // filter[admin-filter][value]=admin
-      // filter[admin-filter][memberOf]=and-group
-      // filter[sticky-filter][path]=sticky
-      // filter[sticky-filter][value]=1
-      // filter[sticky-filter][memberOf]=or-group
-      // filter[promote-filter][path]=promote
-      // filter[promote-filter][value]=1
-      // filter[promote-filter][memberOf]=or-group.
-      [
-        [
-          'and-group' => ['group' => ['conjunction' => 'AND']],
-          'or-group' => ['group' => ['conjunction' => 'OR', 'memberOf' => 'and-group']],
-          'admin-filter' => ['path' => 'uid.name', 'value' => 'admin', 'memberOf' => 'and-group'],
-          'sticky-filter' => ['path' => 'sticky', 'value' => 1, 'memberOf' => 'or-group'],
-          'promote-filter' => ['path' => 'promote', 'value' => 1, 'memberOf' => 'or-group'],
-        ],
-        [
-          'and-group' => ['group' => ['conjunction' => 'AND']],
-          'or-group' => ['group' => ['conjunction' => 'OR', 'memberOf' => 'and-group']],
-          'admin-filter' => ['condition' => ['path' => 'uid.name', 'value' => 'admin', 'operator' => '=', 'memberOf' => 'and-group']],
-          'sticky-filter' => ['condition' => ['path' => 'sticky', 'value' => 1, 'operator' => '=', 'memberOf' => 'or-group']],
-          'promote-filter' => ['condition' => ['path' => 'promote', 'value' => 1, 'operator' => '=', 'memberOf' => 'or-group']],
-        ],
-      ],
-      // Test case:
-      // filter[and-group][group][conjunction]=AND
-      // filter[or-group][group][conjunction]=OR
-      // filter[or-group][group][memberOf]=and-group
-      // filter[admin-filter][condition][path]=uid.name
-      // filter[admin-filter][condition][value]=admin
-      // filter[admin-filter][condition][memberOf]=and-group
-      // filter[sticky-filter][condition][path]=sticky
-      // filter[sticky-filter][condition][value]=1
-      // filter[sticky-filter][condition][memberOf]=or-group
-      // filter[promote-filter][condition][path]=promote
-      // filter[promote-filter][condition][value]=1
-      // filter[promote-filter][condition][memberOf]=or-group.
-      [
-        [
-          'and-group' => ['group' => ['conjunction' => 'AND']],
-          'or-group' => ['group' => ['conjunction' => 'OR', 'memberOf' => 'and-group']],
-          'admin-filter' => ['condition' => ['path' => 'uid.name', 'value' => 'admin', 'memberOf' => 'and-group']],
-          'sticky-filter' => ['condition' => ['path' => 'sticky', 'value' => 1, 'memberOf' => 'or-group']],
-          'promote-filter' => ['condition' => ['path' => 'promote', 'value' => 1, 'memberOf' => 'or-group']],
-        ],
-        [
-          'and-group' => ['group' => ['conjunction' => 'AND']],
-          'or-group' => ['group' => ['conjunction' => 'OR', 'memberOf' => 'and-group']],
-          'admin-filter' => ['condition' => ['path' => 'uid.name', 'value' => 'admin', 'memberOf' => 'and-group']],
-          'sticky-filter' => ['condition' => ['path' => 'sticky', 'value' => 1, 'memberOf' => 'or-group']],
-          'promote-filter' => ['condition' => ['path' => 'promote', 'value' => 1, 'memberOf' => 'or-group']],
-        ],
-      ],
-
-      // filter[has-sticky][path]=sticky
-      // filter[has-sticky[operator]='IS NOT NULL'
-      [
-        ['has-sticky' => ['condition' => ['path' => 'sticky', 'operator' => 'IS NOT NULL']]],
-        ['has-sticky' => [
-          'condition' => ['path' => 'sticky', 'operator' => 'IS NOT NULL', 'value' => NULL]
-        ]],
-      ]
-    ];
-  }
-
-  /**
-   * Data provider for testInvalidFilters().
-   */
-  public function invalidFiltersDataProvider() {
-    return [
-      // Filter suppors only arrays.
-      // # Test case:
-      // filter[foo]=bar
-      // # Reason to fail:
-      // "bar" is a string and not an array.
-      [
-        ['foo' => 'bar'],
-      ],
-      // Filter supports only certain keys.
-      // # Test case:
-      // filter[foo][nid]=1
-      // # Reason to fail:
-      // Filter expects "group", "condition" or "value" key in the filter root.
-      [
-       ['foo' => ['nid' => 1]],
-      ],
-      // Shorthand filter supports only allowed list of params.
-      // # Test case:
-      // filter[foo][value]=1
-      // filter[foo][bar]=baz
-      // # Reason to fail:
-      // "bar" is not expected key for filtering.
-      [
-        ['foo' => ['value' => 1, 'bar' => 'baz']],
-      ],
-      // Shorthand filter supports only allowed list of params.
-      // # Test case:
-      // filter[foo][value]=1
-      // filter[foo][group]=bar
-      // # Reason to fail:
-      // "group" is a legacy and not supported anymore group key.
-      [
-        ['foo' => ['value' => 1, 'group' => 'bar']],
-      ],
-      // Full canonical filter has mandatory params.
-      // # Test case:
-      // filter[foo][condition][value]=1
-      // # Reason to fail:
-      // Missing mandatory "path" key.
-      [
-        ['foo' => ['condition' => ['value' => 1]]],
-      ],
-      // Full canonical filter has mandatory params.
-      // # Test case:
-      // filter[foo][condition][path]=nid
-      // # Reason to fail:
-      // Missing mandatory "value" key.
-      [
-        ['foo' => ['condition' => ['path' => 'nid']]],
-      ],
-      // Full canonical filter supports only allowed list of params.
-      // # Test case:
-      // filter[foo][condition][value]=1
-      // filter[foo][condition][path]=nid
-      // filter[foo][condition][bar]=baz.
-      // # Reason to fail:
-      // "bar" is not expected filtering key.
-      [
-        ['foo' => ['condition' => ['value' => 1, 'path' => 'nid', 'bar' => 'baz']]],
-      ],
-      // Full canonical filter allows only one top level key "condition".
-      // # Test case:
-      // filter[foo][condition][value]=1
-      // filter[foo][condition][path]=nid.
-      // filter[foo][value]=baz.
-      // # Reason to fail:
-      // "value" => "bar" is not expected next to the filter[condition] query.
-      [
-        ['foo' => ['condition' => ['value' => 1, 'path' => 'nid'], ['value' => 'baz']]],
-      ],
-      // Group query supports only allowed list of params.
-      // # Test case:
-      // filter[foo][group][conjunction]=AND
-      // filter[foo][group][bar]=baz
-      // # Reason to fail:
-      // "bar" is not supported group key.
-      [
-        ['foo' => ['group' => ['conjunction' => 'AND', 'bar' => 'baz']]],
-      ],
-      // Group query supports only allowed list of params.
-      // # Test case:
-      // filter[foo][group][conjunction]=AND
-      // filter[foo][group][group]=bar
-      // # Reason to fail:
-      // "group" is a legacy and not supported anymore group key.
-      [
-        ['foo' => ['group' => ['conjunction' => 'AND', 'group' => 'bar']]],
-      ],
-      // Group query supports only allowed list of params.
-      // # Test case:
-      // filter[foo][group][bar]=baz
-      // filter[foo][group][conjunction]=AND
-      // # Reason to fail:
-      // "bar" is not supported group key.
-      [
-        ['foo' => ['group' => ['bar' => 'baz', 'conjunction' => 'AND']]],
-      ],
-      // Group query has mandatory field "conjunction".
-      // # Test case:
-      // filter[foo][group][memberOf]=bar
-      // # Reason to fail:
-      // "conjunction" key is missing.
-      [
-        ['foo' => ['group' => ['memberOf' => 'bar']]],
-      ],
-      // Group query has only certain correct values for "conjunction" key.
-      // # Test case:
-      // filter[foo][group][conjunction]=NOR
-      // # Reason to fail:
-      // "conjunction" key has wrong value.
-      [
-        ['foo' => ['group' => ['conjunction' => 'NOR']]],
-      ],
-      // Group query allows only one top level key "group".
-      // # Test case:
-      // filter[foo][group][conjunction]=AND
-      // filter[foo][group][memberOf]=bar
-      // filter[foo][value]=baz.
-      // # Reason to fail:
-      // "value" => "bar" is not expected next to the filter[condition] query.
-      [
-        ['foo' => ['group' => ['conjunction' => 'AND', 'memberOf' => 'bar'], ['value' => 'baz']]],
-      ],
-    ];
-  }
-
-}
