diff --git a/jsonapi.services.yml b/jsonapi.services.yml
index b1574a2..89bebe9 100644
--- a/jsonapi.services.yml
+++ b/jsonapi.services.yml
@@ -64,19 +64,19 @@ services:
     arguments: ['@jsonapi.resource_type.repository', '@jsonapi.link_manager', '@entity_field.manager', '@entity.repository']
     tags:
       - { name: jsonapi_normalizer_do_not_use_removal_imminent }
-  serializer.normalizer.entity.jsonapi:
-    class: Drupal\jsonapi\Normalizer\ContentEntityNormalizer
-    arguments: ['@jsonapi.link_manager', '@jsonapi.resource_type.repository', '@entity_type.manager', '@entity_field.manager', '@plugin.manager.field.field_type']
+  serializer.normalizer.content_entity.jsonapi:
+    class: Drupal\jsonapi\Normalizer\ContentEntityDenormalizer
+    arguments: ['@entity_type.manager', '@entity_field.manager', '@plugin.manager.field.field_type']
     tags:
       - { name: jsonapi_normalizer_do_not_use_removal_imminent }
-  serializer.normalizer.entity.label_only.jsonapi:
-    class: Drupal\jsonapi\Normalizer\LabelOnlyEntityNormalizer
-    arguments: ['@jsonapi.link_manager', '@jsonapi.resource_type.repository']
+  serializer.normalizer.config_entity.jsonapi:
+    class: Drupal\jsonapi\Normalizer\ConfigEntityDenormalizer
+    arguments: ['@entity_type.manager', '@entity_field.manager', '@plugin.manager.field.field_type']
     tags:
       - { name: jsonapi_normalizer_do_not_use_removal_imminent }
-  serializer.normalizer.config_entity.jsonapi:
-    class: Drupal\jsonapi\Normalizer\ConfigEntityNormalizer
-    arguments: ['@jsonapi.link_manager', '@jsonapi.resource_type.repository', '@entity_type.manager', '@entity_field.manager', '@plugin.manager.field.field_type']
+  serializer.normalizer.resource_object.jsonapi:
+    class: Drupal\jsonapi\Normalizer\ResourceObjectNormalizer
+    arguments: ['@jsonapi.link_manager']
     tags:
       - { name: jsonapi_normalizer_do_not_use_removal_imminent }
   serializer.normalizer.jsonapi_document_toplevel.jsonapi:
@@ -86,7 +86,7 @@ services:
       - { name: jsonapi_normalizer_do_not_use_removal_imminent }
   serializer.normalizer.entity_reference_field.jsonapi:
     class: Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer
-    arguments: ['@jsonapi.resource_type.repository', '@entity.repository']
+    arguments: ['@entity.repository']
     tags:
       # This must have a higher priority than the 'serializer.normalizer.field.jsonapi' to take effect.
       - { name: jsonapi_normalizer_do_not_use_removal_imminent, priority: 1 }
diff --git a/src/Controller/EntityResource.php b/src/Controller/EntityResource.php
index 25a543a..12e4690 100644
--- a/src/Controller/EntityResource.php
+++ b/src/Controller/EntityResource.php
@@ -23,6 +23,7 @@ use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
 use Drupal\jsonapi\IncludeResolver;
 use Drupal\jsonapi\JsonApiResource\NullEntityCollection;
 use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
 use Drupal\jsonapi\LabelOnlyEntity;
 use Drupal\jsonapi\Query\Filter;
 use Drupal\jsonapi\Query\Sort;
@@ -138,7 +139,7 @@ class EntityResource {
    *   Thrown when access to the entity is not allowed.
    */
   public function getIndividual(EntityInterface $entity, Request $request) {
-    $entity = static::getAccessCheckedEntity($entity);
+    $entity = static::getAccessCheckedResourceObject($entity);
     if ($entity instanceof EntityAccessDeniedHttpException) {
       throw $entity;
     }
@@ -213,24 +214,19 @@ class EntityResource {
       // by the user. Field access makes no distinction between 'create' and
       // 'update', so the 'edit' operation is used here.
       $document = Json::decode($request->getContent());
-      if (isset($document['data']['attributes'])) {
-        $received_attributes = array_keys($document['data']['attributes']);
-        foreach ($received_attributes as $field_name) {
-          $internal_field_name = $resource_type->getInternalName($field_name);
-          $field_access = $parsed_entity->get($internal_field_name)
-            ->access('edit', NULL, TRUE);
-          if (!$field_access->isAllowed()) {
-            throw new EntityAccessDeniedHttpException(NULL, $field_access, '/data/attributes/' . $field_name, sprintf('The current user is not allowed to POST the selected field (%s).', $field_name));
-          }
-        }
-      }
-      if (isset($document['data']['relationships'])) {
-        $received_relationships = array_keys($document['data']['relationships']);
-        foreach ($received_relationships as $field_name) {
-          $internal_field_name = $resource_type->getInternalName($field_name);
-          $field_access = $parsed_entity->get($internal_field_name)->access('edit', NULL, TRUE);
-          if (!$field_access->isAllowed()) {
-            throw new EntityAccessDeniedHttpException(NULL, $field_access, '/data/relationships/' . $field_name, sprintf('The current user is not allowed to POST the selected field (%s).', $field_name));
+      foreach (['attributes', 'relationships'] as $data_member_name) {
+        if (isset($document['data'][$data_member_name])) {
+          $valid_names = array_filter(array_map(function ($public_field_name) use ($resource_type) {
+            return $resource_type->getInternalName($public_field_name);
+          }, array_keys($document['data'][$data_member_name])), function ($internal_field_name) use ($resource_type) {
+            return $resource_type->hasField($internal_field_name);
+          });
+          foreach ($valid_names as $field_name) {
+            $field_access = $parsed_entity->get($field_name)->access('edit', NULL, TRUE);
+            if (!$field_access->isAllowed()) {
+              $public_field_name = $resource_type->getPublicName($field_name);
+              throw new EntityAccessDeniedHttpException(NULL, $field_access, "/data/$data_member_name/$public_field_name", sprintf('The current user is not allowed to POST the selected field (%s).', $public_field_name));
+            }
           }
         }
       }
@@ -247,7 +243,7 @@ class EntityResource {
     $parsed_entity->save();
 
     // Build response object.
-    $response = $this->buildWrappedResponse($parsed_entity, $request, new NullEntityCollection(), 201);
+    $response = $this->buildWrappedResponse(new ResourceObject($resource_type, $parsed_entity), $request, new NullEntityCollection(), 201);
 
     // According to JSON:API specification, when a new entity was created
     // we should send "Location" header to the frontend.
@@ -303,7 +299,7 @@ class EntityResource {
 
     $this->validate($entity, $field_names);
     $entity->save();
-    return $this->buildWrappedResponse($entity, $request, new NullEntityCollection());
+    return $this->buildWrappedResponse(new ResourceObject($resource_type, $entity), $request, new NullEntityCollection());
   }
 
   /**
@@ -460,7 +456,7 @@ class EntityResource {
     );
     $collection_data = [];
     foreach ($referenced_entities as $referenced_entity) {
-      $collection_data[] = static::getAccessCheckedEntity($referenced_entity);
+      $collection_data[] = static::getAccessCheckedResourceObject($referenced_entity);
     }
     $entity_collection = new EntityCollection($collection_data, $field_list->getFieldDefinition()->getFieldStorageDefinition()->getCardinality());
     $response = $this->buildWrappedResponse($entity_collection, $request, $this->getIncludes($request, $entity_collection, $related));
@@ -492,7 +488,10 @@ class EntityResource {
   public function getRelationship(ResourceType $resource_type, FieldableEntityInterface $entity, $related, Request $request, $response_code = 200) {
     /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */
     $field_list = $entity->get($resource_type->getInternalName($related));
-    $response = $this->buildWrappedResponse($field_list, $request, $this->getIncludes($request, $entity), $response_code);
+    // Access will have already been checked by the RelationshipFieldAccess
+    // service, so we don't need to call ::getAccessCheckedResourceObject().
+    $resource_object = new ResourceObject($resource_type, $entity);
+    $response = $this->buildWrappedResponse($field_list, $request, $this->getIncludes($request, $resource_object), $response_code);
     // Add the host entity as a cacheable dependency.
     $response->addCacheableDependency($entity);
     return $response;
@@ -1019,7 +1018,7 @@ class EntityResource {
   protected function loadEntitiesWithAccess(EntityStorageInterface $storage, array $ids) {
     $output = [];
     foreach ($storage->loadMultiple($ids) as $entity) {
-      $output[$entity->id()] = static::getAccessCheckedEntity($entity);
+      $output[$entity->id()] = static::getAccessCheckedResourceObject($entity);
     }
     return array_values($output);
   }
@@ -1030,30 +1029,32 @@ class EntityResource {
    * @param \Drupal\Core\Entity\EntityInterface $entity
    *   The entity to test access for.
    *
-   * @return \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\LabelOnlyEntity|\Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
+   * @return \Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\LabelOnlyEntity|\Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
    *   The loaded entity, a label only version of that entity or an
    *   EntityAccessDeniedHttpException object if neither is accessible. All
    *   three possible return values carry the access result cacheability.
    */
-  public static function getAccessCheckedEntity(EntityInterface $entity) {
-    /** @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */
+  public static function getAccessCheckedResourceObject(EntityInterface $entity) {
+    /* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository */
+    $resource_type_repository = \Drupal::service('jsonapi.resource_type.repository');
+    /* @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */
     $entity_repository = \Drupal::service('entity.repository');
     $entity = $entity_repository->getTranslationFromContext($entity, NULL, ['operation' => 'entity_upcast']);
     $access = $entity->access('view', NULL, TRUE);
     $entity->addCacheableDependency($access);
+    $resource_type = $resource_type_repository->get($entity->getEntityTypeId(), $entity->bundle());
     if (!$access->isAllowed()) {
       $label_access = $entity->access('view label', NULL, TRUE);
       $entity->addCacheableDependency($label_access);
       if ($label_access->isAllowed()) {
-        return new LabelOnlyEntity($entity);
+        return new LabelOnlyEntity($resource_type, $entity);
       }
       else {
         // Pass an exception to the list of things to normalize.
         return new EntityAccessDeniedHttpException($entity, $access->orIf($label_access), '/data', 'The current user is not allowed to GET the selected resource.');
       }
     }
-
-    return $entity;
+    return new ResourceObject($resource_type, $entity);
   }
 
   /**
diff --git a/src/IncludeResolver.php b/src/IncludeResolver.php
index 9558ec9..92f9939 100644
--- a/src/IncludeResolver.php
+++ b/src/IncludeResolver.php
@@ -5,12 +5,14 @@ namespace Drupal\jsonapi;
 use Drupal\Core\Access\AccessResult;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Field\FieldItemListInterface;
 use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
 use Drupal\jsonapi\Context\FieldResolver;
 use Drupal\jsonapi\Controller\EntityResource;
 use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
 use Drupal\jsonapi\JsonApiResource\EntityCollection;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
 use Drupal\jsonapi\ResourceType\ResourceType;
 
 /**
@@ -39,7 +41,7 @@ class IncludeResolver {
    *
    * @param \Drupal\jsonapi\ResourceType\ResourceType $base_resource_type
    *   The base resource type for which includes are to be resolved.
-   * @param \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\JsonApiResource\EntityCollection $data
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface|\Drupal\jsonapi\JsonApiResource\EntityCollection $data
    *   The resource(s) for which to resolve includes.
    * @param string $include_parameter
    *   The include query parameter to resolve.
@@ -55,8 +57,9 @@ class IncludeResolver {
    *   Thrown if a storage handler couldn't be loaded.
    */
   public function resolve(ResourceType $base_resource_type, $data, $include_parameter, $related_field = NULL) {
+    assert($data instanceof ResourceIdentifierInterface || $data instanceof EntityCollection);
     // Map a single entity into an EntityCollection.
-    $entity_collection = $data instanceof EntityInterface ? new EntityCollection([$data], 1) : $data;
+    $entity_collection = $data instanceof ResourceIdentifierInterface ? new EntityCollection([$data], 1) : $data;
     $include_tree = static::toIncludeTree($base_resource_type, $include_parameter, $related_field);
     return EntityCollection::deduplicate($this->resolveIncludeTree($include_tree, $entity_collection));
   }
@@ -100,21 +103,25 @@ class IncludeResolver {
           $includes = EntityCollection::merge($includes, new EntityCollection([$exception]));
           continue;
         }
-        elseif (!$entity instanceof FieldableEntityInterface) {
+        elseif (!$entity instanceof ResourceObject) {
           continue;
         }
+        $public_field_name = $entity->getResourceType()->getPublicName($field_name);
         // Not all entities in $entity_collection will be of the same bundle and
         // may not have all of the same fields. Therefore, calling
         // $entity->get($a_missing_field_name) will result in an exception.
-        if (!$entity->hasField($field_name)) {
+        if (!$entity->hasField($public_field_name)) {
+          continue;
+        }
+        $field_list = $entity->getField($public_field_name);
+        // Config entities don't have real fields and can't have relationships.
+        if (!$field_list instanceof FieldItemListInterface) {
           continue;
         }
-        $field_list = $entity->get($field_name);
-        // @todo: raise an omitted item to an inaccessible related field in https://www.drupal.org/project/jsonapi/issues/2956084.
         $field_access = $field_list->access('view', NULL, TRUE);
         if (!$field_access->isAllowed()) {
           $message = 'The current user is not allowed to view this relationship.';
-          $exception = new EntityAccessDeniedHttpException($entity, $field_access, '', $message, $field_name);
+          $exception = new EntityAccessDeniedHttpException($field_list->getEntity(), $field_access, '', $message, $public_field_name);
           $includes = EntityCollection::merge($includes, new EntityCollection([$exception]));
           continue;
         }
@@ -129,7 +136,7 @@ class IncludeResolver {
         $entity_storage = $this->entityTypeManager->getStorage($target_type);
         $targeted_entities = $entity_storage->loadMultiple(array_unique($ids));
         $access_checked_entities = array_map(function (EntityInterface $entity) {
-          return EntityResource::getAccessCheckedEntity($entity);
+          return EntityResource::getAccessCheckedResourceObject($entity);
         }, $targeted_entities);
         $targeted_collection = new EntityCollection($access_checked_entities);
         $includes = static::resolveIncludeTree($children, $targeted_collection, EntityCollection::merge($includes, $targeted_collection));
diff --git a/src/JsonApiResource/EntityCollection.php b/src/JsonApiResource/EntityCollection.php
index dd7504e..8f67f1e 100644
--- a/src/JsonApiResource/EntityCollection.php
+++ b/src/JsonApiResource/EntityCollection.php
@@ -3,9 +3,7 @@
 namespace Drupal\jsonapi\JsonApiResource;
 
 use Drupal\Component\Assertion\Inspector;
-use Drupal\Core\Entity\EntityInterface;
 use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
-use Drupal\jsonapi\LabelOnlyEntity;
 
 /**
  * Wrapper to normalize collections with multiple entities.
@@ -15,11 +13,11 @@ use Drupal\jsonapi\LabelOnlyEntity;
 class EntityCollection implements \IteratorAggregate, \Countable {
 
   /**
-   * Entity storage.
+   * Various representations of entities.
    *
-   * @var \Drupal\Core\Entity\EntityInterface[]
+   * @var \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface[]
    */
-  protected $entities;
+  protected $resourceObjects;
 
   /**
    * The number of resources permitted in this collection.
@@ -45,7 +43,7 @@ class EntityCollection implements \IteratorAggregate, \Countable {
   /**
    * Instantiates a EntityCollection object.
    *
-   * @param \Drupal\Core\Entity\EntityInterface|null[]|false[] $resources
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface[]|null[]|false[] $resource_objects
    *   The resources for the collection.
    * @param int $cardinality
    *   The number of resources that this collection may contain. Related
@@ -53,17 +51,15 @@ class EntityCollection implements \IteratorAggregate, \Countable {
    *   to-one relationship should have a cardinality of 1. Use -1 for unlimited
    *   cardinality.
    */
-  public function __construct(array $resources, $cardinality = -1) {
+  public function __construct(array $resource_objects, $cardinality = -1) {
     assert(Inspector::assertAll(function ($entity) {
         return $entity === NULL
         || $entity === FALSE
-        || $entity instanceof EntityInterface
-        || $entity instanceof LabelOnlyEntity
-        || $entity instanceof EntityAccessDeniedHttpException;
-    }, $resources));
+        || $entity instanceof ResourceIdentifierInterface;
+    }, $resource_objects));
     assert($cardinality >= -1 && $cardinality !== 0, 'Cardinality must be -1 for unlimited cardinality or a positive integer.');
-    assert($cardinality === -1 || count($resources) <= $cardinality, 'If cardinality is not unlimited, the number of given resources must not exceed the cardinality of the collection.');
-    $this->entities = array_values($resources);
+    assert($cardinality === -1 || count($resource_objects) <= $cardinality, 'If cardinality is not unlimited, the number of given resources must not exceed the cardinality of the collection.');
+    $this->resourceObjects = array_values($resource_objects);
     $this->cardinality = $cardinality;
   }
 
@@ -74,7 +70,7 @@ class EntityCollection implements \IteratorAggregate, \Countable {
    *   An \ArrayIterator instance
    */
   public function getIterator() {
-    return new \ArrayIterator($this->entities);
+    return new \ArrayIterator($this->resourceObjects);
   }
 
   /**
@@ -84,7 +80,7 @@ class EntityCollection implements \IteratorAggregate, \Countable {
    *   The number of parameters
    */
   public function count() {
-    return count($this->entities);
+    return count($this->resourceObjects);
   }
 
   /**
@@ -108,7 +104,7 @@ class EntityCollection implements \IteratorAggregate, \Countable {
    *   The array of entities.
    */
   public function toArray() {
-    return $this->entities;
+    return $this->resourceObjects;
   }
 
   /**
@@ -171,15 +167,9 @@ class EntityCollection implements \IteratorAggregate, \Countable {
   public static function deduplicate(EntityCollection $collection) {
     $deduplicated = [];
     foreach ($collection as $resource) {
-      if ($resource instanceof EntityInterface) {
-        $resource_identifier = ResourceIdentifier::fromEntity($resource);
-        $dedupe_key = $resource_identifier->getTypeName() . ':' . $resource_identifier->getId();
-      }
-      else {
-        $dedupe_key = $resource->getTypeName() . ':' . $resource->getId();
-        if ($resource instanceof EntityAccessDeniedHttpException && ($error = $resource->getError()) && !is_null($error['relationship_field'])) {
-          $dedupe_key .= ':' . $error['relationship_field'];
-        }
+      $dedupe_key = $resource->getTypeName() . ':' . $resource->getId();
+      if ($resource instanceof EntityAccessDeniedHttpException && ($error = $resource->getError()) && !is_null($error['relationship_field'])) {
+        $dedupe_key .= ':' . $error['relationship_field'];
       }
       $deduplicated[$dedupe_key] = $resource;
     }
diff --git a/src/JsonApiResource/JsonApiDocumentTopLevel.php b/src/JsonApiResource/JsonApiDocumentTopLevel.php
index 74eca6c..3e35908 100644
--- a/src/JsonApiResource/JsonApiDocumentTopLevel.php
+++ b/src/JsonApiResource/JsonApiDocumentTopLevel.php
@@ -3,6 +3,7 @@
 namespace Drupal\jsonapi\JsonApiResource;
 
 use Drupal\Component\Assertion\Inspector;
+use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
 
 /**
  * Represents a JSON:API document's "top level".
@@ -18,7 +19,7 @@ class JsonApiDocumentTopLevel {
   /**
    * The data to normalize.
    *
-   * @var \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\JsonApiResource\EntityCollection|\Drupal\jsonapi\LabelOnlyEntity|\Drupal\jsonapi\JsonApiResource\ErrorCollection
+   * @var \Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\EntityCollection|\Drupal\jsonapi\LabelOnlyEntity|\Drupal\jsonapi\JsonApiResource\ErrorCollection
    */
   protected $data;
 
@@ -46,7 +47,7 @@ class JsonApiDocumentTopLevel {
   /**
    * Instantiates a JsonApiDocumentTopLevel object.
    *
-   * @param \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\JsonApiResource\EntityCollection|\Drupal\jsonapi\LabelOnlyEntity|\Drupal\jsonapi\JsonApiResource\ErrorCollection $data
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\EntityCollection|\Drupal\jsonapi\LabelOnlyEntity|\Drupal\jsonapi\JsonApiResource\ErrorCollection $data
    *   The data to normalize. It can be either a straight up entity or a
    *   collection of entities.
    * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $includes
@@ -59,6 +60,7 @@ class JsonApiDocumentTopLevel {
    *   (optional) The metadata to normalize.
    */
   public function __construct($data, EntityCollection $includes, array $links, array $meta = []) {
+    assert($data instanceof ResourceIdentifierInterface || $data instanceof EntityCollection || $data instanceof ErrorCollection || $data instanceof EntityReferenceFieldItemListInterface);
     assert(!$data instanceof ErrorCollection || $includes instanceof NullEntityCollection);
     assert(Inspector::assertAll(function ($link) {
       return is_array($link) || isset($link['href']) && is_string($link['href']);
@@ -73,7 +75,7 @@ class JsonApiDocumentTopLevel {
   /**
    * Gets the data.
    *
-   * @return \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\JsonApiResource\EntityCollection|\Drupal\jsonapi\LabelOnlyEntity|\Drupal\jsonapi\JsonApiResource\ErrorCollection
+   * @return \Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\EntityCollection|\Drupal\jsonapi\LabelOnlyEntity|\Drupal\jsonapi\JsonApiResource\ErrorCollection
    *   The data.
    */
   public function getData() {
diff --git a/src/JsonApiResource/ResourceIdentifier.php b/src/JsonApiResource/ResourceIdentifier.php
index 0a0925a..a4723b9 100644
--- a/src/JsonApiResource/ResourceIdentifier.php
+++ b/src/JsonApiResource/ResourceIdentifier.php
@@ -6,6 +6,7 @@ use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
 use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
 use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
 
 /**
  * Represents a JSON:API resource identifier object.
@@ -23,6 +24,13 @@ class ResourceIdentifier implements ResourceIdentifierInterface {
    */
   protected $resourceTypeName;
 
+  /**
+   * The JSON:API resource type.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $resourceType;
+
   /**
    * The resource ID.
    *
@@ -40,18 +48,22 @@ class ResourceIdentifier implements ResourceIdentifierInterface {
   /**
    * ResourceIdentifier constructor.
    *
-   * @param string $resource_type_name
-   *   The JSON:API resource type name.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType|string $resource_type
+   *   The JSON:API resource type or a JSON:API resource type name.
    * @param string $id
    *   The resource ID.
    * @param array $meta
    *   Any metadata for the ResourceIdentifier.
    */
-  public function __construct($resource_type_name, $id, array $meta = []) {
+  public function __construct($resource_type, $id, array $meta = []) {
+    assert(is_string($resource_type) || $resource_type instanceof ResourceType);
     assert(!isset($meta[static::ARITY_KEY]) || is_int($meta[static::ARITY_KEY]) && $meta[static::ARITY_KEY] >= 0);
-    $this->resourceTypeName = $resource_type_name;
+    $this->resourceTypeName = is_string($resource_type) ? $resource_type : $resource_type->getTypeName();
     $this->id = $id;
     $this->meta = $meta;
+    if (!is_string($resource_type)) {
+      $this->resourceType = $resource_type;
+    }
   }
 
   /**
@@ -64,6 +76,18 @@ class ResourceIdentifier implements ResourceIdentifierInterface {
     return $this->resourceTypeName;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getResourceType() {
+    if (!isset($this->resourceType)) {
+      /* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository */
+      $resource_type_repository = \Drupal::service('jsonapi.resource_type.repository');
+      $this->resourceType = $resource_type_repository->getByTypeName($this->getTypeName());
+    }
+    return $this->resourceType;
+  }
+
   /**
    * Gets the ResourceIdentifier's ID.
    *
@@ -268,7 +292,7 @@ class ResourceIdentifier implements ResourceIdentifierInterface {
     /* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository */
     $resource_type_repository = \Drupal::service('jsonapi.resource_type.repository');
     $resource_type = $resource_type_repository->get($entity->getEntityTypeId(), $entity->bundle());
-    return new static($resource_type->getTypeName(), $entity->uuid());
+    return new static($resource_type, $entity->uuid());
   }
 
   /**
diff --git a/src/JsonApiResource/ResourceIdentifierInterface.php b/src/JsonApiResource/ResourceIdentifierInterface.php
index 794a814..a23e0c2 100644
--- a/src/JsonApiResource/ResourceIdentifierInterface.php
+++ b/src/JsonApiResource/ResourceIdentifierInterface.php
@@ -30,4 +30,12 @@ interface ResourceIdentifierInterface {
    */
   public function getTypeName();
 
+  /**
+   * Gets the resource identifier's JSON:API resource type.
+   *
+   * @return \Drupal\jsonapi\ResourceType\ResourceType
+   *   The JSON:API resource type.
+   */
+  public function getResourceType();
+
 }
diff --git a/src/JsonApiResource/ResourceIdentifierTrait.php b/src/JsonApiResource/ResourceIdentifierTrait.php
index 8998992..407f2ad 100644
--- a/src/JsonApiResource/ResourceIdentifierTrait.php
+++ b/src/JsonApiResource/ResourceIdentifierTrait.php
@@ -6,6 +6,8 @@ namespace Drupal\jsonapi\JsonApiResource;
  * Used to associate an object like an exception to a particular resource.
  *
  * @internal
+ *
+ * @see \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface
  */
 trait ResourceIdentifierTrait {
 
@@ -16,6 +18,13 @@ trait ResourceIdentifierTrait {
    */
   protected $resourceIdentifier;
 
+  /**
+   * The JSON:API resource type of of the identified resource object.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $resourceType;
+
   /**
    * {@inheritdoc}
    */
@@ -30,4 +39,14 @@ trait ResourceIdentifierTrait {
     return $this->resourceIdentifier->getTypeName();
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getResourceType() {
+    if (!isset($this->resourceType)) {
+      $this->resourceType = $this->resourceIdentifier->getResourceType();
+    }
+    return $this->resourceType;
+  }
+
 }
diff --git a/src/JsonApiResource/ResourceObject.php b/src/JsonApiResource/ResourceObject.php
new file mode 100644
index 0000000..93dc25d
--- /dev/null
+++ b/src/JsonApiResource/ResourceObject.php
@@ -0,0 +1,189 @@
+<?php
+
+namespace Drupal\jsonapi\JsonApiResource;
+
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Cache\CacheableDependencyTrait;
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
+use Drupal\jsonapi\ResourceType\ResourceType;
+
+/**
+ * Represent a JSON:API resource object.
+ *
+ * This value object wraps a Drupal entity so that it can carry a JSON:API
+ * resource type object alongside it.
+ *
+ * @internal
+ */
+class ResourceObject implements CacheableDependencyInterface, ResourceIdentifierInterface {
+
+  use CacheableDependencyTrait;
+  use ResourceIdentifierTrait;
+
+  /**
+   * The resource type of this resource object.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceType
+   */
+  protected $resourceType;
+
+  /**
+   * The resource type of this resource object.
+   *
+   * @var array
+   */
+  protected $fields;
+
+  /**
+   * The entity represented by this resource object.
+   *
+   * @var \Drupal\Core\Entity\EntityInterface
+   */
+  protected $entity;
+
+  /**
+   * ResourceObject constructor.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON:API resource type of the resource object.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The underlying entity.
+   */
+  public function __construct(ResourceType $resource_type, EntityInterface $entity) {
+    $this->resourceType = $resource_type;
+    $this->entity = $entity;
+    $this->fields = $this->extractFields($entity);
+    $this->resourceIdentifier = new ResourceIdentifier($resource_type, $this->entity->uuid());
+    $this->setCacheability($entity);
+  }
+
+  /**
+   * Whether the resource object has the given field.
+   *
+   * @param string $public_field_name
+   *   A public field name.
+   *
+   * @return bool
+   *   TRUE if the resource object has the given field, FALSE otherwise.
+   */
+  public function hasField($public_field_name) {
+    return isset($this->fields[$public_field_name]);
+  }
+
+  /**
+   * Gets the given field.
+   *
+   * @param string $public_field_name
+   *   A public field name.
+   *
+   * @return mixed|\Drupal\Core\Field\FieldItemListInterface|null
+   *   The field or NULL if the resource object does not have the given field.
+   *
+   * @see ::extractFields()
+   */
+  public function getField($public_field_name) {
+    return $this->hasField($public_field_name) ? $this->fields[$public_field_name] : NULL;
+  }
+
+  /**
+   * Gets the ResourceObject's fields.
+   *
+   * @return mixed|\Drupal\Core\Field\FieldItemListInterface[]
+   *   The resource object's fields.
+   *
+   * @see ::extractFields()
+   */
+  public function getFields() {
+    return $this->fields;
+  }
+
+  /**
+   * Extracts the entity's fields.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity from which fields should be extracted.
+   *
+   * @return mixed|\Drupal\Core\Field\FieldItemListInterface[]
+   *   If the resource object represents a content entity, the fields will be
+   *   objects satisfying FieldItemListInterface. If it represents a config
+   *   entity, the fields will be scalar values or arrays.
+   */
+  protected function extractFields(EntityInterface $entity) {
+    assert($entity instanceof ContentEntityInterface || $entity instanceof ConfigEntityInterface);
+    return $entity instanceof ContentEntityInterface
+      ? $this->extractContentEntityFields($entity)
+      : $this->extractConfigEntityFields($entity);
+  }
+
+  /**
+   * Extracts a content entity's fields.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   The config entity from which fields should be extracted.
+   *
+   * @return \Drupal\Core\Field\FieldItemListInterface[]
+   *   The fields extracted from a content entity.
+   */
+  protected function extractContentEntityFields(ContentEntityInterface $entity) {
+    $output = [];
+    $fields = TypedDataInternalPropertiesHelper::getNonInternalProperties($entity->getTypedData());
+    // Filter the array based on the field names.
+    $enabled_field_names = array_filter(
+      array_keys($fields),
+      [$this->resourceType, 'isFieldEnabled']
+    );
+
+    // The "label" field needs special treatment: some entity types have a label
+    // field that is actually backed by a label callback.
+    $entity_type = $entity->getEntityType();
+    if ($entity_type->hasLabelCallback()) {
+      $label_field_name = $entity_type->getKey('label');
+      // @todo Remove this work-around after https://www.drupal.org/project/drupal/issues/2450793 lands.
+      if ($entity->getEntityTypeId() === 'user') {
+        $label_field_name = 'name';
+      }
+      $fields[$label_field_name]->value = $entity->label();
+    }
+
+    // Return a sub-array of $output containing the keys in $enabled_fields.
+    $input = array_intersect_key($fields, array_flip($enabled_field_names));
+    foreach ($input as $field_name => $field_value) {
+      $public_field_name = $this->resourceType->getPublicName($field_name);
+      $output[$public_field_name] = $field_value;
+    }
+    return $output;
+  }
+
+  /**
+   * Extracts a config entity's fields.
+   *
+   * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $entity
+   *   The config entity from which fields should be extracted.
+   *
+   * @return array
+   *   The fields extracted from a config entity.
+   */
+  protected function extractConfigEntityFields(ConfigEntityInterface $entity) {
+    $enabled_public_fields = [];
+    $fields = $entity->toArray();
+    // Filter the array based on the field names.
+    $enabled_field_names = array_filter(array_keys($fields), function ($internal_field_name) {
+      // Config entities have "fields" which aren't known to the resource type,
+      // these fields should not be excluded because they cannot be enabled or
+      // disabled.
+      return !$this->resourceType->hasField($internal_field_name) || $this->resourceType->isFieldEnabled($internal_field_name);
+    });
+    // Return a sub-array of $output containing the keys in $enabled_fields.
+    $input = array_intersect_key($fields, array_flip($enabled_field_names));
+    /* @var \Drupal\Core\Config\Entity\ConfigEntityInterface $entity */
+    foreach ($input as $field_name => $field_value) {
+      $public_field_name = $this->resourceType->getPublicName($field_name);
+      $enabled_public_fields[$public_field_name] = $field_value;
+    }
+    return $enabled_public_fields;
+  }
+
+}
diff --git a/src/LabelOnlyEntity.php b/src/LabelOnlyEntity.php
index 0d8c73e..a770eb4 100644
--- a/src/LabelOnlyEntity.php
+++ b/src/LabelOnlyEntity.php
@@ -2,34 +2,15 @@
 
 namespace Drupal\jsonapi;
 
-use Drupal\Core\Cache\CacheableDependencyInterface;
-use Drupal\Core\Cache\CacheableDependencyTrait;
 use Drupal\Core\Entity\EntityInterface;
-use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
-use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface;
-use Drupal\jsonapi\JsonApiResource\ResourceIdentifierTrait;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
 
 /**
- * Value object decorating an Entity object; only its label is to be normalized.
+ * Value object decorating a ResourceObject; only its label is available.
  *
  * @internal
  */
-class LabelOnlyEntity implements CacheableDependencyInterface, ResourceIdentifierInterface {
-
-  use CacheableDependencyTrait;
-  use ResourceIdentifierTrait;
-
-  /**
-   * Constructs a LabelOnlyEntity value object.
-   *
-   * @param \Drupal\Core\Entity\EntityInterface $entity
-   *   The entity for which to only normalize its label.
-   */
-  public function __construct(EntityInterface $entity) {
-    $this->resourceIdentifier = ResourceIdentifier::fromEntity($entity);
-    $this->entity = $entity;
-    $this->setCacheability($entity);
-  }
+final class LabelOnlyEntity extends ResourceObject {
 
   /**
    * Gets the decorated entity.
@@ -41,6 +22,17 @@ class LabelOnlyEntity implements CacheableDependencyInterface, ResourceIdentifie
     return $this->entity;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  protected function extractFields(EntityInterface $entity) {
+    $fields = parent::extractFields($entity);
+    $label_field_name = $this->getLabelFieldName();
+    return $label_field_name && $this->resourceType->isFieldEnabled($label_field_name) ?
+      [$this->resourceType->getPublicName($label_field_name) => $fields[$label_field_name]]
+      : [];
+  }
+
   /**
    * Determines the entity type's (internal) label field name.
    */
diff --git a/src/Normalizer/ConfigEntityDenormalizer.php b/src/Normalizer/ConfigEntityDenormalizer.php
new file mode 100644
index 0000000..c54ff87
--- /dev/null
+++ b/src/Normalizer/ConfigEntityDenormalizer.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
+
+/**
+ * Converts the Drupal config entity object to a JSON:API array structure.
+ *
+ * @internal
+ */
+final class ConfigEntityDenormalizer extends EntityDenormalizerBase {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = ConfigEntityInterface::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function prepareInput(array $data, ResourceType $resource_type, $format, array $context) {
+    $prepared = [];
+    foreach ($data as $key => $value) {
+      $prepared[$resource_type->getInternalName($key)] = $value;
+    }
+    return $prepared;
+  }
+
+}
diff --git a/src/Normalizer/ConfigEntityNormalizer.php b/src/Normalizer/ConfigEntityNormalizer.php
deleted file mode 100644
index f6c528c..0000000
--- a/src/Normalizer/ConfigEntityNormalizer.php
+++ /dev/null
@@ -1,75 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Normalizer;
-
-use Drupal\Core\Access\AccessResult;
-use Drupal\Core\Config\Entity\ConfigEntityInterface;
-use Drupal\jsonapi\Normalizer\Value\ConfigFieldItemNormalizerValue;
-use Drupal\jsonapi\Normalizer\Value\FieldNormalizerValue;
-use Drupal\jsonapi\ResourceType\ResourceType;
-
-/**
- * Converts the Drupal config entity object to a JSON:API array structure.
- *
- * @internal
- */
-class ConfigEntityNormalizer extends EntityNormalizer {
-
-  /**
-   * The interface or class that this Normalizer supports.
-   *
-   * @var string
-   */
-  protected $supportedInterfaceOrClass = ConfigEntityInterface::class;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function getFields($entity, $bundle, ResourceType $resource_type) {
-    $enabled_public_fields = [];
-    $fields = $entity->toArray();
-    // Filter the array based on the field names. Some config entity types don't
-    // have a complete field mapping available and their fields can't be
-    // enabled or disabled. Thus this code should only filter out fields that
-    // are known to exist and are not enabled.
-    $enabled_field_names = array_filter(array_keys($fields), function ($field_name) use ($resource_type) {
-      return !$resource_type->hasField($field_name) || $resource_type->isFieldEnabled($field_name);
-    });
-    // Return a sub-array of $output containing the keys in $enabled_fields.
-    $input = array_intersect_key($fields, array_flip($enabled_field_names));
-    /* @var \Drupal\Core\Config\Entity\ConfigEntityInterface $entity */
-    foreach ($input as $field_name => $field_value) {
-      $public_field_name = $resource_type->getPublicName($field_name);
-      $enabled_public_fields[$public_field_name] = $field_value;
-    }
-    return $enabled_public_fields;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function serializeField($field, array $context, $format) {
-    return new FieldNormalizerValue(
-      // Config entities have no concept of "fields", nor any concept of
-      // "field access". For practical reasons, JSON:API uses the same value
-      // object that it uses for content entities (FieldNormalizerValue), and
-      // that requires an access result. Therefore we can safely hardcode it.
-      AccessResult::allowed(),
-      [new ConfigFieldItemNormalizerValue($field)],
-      1,
-      'attributes'
-    );
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function prepareInput(array $data, ResourceType $resource_type, $format, array $context) {
-    $prepared = [];
-    foreach ($data as $key => $value) {
-      $prepared[$resource_type->getInternalName($key)] = $value;
-    }
-    return $prepared;
-  }
-
-}
diff --git a/src/Normalizer/ContentEntityDenormalizer.php b/src/Normalizer/ContentEntityDenormalizer.php
new file mode 100644
index 0000000..65209a1
--- /dev/null
+++ b/src/Normalizer/ContentEntityDenormalizer.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
+
+/**
+ * Converts a JSON:API array structure into a Drupal entity object.
+ *
+ * @internal
+ */
+final class ContentEntityDenormalizer extends EntityDenormalizerBase {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = ContentEntityInterface::class;
+
+  /**
+   * Prepares the input data to create the entity.
+   *
+   * @param array $data
+   *   The input data to modify.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   Contains the info about the resource type.
+   * @param string $format
+   *   Format the given data was extracted from.
+   * @param array $context
+   *   Options available to the denormalizer.
+   *
+   * @return array
+   *   The modified input data.
+   */
+  protected function prepareInput(array $data, ResourceType $resource_type, $format, array $context) {
+    $data_internal = [];
+
+    $field_map = $this->fieldManager->getFieldMap()[$resource_type->getEntityTypeId()];
+
+    $entity_type_id = $resource_type->getEntityTypeId();
+    $entity_type_definition = $this->entityTypeManager->getDefinition($entity_type_id);
+    $bundle_key = $entity_type_definition->getKey('bundle');
+    $uuid_key = $entity_type_definition->getKey('uuid');
+
+    // Translate the public fields into the entity fields.
+    foreach ($data as $public_field_name => $field_value) {
+      $internal_name = $resource_type->getInternalName($public_field_name);
+
+      // Skip any disabled field, except the always required bundle key and
+      // required-in-case-of-PATCHing uuid key.
+      // @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository::getFieldMapping()
+      if ($resource_type->hasField($internal_name) && !$resource_type->isFieldEnabled($internal_name) && $bundle_key !== $internal_name && $uuid_key !== $internal_name) {
+        continue;
+      }
+
+      if (!isset($field_map[$internal_name]) || !in_array($resource_type->getBundle(), $field_map[$internal_name]['bundles'], TRUE)) {
+        throw new UnprocessableEntityHttpException(sprintf(
+          'The attribute %s does not exist on the %s resource type.',
+          $internal_name,
+          $resource_type->getTypeName()
+         ));
+      }
+
+      $field_type = $field_map[$internal_name]['type'];
+      $field_class = $this->pluginManager->getDefinition($field_type)['list_class'];
+
+      $field_denormalization_context = array_merge($context, [
+        'field_type' => $field_type,
+        'field_name' => $internal_name,
+        'field_definition' => $this->fieldManager->getFieldDefinitions($resource_type->getEntityTypeId(), $resource_type->getBundle())[$internal_name],
+      ]);
+      $data_internal[$internal_name] = $this->serializer->denormalize($field_value, $field_class, $format, $field_denormalization_context);
+    }
+
+    return $data_internal;
+  }
+
+}
diff --git a/src/Normalizer/ContentEntityNormalizer.php b/src/Normalizer/ContentEntityNormalizer.php
deleted file mode 100644
index db3c2db..0000000
--- a/src/Normalizer/ContentEntityNormalizer.php
+++ /dev/null
@@ -1,10 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Normalizer;
-
-/**
- * Converts the Drupal content entity object to a JSON:API array structure.
- *
- * @internal
- */
-class ContentEntityNormalizer extends EntityNormalizer {}
diff --git a/src/Normalizer/EntityDenormalizerBase.php b/src/Normalizer/EntityDenormalizerBase.php
new file mode 100644
index 0000000..70c000b
--- /dev/null
+++ b/src/Normalizer/EntityDenormalizerBase.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\FieldTypePluginManagerInterface;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * Converts the Drupal entity object to a JSON:API array structure.
+ *
+ * @internal
+ */
+abstract class EntityDenormalizerBase extends NormalizerBase implements DenormalizerInterface {
+
+  /**
+   * The formats that the Normalizer can handle.
+   *
+   * @var array
+   */
+  protected $formats = ['api_json'];
+
+  /**
+   * The JSON:API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The entity field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * The field plugin manager.
+   *
+   * @var \Drupal\Core\Field\FieldTypePluginManagerInterface
+   */
+  protected $pluginManager;
+
+  /**
+   * Constructs an EntityNormalizer object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
+   *   The entity field manager.
+   * @param \Drupal\Core\Field\FieldTypePluginManagerInterface $plugin_manager
+   *   The plugin manager for fields.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, FieldTypePluginManagerInterface $plugin_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->fieldManager = $field_manager;
+    $this->pluginManager = $plugin_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsNormalization($data, $format = NULL) {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = []) {
+    assert(FALSE);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    if (empty($context['resource_type']) || !$context['resource_type'] instanceof ResourceType) {
+      throw new PreconditionFailedHttpException('Missing context during denormalization.');
+    }
+    /* @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */
+    $resource_type = $context['resource_type'];
+    $entity_type_id = $resource_type->getEntityTypeId();
+    $bundle = $resource_type->getBundle();
+    $bundle_key = $this->entityTypeManager->getDefinition($entity_type_id)
+      ->getKey('bundle');
+    if ($bundle_key && $bundle) {
+      $data[$bundle_key] = $bundle;
+    }
+
+    return $this->entityTypeManager->getStorage($entity_type_id)
+      ->create($this->prepareInput($data, $resource_type, $format, $context));
+  }
+
+  /**
+   * Prepares the input data to create the entity.
+   *
+   * @param array $data
+   *   The input data to modify.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   Contains the info about the resource type.
+   * @param string $format
+   *   Format the given data was extracted from.
+   * @param array $context
+   *   Options available to the denormalizer.
+   *
+   * @return array
+   *   The modified input data.
+   */
+  abstract protected function prepareInput(array $data, ResourceType $resource_type, $format, array $context);
+
+}
diff --git a/src/Normalizer/EntityNormalizer.php b/src/Normalizer/EntityNormalizer.php
deleted file mode 100644
index f6b0239..0000000
--- a/src/Normalizer/EntityNormalizer.php
+++ /dev/null
@@ -1,287 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Normalizer;
-
-use Drupal\Core\Entity\ContentEntityInterface;
-use Drupal\Core\Entity\EntityFieldManagerInterface;
-use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Drupal\Core\Field\FieldTypePluginManagerInterface;
-use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
-use Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue;
-use Drupal\jsonapi\Normalizer\Value\FieldNormalizerValueInterface;
-use Drupal\jsonapi\ResourceType\ResourceType;
-use Drupal\jsonapi\LinkManager\LinkManager;
-use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
-use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
-use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
-use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
-
-/**
- * Converts the Drupal entity object to a JSON:API array structure.
- *
- * @internal
- */
-class EntityNormalizer extends NormalizerBase implements DenormalizerInterface {
-
-  /**
-   * The interface or class that this Normalizer supports.
-   *
-   * @var string
-   */
-  protected $supportedInterfaceOrClass = ContentEntityInterface::class;
-
-  /**
-   * The formats that the Normalizer can handle.
-   *
-   * @var array
-   */
-  protected $formats = ['api_json'];
-
-  /**
-   * The link manager.
-   *
-   * @var \Drupal\jsonapi\LinkManager\LinkManager
-   */
-  protected $linkManager;
-
-  /**
-   * The JSON:API resource type repository.
-   *
-   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
-   */
-  protected $resourceTypeRepository;
-
-  /**
-   * The entity type manager.
-   *
-   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
-   */
-  protected $entityTypeManager;
-
-  /**
-   * The entity field manager.
-   *
-   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
-   */
-  protected $fieldManager;
-
-  /**
-   * The field plugin manager.
-   *
-   * @var \Drupal\Core\Field\FieldTypePluginManagerInterface
-   */
-  protected $pluginManager;
-
-  /**
-   * Constructs an EntityNormalizer object.
-   *
-   * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager
-   *   The link manager.
-   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
-   *   The JSON:API resource type repository.
-   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
-   *   The entity type manager.
-   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
-   *   The entity field manager.
-   * @param \Drupal\Core\Field\FieldTypePluginManagerInterface $plugin_manager
-   *   The plugin manager for fields.
-   */
-  public function __construct(LinkManager $link_manager, ResourceTypeRepositoryInterface $resource_type_repository, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, FieldTypePluginManagerInterface $plugin_manager) {
-    $this->linkManager = $link_manager;
-    $this->resourceTypeRepository = $resource_type_repository;
-    $this->entityTypeManager = $entity_type_manager;
-    $this->fieldManager = $field_manager;
-    $this->pluginManager = $plugin_manager;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function normalize($entity, $format = NULL, array $context = []) {
-    // If the fields to use were specified, only output those field values.
-    $context['resource_type'] = $resource_type = $this->resourceTypeRepository->get(
-      $entity->getEntityTypeId(),
-      $entity->bundle()
-    );
-    $resource_type_name = $resource_type->getTypeName();
-    // Get the bundle ID of the requested resource. This is used to determine if
-    // this is a bundle level resource or an entity level resource.
-    $bundle = $resource_type->getBundle();
-    if (!empty($context['sparse_fieldset'][$resource_type_name])) {
-      $field_names = $context['sparse_fieldset'][$resource_type_name];
-    }
-    else {
-      $field_names = $this->getFieldNames($entity, $bundle, $resource_type);
-    }
-    /* @var Value\FieldNormalizerValueInterface[] $normalizer_values */
-    $normalizer_values = [];
-    foreach ($this->getFields($entity, $bundle, $resource_type) as $field_name => $field) {
-      $in_sparse_fieldset = in_array($field_name, $field_names);
-      // Omit fields not listed in sparse fieldsets.
-      if (!$in_sparse_fieldset) {
-        continue;
-      }
-      $normalized_field = $this->serializeField($field, $context, $format);
-      assert($normalized_field instanceof FieldNormalizerValueInterface);
-      $normalizer_values[$field_name] = $normalized_field;
-    }
-
-    $link_context = ['link_manager' => $this->linkManager];
-    return new EntityNormalizerValue($normalizer_values, $context, $entity, $link_context);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function denormalize($data, $class, $format = NULL, array $context = []) {
-    if (empty($context['resource_type']) || !$context['resource_type'] instanceof ResourceType) {
-      throw new PreconditionFailedHttpException('Missing context during denormalization.');
-    }
-    /* @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */
-    $resource_type = $context['resource_type'];
-    $entity_type_id = $resource_type->getEntityTypeId();
-    $bundle = $resource_type->getBundle();
-    $bundle_key = $this->entityTypeManager->getDefinition($entity_type_id)
-      ->getKey('bundle');
-    if ($bundle_key && $bundle) {
-      $data[$resource_type->getPublicName($bundle_key)] = $bundle;
-    }
-
-    return $this->entityTypeManager->getStorage($entity_type_id)
-      ->create($this->prepareInput($data, $resource_type, $format, $context));
-  }
-
-  /**
-   * Gets the field names for the given entity.
-   *
-   * @param mixed $entity
-   *   The entity.
-   * @param string $bundle
-   *   The entity bundle.
-   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
-   *   The resource type.
-   *
-   * @return string[]
-   *   The field names.
-   */
-  protected function getFieldNames($entity, $bundle, ResourceType $resource_type) {
-    /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
-    return array_keys($this->getFields($entity, $bundle, $resource_type));
-  }
-
-  /**
-   * Gets the field names for the given entity.
-   *
-   * @param mixed $entity
-   *   The entity.
-   * @param string $bundle
-   *   The bundle id.
-   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
-   *   The resource type.
-   *
-   * @return array
-   *   The fields.
-   */
-  protected function getFields($entity, $bundle, ResourceType $resource_type) {
-    $output = [];
-    $fields = TypedDataInternalPropertiesHelper::getNonInternalProperties($entity->getTypedData());
-    // Filter the array based on the field names.
-    $enabled_field_names = array_filter(
-      array_keys($fields),
-      [$resource_type, 'isFieldEnabled']
-    );
-
-    // The "label" field needs special treatment: some entity types have a label
-    // field that is actually backed by a label callback.
-    $entity_type = $entity->getEntityType();
-    if ($entity_type->hasLabelCallback()) {
-      $label_field_name = $entity_type->getKey('label');
-      // @todo Remove this work-around after https://www.drupal.org/project/drupal/issues/2450793 lands.
-      if ($entity->getEntityTypeId() === 'user') {
-        $label_field_name = 'name';
-      }
-      $fields[$label_field_name]->value = $entity->label();
-    }
-
-    // Return a sub-array of $output containing the keys in $enabled_fields.
-    $input = array_intersect_key($fields, array_flip($enabled_field_names));
-    /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
-    foreach ($input as $field_name => $field_value) {
-      $public_field_name = $resource_type->getPublicName($field_name);
-      $output[$public_field_name] = $field_value;
-    }
-    return $output;
-  }
-
-  /**
-   * Serializes a given field.
-   *
-   * @param mixed $field
-   *   The field to serialize.
-   * @param array $context
-   *   The normalization context.
-   * @param string $format
-   *   The serialization format.
-   *
-   * @return Value\FieldNormalizerValueInterface
-   *   The normalized value.
-   */
-  protected function serializeField($field, array $context, $format) {
-    return $this->serializer->normalize($field, $format, $context);
-  }
-
-  /**
-   * Prepares the input data to create the entity.
-   *
-   * @param array $data
-   *   The input data to modify.
-   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
-   *   Contains the info about the resource type.
-   * @param string $format
-   *   Format the given data was extracted from.
-   * @param array $context
-   *   Options available to the denormalizer.
-   *
-   * @return array
-   *   The modified input data.
-   */
-  protected function prepareInput(array $data, ResourceType $resource_type, $format, array $context) {
-    $data_internal = [];
-
-    $field_map = $this->fieldManager->getFieldMap()[$resource_type->getEntityTypeId()];
-
-    $entity_type_id = $resource_type->getEntityTypeId();
-    $entity_type_definition = $this->entityTypeManager->getDefinition($entity_type_id);
-    $uuid_key = $entity_type_definition->getKey('uuid');
-
-    // Translate the public fields into the entity fields.
-    foreach ($data as $public_field_name => $field_value) {
-      $internal_name = $resource_type->getInternalName($public_field_name);
-
-      // Fail for any disabled field unless it is the uuid key, which is
-      // disabled because it's transmitted as the `id` key of a resource object.
-      // However, for the purpose of denormalization, it exists in this array.
-      // @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository::getFieldMapping()
-      if (!$resource_type->isFieldEnabled($internal_name) && $uuid_key !== $internal_name) {
-        throw new UnprocessableEntityHttpException(sprintf(
-          'The attribute %s does not exist on the %s resource type.',
-          $internal_name,
-          $resource_type->getTypeName()
-        ));
-      }
-
-      $field_type = $field_map[$internal_name]['type'];
-      $field_class = $this->pluginManager->getDefinition($field_type)['list_class'];
-
-      $field_denormalization_context = array_merge($context, [
-        'field_type' => $field_type,
-        'field_name' => $internal_name,
-        'field_definition' => $this->fieldManager->getFieldDefinitions($resource_type->getEntityTypeId(), $resource_type->getBundle())[$internal_name],
-      ]);
-      $data_internal[$internal_name] = $this->serializer->denormalize($field_value, $field_class, $format, $field_denormalization_context);
-    }
-
-    return $data_internal;
-  }
-
-}
diff --git a/src/Normalizer/EntityReferenceFieldNormalizer.php b/src/Normalizer/EntityReferenceFieldNormalizer.php
index bbe5dae..b14998c 100644
--- a/src/Normalizer/EntityReferenceFieldNormalizer.php
+++ b/src/Normalizer/EntityReferenceFieldNormalizer.php
@@ -6,9 +6,11 @@ use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityRepositoryInterface;
 use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
+use Drupal\Core\Field\FieldItemInterface;
+use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
 use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
+use Drupal\jsonapi\Controller\EntityResource;
 use Drupal\jsonapi\Normalizer\Value\NullFieldNormalizerValue;
-use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
 use Drupal\jsonapi\JsonApiResource\EntityCollection;
 use Drupal\serialization\Normalizer\CacheableNormalizerInterface;
 
@@ -34,13 +36,10 @@ class EntityReferenceFieldNormalizer extends FieldNormalizer {
   /**
    * Instantiates a EntityReferenceFieldNormalizer object.
    *
-   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
-   *   The JSON:API resource type repository.
    * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
    *   The entity repository.
    */
-  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, EntityRepositoryInterface $entity_repository) {
-    $this->resourceTypeRepository = $resource_type_repository;
+  public function __construct(EntityRepositoryInterface $entity_repository) {
     $this->entityRepository = $entity_repository;
   }
 
@@ -65,7 +64,7 @@ class EntityReferenceFieldNormalizer extends FieldNormalizer {
       ->getFieldStorageDefinition()
       ->getCardinality();
     $entity_list_metadata = [];
-    $entity_list = [];
+    $resource_objects = [];
     foreach ($field->filterEmptyItems() as $item) {
       // A non-empty entity reference field that refers to a non-existent entity
       // is not a data integrity problem. For example, Term entities' "parent"
@@ -74,7 +73,7 @@ class EntityReferenceFieldNormalizer extends FieldNormalizer {
       // cleaned up by Drupal; hence we map it to a "missing" resource.
       if ($item->get('entity')->getValue() === NULL) {
         if ($field->getFieldDefinition()->getFieldStorageDefinition()->getSetting('target_type') === 'taxonomy_term' && $item->get('target_id')->getCastedValue() === 0) {
-          $entity_list[] = NULL;
+          $resource_objects[] = NULL;
           $entity_list_metadata[] = [
             'links' => [
               'help' => [
@@ -87,7 +86,7 @@ class EntityReferenceFieldNormalizer extends FieldNormalizer {
           ];
         }
         else {
-          $entity_list[] = FALSE;
+          $resource_objects[] = FALSE;
           $entity_list_metadata[] = [
             'links' => [
               'help' => [
@@ -123,34 +122,42 @@ class EntityReferenceFieldNormalizer extends FieldNormalizer {
       $entity_list_metadata[] = $metadata;
 
       // Get the referenced entity.
-      $entity = $item->get('entity')->getValue();
+      $resource_object = $this->getTranslatedResourceObject($item);
 
-      if ($this->isInternalResourceType($entity)) {
+      if ($resource_object->getResourceType()->isInternal()) {
         continue;
       }
 
       // And get the translation in the requested language.
-      $entity_list[] = $this->entityRepository->getTranslationFromContext($entity);
+      $resource_objects[] = $resource_object;
     }
-    $entity_collection = new EntityCollection($entity_list, $cardinality);
-    $relationship = new Relationship($this->resourceTypeRepository, $field->getName(), $entity_collection, $field->getEntity(), $cacheabilty, $cardinality, $main_property, $entity_list_metadata);
+    $entity_collection = new EntityCollection($resource_objects, $cardinality);
+    $relationship = new Relationship($field->getName(), $entity_collection, $context['resource_object'], $cacheabilty, $cardinality, $main_property, $entity_list_metadata);
     return $this->serializer->normalize($relationship, $format, $context);
   }
 
   /**
-   * Determines if the given entity is of an internal resource type.
+   * Gets a translated entity reference target entity as a ResourceObject.
    *
-   * @param \Drupal\Core\Entity\EntityInterface $entity
-   *   The entity for which to check the internal status.
+   * @param \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem $item
+   *   An entity reference field item.
    *
-   * @return bool
-   *   TRUE if the entity's resource type is internal, FALSE otherwise.
+   * @return \Drupal\jsonapi\JsonApiResource\ResourceObject
+   *   The targeted resource object.
    */
-  protected function isInternalResourceType(EntityInterface $entity) {
-    return ($resource_type = $this->resourceTypeRepository->get(
-      $entity->getEntityTypeId(),
-      $entity->bundle()
-    )) && $resource_type->isInternal();
+  protected function getTranslatedResourceObject(EntityReferenceItem $item) {
+    // TODO: We are always loading the referenced entity. Even if it is not
+    // going to be included. That may be a performance issue. We do it because
+    // we need to know the entity type and bundle to load the JSON:API resource
+    // type for the relationship item and we need to know the UUID, which is not
+    // stored on the entity reference item. We need a better way of finding out
+    // about this.
+    assert($item instanceof FieldItemInterface);
+    $entity = $item->get('entity')->getValue();
+    assert($entity instanceof EntityInterface);
+    // @todo: determine if getting the entity translation is still relevant since includes are not processed differently.
+    $translation = $this->entityRepository->getTranslationFromContext($entity);
+    return EntityResource::getAccessCheckedResourceObject($translation);
   }
 
 }
diff --git a/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php b/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
index 622e434..1cacf67 100644
--- a/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
+++ b/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php
@@ -8,6 +8,7 @@ use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
 use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
 use Drupal\jsonapi\JsonApiResource\ErrorCollection;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
 use Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue;
 use Drupal\jsonapi\JsonApiResource\EntityCollection;
 use Drupal\jsonapi\LinkManager\LinkManager;
@@ -193,6 +194,7 @@ class JsonApiDocumentTopLevelNormalizer extends NormalizerBase implements Denorm
     }
 
     if ($data instanceof EntityReferenceFieldItemListInterface) {
+      $context['resource_object'] = new ResourceObject($context['resource_type'], $data->getEntity());
       $normalizer_values = [
         $this->serializer->normalize($data, $format, $context),
       ];
@@ -208,10 +210,10 @@ class JsonApiDocumentTopLevelNormalizer extends NormalizerBase implements Denorm
     }
     $is_collection = $data instanceof EntityCollection;
     // To improve the logical workflow deal with an array at all times.
-    $entities = $is_collection ? $data->toArray() : [$data];
+    $resource_objects = $is_collection ? $data->toArray() : [$data];
     $normalizer_values = array_map(function ($entity) use ($format, $context, $serializer) {
       return $serializer->normalize($entity, $format, $context);
-    }, $entities);
+    }, $resource_objects);
 
     if (!empty($omissions)) {
       $normalizer_values = array_merge($normalizer_values, $omissions);
diff --git a/src/Normalizer/LabelOnlyEntityNormalizer.php b/src/Normalizer/LabelOnlyEntityNormalizer.php
deleted file mode 100644
index 9b43e7a..0000000
--- a/src/Normalizer/LabelOnlyEntityNormalizer.php
+++ /dev/null
@@ -1,91 +0,0 @@
-<?php
-
-namespace Drupal\jsonapi\Normalizer;
-
-use Drupal\jsonapi\LabelOnlyEntity;
-use Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue;
-use Drupal\jsonapi\LinkManager\LinkManager;
-use Drupal\jsonapi\ResourceType\ResourceType;
-use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
-
-/**
- * Pretends that the entity only has a single field: the label field.
- *
- * @see \Drupal\jsonapi\Normalizer\EntityNormalizer::normalize()
- *
- * @internal
- */
-class LabelOnlyEntityNormalizer extends NormalizerBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected $supportedInterfaceOrClass = LabelOnlyEntity::class;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected $formats = ['api_json'];
-
-  /**
-   * The link manager.
-   *
-   * @var \Drupal\jsonapi\LinkManager\LinkManager
-   */
-  protected $linkManager;
-
-  /**
-   * The JSON:API resource type repository.
-   *
-   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
-   */
-  protected $resourceTypeRepository;
-
-  /**
-   * Constructs an LabelOnlyEntityNormalizer object.
-   *
-   * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager
-   *   The link manager.
-   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
-   *   The JSON:API resource type repository.
-   */
-  public function __construct(LinkManager $link_manager, ResourceTypeRepositoryInterface $resource_type_repository) {
-    $this->linkManager = $link_manager;
-    $this->resourceTypeRepository = $resource_type_repository;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function normalize($label_only_entity, $format = NULL, array $context = []) {
-    assert($label_only_entity instanceof LabelOnlyEntity);
-    $entity = $label_only_entity->getEntity();
-
-    $context['resource_type'] = $this->resourceTypeRepository->get(
-      $entity->getEntityTypeId(),
-      $entity->bundle()
-    );
-
-    // Determine the (internal) label field name.
-    $label_field_name = $label_only_entity->getLabelFieldName();
-
-    // Determine the public alias for the label field name.
-    assert($context['resource_type'] instanceof ResourceType);
-    $resource_type = $context['resource_type'];
-    $public_field_label_name = $resource_type->getPublicName($label_field_name);
-
-    // Perform the default entity normalization, extract all values from the
-    // resulting EntityNormalizerValue object.
-    // @see \Drupal\jsonapi\Normalizer\EntityNormalizer::normalize()
-    $full_normalized_entity = $this->serializer->normalize($entity, $format, $context);
-    assert($full_normalized_entity instanceof EntityNormalizerValue);
-    $all_values = $full_normalized_entity->getValues();
-
-    // Reconstruct an EntityNormalizerValue object, this time with only the
-    // label field.
-    $label_only_values = [$public_field_label_name => $all_values[$public_field_label_name]];
-    $link_context = ['link_manager' => $this->linkManager];
-    return new EntityNormalizerValue($label_only_values, $context, $entity, $link_context);
-  }
-
-}
diff --git a/src/Normalizer/Relationship.php b/src/Normalizer/Relationship.php
index 2711e1c..493a160 100644
--- a/src/Normalizer/Relationship.php
+++ b/src/Normalizer/Relationship.php
@@ -5,10 +5,9 @@ namespace Drupal\jsonapi\Normalizer;
 use Drupal\Core\Access\AccessibleInterface;
 use Drupal\Core\Cache\CacheableDependencyInterface;
 use Drupal\Core\Cache\CacheableDependencyTrait;
-use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Field\FieldStorageDefinitionInterface;
 use Drupal\Core\Session\AccountInterface;
-use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
 use Drupal\jsonapi\JsonApiResource\EntityCollection;
 
 /**
@@ -36,7 +35,7 @@ class Relationship implements AccessibleInterface, CacheableDependencyInterface
    *
    * @var \Drupal\Core\Entity\EntityInterface
    */
-  protected $hostEntity;
+  protected $hostResourceObject;
 
   /**
    * The field name.
@@ -45,13 +44,6 @@ class Relationship implements AccessibleInterface, CacheableDependencyInterface
    */
   protected $propertyName;
 
-  /**
-   * The JSON:API resource type repository.
-   *
-   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
-   */
-  protected $resourceTypeRepository;
-
   /**
    * The relationship items.
    *
@@ -62,13 +54,11 @@ class Relationship implements AccessibleInterface, CacheableDependencyInterface
   /**
    * Relationship constructor.
    *
-   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
-   *   The JSON:API resource type repository.
    * @param string $field_name
    *   The name of the relationship.
    * @param \Drupal\jsonapi\JsonApiResource\EntityCollection $entities
    *   A collection of entities.
-   * @param \Drupal\Core\Entity\EntityInterface $host_entity
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceObject $host_resource_object
    *   The host entity.
    * @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability
    *   The cacheability of this relationship and its metadata.
@@ -80,19 +70,17 @@ class Relationship implements AccessibleInterface, CacheableDependencyInterface
    *   An array of additional properties stored by the field and that will be
    *   added to the meta in the relationship.
    */
-  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, $field_name, EntityCollection $entities, EntityInterface $host_entity, CacheableDependencyInterface $cacheability, $cardinality = FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED, $target_key = 'target_id', array $entity_list_metadata = []) {
-    $this->resourceTypeRepository = $resource_type_repository;
+  public function __construct($field_name, EntityCollection $entities, ResourceObject $host_resource_object, CacheableDependencyInterface $cacheability, $cardinality = FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED, $target_key = 'target_id', array $entity_list_metadata = []) {
     $this->propertyName = $field_name;
     $this->cardinality = $cardinality;
-    $this->hostEntity = $host_entity;
+    $this->hostResourceObject = $host_resource_object;
 
     $this->setCacheability($cacheability);
 
     $this->items = [];
-    foreach ($entities as $key => $entity) {
+    foreach ($entities as $key => $resource_object) {
       $this->items[] = new RelationshipItem(
-        $resource_type_repository,
-        $entity,
+        $resource_object,
         $this,
         $target_key,
         $entity_list_metadata[$key]
@@ -113,21 +101,11 @@ class Relationship implements AccessibleInterface, CacheableDependencyInterface
   /**
    * Gets the host entity.
    *
-   * @return \Drupal\Core\Entity\EntityInterface
-   *   The entity which contains this relationship.
-   */
-  public function getHostEntity() {
-    return $this->hostEntity;
-  }
-
-  /**
-   * Sets the host entity.
-   *
-   * @param \Drupal\Core\Entity\EntityInterface $hostEntity
-   *   The host entity.
+   * @return \Drupal\jsonapi\JsonApiResource\ResourceObject
+   *   The resource object which contains this relationship.
    */
-  public function setHostEntity(EntityInterface $hostEntity) {
-    $this->hostEntity = $hostEntity;
+  public function getHostResourceObject() {
+    return $this->hostResourceObject;
   }
 
   /**
diff --git a/src/Normalizer/RelationshipItem.php b/src/Normalizer/RelationshipItem.php
index eb770ff..effbc7e 100644
--- a/src/Normalizer/RelationshipItem.php
+++ b/src/Normalizer/RelationshipItem.php
@@ -2,9 +2,8 @@
 
 namespace Drupal\jsonapi\Normalizer;
 
-use Drupal\Core\Entity\EntityInterface;
+use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface;
 use Drupal\jsonapi\ResourceType\ResourceType;
-use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
 
 /**
  * Value object representing a JSON:API relationship item.
@@ -23,9 +22,9 @@ class RelationshipItem {
   /**
    * The target entity.
    *
-   * @var \Drupal\Core\Entity\EntityInterface|null
+   * @var \Drupal\jsonapi\JsonApiResource\ResourceObject|null|false
    */
-  protected $targetEntity;
+  protected $targetResourceObject;
 
   /**
    * The target JSON:API resource type.
@@ -51,11 +50,9 @@ class RelationshipItem {
   /**
    * Relationship item constructor.
    *
-   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
-   *   The JSON:API resource type repository.
-   * @param \Drupal\Core\Entity\EntityInterface|null|false $target_entity
-   *   The entity this relationship points to, if any. NULL if virtual resource.
-   *   FALSE if missing resource (dangling entity reference).
+   * @param \Drupal\Core\Entity\EntityInterface|null|false $target_resource_object
+   *   The resource object that this relationship points to, if any. NULL if
+   *   virtual resource. FALSE if missing resource (dangling entity reference).
    * @param \Drupal\jsonapi\Normalizer\Relationship $parent
    *   The parent of this item.
    * @param string $target_key
@@ -63,23 +60,19 @@ class RelationshipItem {
    * @param array $metadata
    *   The list of metadata associated with this relationship item value.
    */
-  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, $target_entity, Relationship $parent, $target_key = 'target_id', array $metadata = []) {
-    assert($target_entity === NULL || $target_entity === FALSE || $target_entity instanceof EntityInterface);
-    if ($target_entity === NULL || $target_entity === FALSE) {
-      $host_entity = $parent->getHostEntity();
-      $relatable_resource_types = $resource_type_repository->get(
-        $host_entity->getEntityTypeId(),
-        $host_entity->bundle()
-      )->getRelatableResourceTypes()[$parent->getPropertyName()];
-
-      if ($target_entity === NULL) {
+  public function __construct($target_resource_object, Relationship $parent, $target_key = 'target_id', array $metadata = []) {
+    assert($target_resource_object === NULL || $target_resource_object === FALSE || $target_resource_object instanceof ResourceIdentifierInterface);
+    if ($target_resource_object === NULL || $target_resource_object === FALSE) {
+      $host_resource_type = $parent->getHostResourceObject()->getResourceType();
+      $relatable_resource_types = $host_resource_type->getRelatableResourceTypesByField($parent->getPropertyName());
+
+      if ($target_resource_object === NULL) {
         if (count($relatable_resource_types) !== 1) {
           throw new \RuntimeException('Relationships to virtual resources are possible only if a single resource type is relatable.');
         }
         $this->targetResourceType = reset($relatable_resource_types);
       }
       else {
-        assert($target_entity === FALSE);
         // In case of a dangling reference, it is impossible to determine which
         // resource type it used to reference, because that requires knowing the
         // referenced bundle, which Drupal does not store.
@@ -92,13 +85,10 @@ class RelationshipItem {
       }
     }
     else {
-      $this->targetResourceType = $resource_type_repository->get(
-        $target_entity->getEntityTypeId(),
-        $target_entity->bundle()
-      );
+      $this->targetResourceType = $target_resource_object->getResourceType();
     }
     $this->targetKey = $target_key;
-    $this->targetEntity = $target_entity;
+    $this->targetResourceObject = $target_resource_object;
     $this->parent = $parent;
     $this->metadata = $metadata;
   }
@@ -106,11 +96,11 @@ class RelationshipItem {
   /**
    * Gets the target entity.
    *
-   * @return \Drupal\Core\Entity\EntityInterface|null
+   * @return \Drupal\jsonapi\JsonApiResource\ResourceObject|null|false
    *   The target entity of this relationship item.
    */
-  public function getTargetEntity() {
-    return $this->targetEntity;
+  public function getTargetResourceObject() {
+    return $this->targetResourceObject;
   }
 
   /**
@@ -132,11 +122,11 @@ class RelationshipItem {
    *   The value of this relationship item.
    */
   public function getValue() {
-    $target_uuid = $this->targetEntity === NULL
+    $target_uuid = $this->targetResourceObject === NULL
       ? 'virtual'
-      : ($this->targetEntity === FALSE
+      : ($this->targetResourceObject === FALSE
         ? 'missing'
-        : $this->getTargetEntity()->uuid());
+        : $this->getTargetResourceObject()->getId());
 
     return [
       'target_uuid' => $target_uuid,
diff --git a/src/Normalizer/RelationshipItemNormalizer.php b/src/Normalizer/RelationshipItemNormalizer.php
index 96e5538..3b91769 100644
--- a/src/Normalizer/RelationshipItemNormalizer.php
+++ b/src/Normalizer/RelationshipItemNormalizer.php
@@ -42,11 +42,6 @@ class RelationshipItemNormalizer extends FieldItemNormalizer {
    */
   public function normalize($relationship_item, $format = NULL, array $context = []) {
     /* @var $relationship_item \Drupal\jsonapi\Normalizer\RelationshipItem */
-    // TODO: We are always loading the referenced entity. Even if it is not
-    // going to be included. That may be a performance issue. We do it because
-    // we need to know the entity type and bundle to load the JSON:API resource
-    // type for the relationship item. We need a better way of finding about
-    // this.
     $values = $relationship_item->getValue();
     if (isset($context['langcode'])) {
       $values['lang'] = $context['langcode'];
diff --git a/src/Normalizer/RelationshipNormalizer.php b/src/Normalizer/RelationshipNormalizer.php
index feb737a..b7fdf08 100644
--- a/src/Normalizer/RelationshipNormalizer.php
+++ b/src/Normalizer/RelationshipNormalizer.php
@@ -194,7 +194,7 @@ class RelationshipNormalizer extends NormalizerBase implements DenormalizerInter
     assert($context['resource_type'] instanceof ResourceType);
     $resource_type = $context['resource_type'];
     $link_context = [
-      'host_entity_id' => $relationship->getHostEntity()->uuid(),
+      'host_entity_id' => $relationship->getHostResourceObject()->getId(),
       'field_name' => $resource_type->getPublicName($relationship->getPropertyName()),
       'link_manager' => $this->linkManager,
       'resource_type' => $resource_type,
diff --git a/src/Normalizer/ResourceObjectNormalizer.php b/src/Normalizer/ResourceObjectNormalizer.php
new file mode 100644
index 0000000..d89abb7
--- /dev/null
+++ b/src/Normalizer/ResourceObjectNormalizer.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace Drupal\jsonapi\Normalizer;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
+use Drupal\jsonapi\LabelOnlyEntity;
+use Drupal\jsonapi\Normalizer\Value\ConfigFieldItemNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\FieldNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\ResourceObjectNormalizerValue;
+use Drupal\jsonapi\Normalizer\Value\FieldNormalizerValueInterface;
+use Drupal\jsonapi\LinkManager\LinkManager;
+
+/**
+ * Converts the JSON:API module ResourceObject into a JSON:API array structure.
+ *
+ * @internal
+ */
+class ResourceObjectNormalizer extends NormalizerBase {
+
+  /**
+   * The interface or class that this Normalizer supports.
+   *
+   * @var string
+   */
+  protected $supportedInterfaceOrClass = [
+    ResourceObject::class,
+    LabelOnlyEntity::class,
+  ];
+
+  /**
+   * The formats that the Normalizer can handle.
+   *
+   * @var array
+   */
+  protected $formats = ['api_json'];
+
+  /**
+   * The link manager.
+   *
+   * @var \Drupal\jsonapi\LinkManager\LinkManager
+   */
+  protected $linkManager;
+
+  /**
+   * Constructs an EntityNormalizer object.
+   *
+   * @param \Drupal\jsonapi\LinkManager\LinkManager $link_manager
+   *   The link manager.
+   */
+  public function __construct(LinkManager $link_manager) {
+    $this->linkManager = $link_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supportsDenormalization($data, $type, $format = NULL) {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = []) {
+    assert($object instanceof ResourceObject);
+    // If the fields to use were specified, only output those field values.
+    $context['resource_object'] = $object;
+    $context['resource_type'] = $resource_type = $object->getResourceType();
+    // Get the bundle ID of the requested resource. This is used to determine if
+    // this is a bundle level resource or an entity level resource.
+    $resource_type_name = $resource_type->getTypeName();
+    if (!empty($context['sparse_fieldset'][$resource_type_name])) {
+      $field_names = $context['sparse_fieldset'][$resource_type_name];
+    }
+    else {
+      $field_names = $this->getFieldNames($object);
+    }
+    /* @var Value\FieldNormalizerValueInterface[] $normalizer_values */
+    $normalizer_values = [];
+    foreach ($object->getFields() as $field_name => $field) {
+      $normalized_field = $this->serializeField($field, $context, $format);
+      assert($normalized_field instanceof FieldNormalizerValueInterface);
+
+      $in_sparse_fieldset = in_array($field_name, $field_names);
+      // Omit fields not listed in sparse fieldsets.
+      if (!$in_sparse_fieldset) {
+        continue;
+      }
+      $normalizer_values[$field_name] = $normalized_field;
+    }
+
+    $link_context = ['link_manager' => $this->linkManager];
+    return new ResourceObjectNormalizerValue($normalizer_values, $context, $object, $link_context);
+  }
+
+  /**
+   * Gets the field names for the given entity.
+   *
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object
+   *   The resource object.
+   *
+   * @return string[]
+   *   The field names.
+   */
+  protected function getFieldNames(ResourceObject $object) {
+    /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+    return array_keys($object->getFields());
+  }
+
+  /**
+   * Serializes a given field.
+   *
+   * @param mixed $field
+   *   The field to serialize.
+   * @param array $context
+   *   The normalization context.
+   * @param string $format
+   *   The serialization format.
+   *
+   * @return Value\FieldNormalizerValueInterface
+   *   The normalized value.
+   */
+  protected function serializeField($field, array $context, $format) {
+    if (!$field instanceof FieldItemListInterface) {
+      // Config entities have no concept of "fields", nor any concept of
+      // "field access". For practical reasons, JSON:API uses the same value
+      // object that it uses for content entities (FieldNormalizerValue), and
+      // that requires an access result. Therefore we can safely hardcode it.
+      return new FieldNormalizerValue(
+        AccessResult::allowed(),
+        [new ConfigFieldItemNormalizerValue($field)],
+        1,
+        'attributes'
+      );
+    }
+    return $this->serializer->normalize($field, $format, $context);
+  }
+
+}
diff --git a/src/Normalizer/Value/EntityNormalizerValue.php b/src/Normalizer/Value/ResourceObjectNormalizerValue.php
similarity index 75%
rename from src/Normalizer/Value/EntityNormalizerValue.php
rename to src/Normalizer/Value/ResourceObjectNormalizerValue.php
index 83dc017..c9c3f7e 100644
--- a/src/Normalizer/Value/EntityNormalizerValue.php
+++ b/src/Normalizer/Value/ResourceObjectNormalizerValue.php
@@ -4,14 +4,14 @@ namespace Drupal\jsonapi\Normalizer\Value;
 
 use Drupal\Core\Cache\CacheableDependencyInterface;
 use Drupal\Core\Cache\CacheableDependencyTrait;
-use Drupal\Core\Entity\EntityInterface;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
 
 /**
  * Helps normalize entities in compliance with the JSON:API spec.
  *
  * @internal
  */
-class EntityNormalizerValue implements ValueExtractorInterface, CacheableDependencyInterface {
+class ResourceObjectNormalizerValue implements ValueExtractorInterface, CacheableDependencyInterface {
 
   use CacheableDependencyTrait;
   use CacheableDependenciesMergerTrait;
@@ -31,11 +31,11 @@ class EntityNormalizerValue implements ValueExtractorInterface, CacheableDepende
   protected $context;
 
   /**
-   * The resource entity.
+   * The resource object.
    *
-   * @var \Drupal\Core\Entity\EntityInterface
+   * @var \Drupal\jsonapi\JsonApiResource\ResourceObject
    */
-  protected $entity;
+  protected $object;
 
   /**
    * The link manager.
@@ -51,20 +51,20 @@ class EntityNormalizerValue implements ValueExtractorInterface, CacheableDepende
    *   The normalized result.
    * @param array $context
    *   The context for the normalizer.
-   * @param \Drupal\Core\Entity\EntityInterface $entity
+   * @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object
    *   The entity.
    * @param array $link_context
    *   All the objects and variables needed to generate the links for this
    *   relationship.
    */
-  public function __construct(array $values, array $context, EntityInterface $entity, array $link_context) {
-    $this->setCacheability(static::mergeCacheableDependencies(array_merge([$entity], $values)));
+  public function __construct(array $values, array $context, ResourceObject $object, array $link_context) {
+    $this->setCacheability(static::mergeCacheableDependencies(array_merge([$object], $values)));
 
     $this->values = array_filter($values, function ($value) {
       return !($value instanceof NullFieldNormalizerValue);
     });
     $this->context = $context;
-    $this->entity = $entity;
+    $this->object = $object;
     $this->linkManager = $link_context['link_manager'];
   }
 
@@ -73,15 +73,16 @@ class EntityNormalizerValue implements ValueExtractorInterface, CacheableDepende
    */
   public function rasterizeValue() {
     // Create the array of normalized fields, starting with the URI.
+    $resource_type = $this->object->getResourceType();
     $rasterized = [
-      'type' => $this->context['resource_type']->getTypeName(),
-      'id' => $this->entity->uuid(),
+      'type' => $resource_type->getTypeName(),
+      'id' => $this->object->getId(),
       'attributes' => [],
       'relationships' => [],
     ];
     $rasterized['links']['self']['href'] = $this->linkManager->getEntityLink(
       $rasterized['id'],
-      $this->context['resource_type'],
+      $resource_type,
       [],
       'individual'
     );
diff --git a/tests/src/Functional/ResourceTestBase.php b/tests/src/Functional/ResourceTestBase.php
index dc137b5..aebf778 100644
--- a/tests/src/Functional/ResourceTestBase.php
+++ b/tests/src/Functional/ResourceTestBase.php
@@ -26,6 +26,7 @@ use Drupal\Core\Url;
 use Drupal\field\Entity\FieldConfig;
 use Drupal\field\Entity\FieldStorageConfig;
 use Drupal\jsonapi\JsonApiResource\NullEntityCollection;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
 use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer;
 use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
 use Drupal\jsonapi\ResourceResponse;
@@ -344,13 +345,14 @@ abstract class ResourceTestBase extends BrowserTestBase {
    *   The JSON:API normalization for the given entity.
    */
   protected function normalize(EntityInterface $entity, Url $url) {
-    $doc = new JsonApiDocumentTopLevel($entity, new NullEntityCollection(), [
+    $resource_type = $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName);
+    $doc = new JsonApiDocumentTopLevel(new ResourceObject($resource_type, $entity), new NullEntityCollection(), [
       'self' => [
         'href' => $url->toString(TRUE)->getGeneratedUrl(),
       ],
     ]);
     return $this->serializer->normalize($doc, 'api_json', [
-      'resource_type' => $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName),
+      'resource_type' => $resource_type,
       'account' => $this->account,
     ])->rasterizeValue();
   }
diff --git a/tests/src/Kernel/Controller/EntityResourceTest.php b/tests/src/Kernel/Controller/EntityResourceTest.php
index 7a4e309..7d19eb3 100644
--- a/tests/src/Kernel/Controller/EntityResourceTest.php
+++ b/tests/src/Kernel/Controller/EntityResourceTest.php
@@ -8,6 +8,7 @@ use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
 use Drupal\Core\Field\FieldStorageDefinitionInterface;
 use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
 use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
 use Drupal\jsonapi\ResourceType\ResourceType;
 use Drupal\jsonapi\Controller\EntityResource;
 use Drupal\jsonapi\JsonApiResource\EntityCollection;
@@ -197,7 +198,7 @@ class EntityResourceTest extends JsonapiKernelTestBase {
   public function testGetIndividual() {
     $response = $this->entityResource->getIndividual($this->node, new Request());
     $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
-    $this->assertEquals(1, $response->getResponseData()->getData()->id());
+    $this->assertEquals($this->node->uuid(), $response->getResponseData()->getData()->getId());
   }
 
   /**
@@ -227,7 +228,7 @@ class EntityResourceTest extends JsonapiKernelTestBase {
     // Assertions.
     $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
     $this->assertInstanceOf(EntityCollection::class, $response->getResponseData()->getData());
-    $this->assertEquals(1, $response->getResponseData()->getData()->getIterator()->current()->id());
+    $this->assertEquals($this->node->uuid(), $response->getResponseData()->getData()->getIterator()->current()->getId());
     $this->assertEquals([
       'node:1',
       'node:2',
@@ -312,7 +313,8 @@ class EntityResourceTest extends JsonapiKernelTestBase {
     $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
     $this->assertInstanceOf(EntityCollection::class, $response->getResponseData()->getData());
     $this->assertCount(2, $response->getResponseData()->getData());
-    $this->assertEquals($response->getResponseData()->getData()->toArray()[0]->id(), 'lorem');
+    // `drupal_internal__type` is the alias for a node_type entity's ID field.
+    $this->assertEquals($response->getResponseData()->getData()->toArray()[0]->getField('drupal_internal__type'), 'lorem');
     $expected_cache_tags = [
       'config:node.type.article',
       'config:node.type.lorem',
@@ -356,7 +358,7 @@ class EntityResourceTest extends JsonapiKernelTestBase {
     $this->assertInstanceOf(EntityCollection::class, $response->getResponseData()->getData());
     $data = $response->getResponseData()->getData();
     $this->assertCount(1, $data);
-    $this->assertEquals(2, $data->toArray()[0]->id());
+    $this->assertEquals($this->node2->uuid(), $data->toArray()[0]->getId());
     $this->assertEquals(['node:2', 'node_list'], $response->getCacheableMetadata()->getCacheTags());
   }
 
@@ -400,8 +402,8 @@ class EntityResourceTest extends JsonapiKernelTestBase {
     ]);
     $response = $this->entityResource->getRelated($resource_type, $this->node, 'uid', new Request());
     $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
-    $this->assertInstanceOf(User::class, $response->getResponseData()->getData()->toArray()[0]);
-    $this->assertEquals(1, $response->getResponseData()->getData()->toArray()[0]->id());
+    $this->assertInstanceOf(ResourceObject::class, $response->getResponseData()->getData()->toArray()[0]);
+    $this->assertEquals($this->user->uuid(), $response->getResponseData()->getData()->toArray()[0]->getId());
     $this->assertEquals(['node:1'], $response->getCacheableMetadata()->getCacheTags());
     // to-many relationship.
     $response = $this->entityResource->getRelated($resource_type, $this->node4, 'field_relationships', new Request());
@@ -458,7 +460,7 @@ class EntityResourceTest extends JsonapiKernelTestBase {
     // As a side effect, the node will also be saved.
     $this->assertNotEmpty($node->id());
     $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
-    $this->assertEquals(5, $response->getResponseData()->getData()->id());
+    $this->assertEquals($node->uuid(), $response->getResponseData()->getData()->getId());
     $this->assertEquals(201, $response->getStatusCode());
   }
 
@@ -526,7 +528,7 @@ class EntityResourceTest extends JsonapiKernelTestBase {
     // As a side effect, the node will also be saved.
     $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData());
     $updated_node = $response->getResponseData()->getData();
-    $this->assertInstanceOf(Node::class, $updated_node);
+    $this->assertInstanceOf(ResourceObject::class, $updated_node);
     $this->assertSame($values['title'], $this->node->getTitle());
     $this->assertSame($values['field_relationships'], $this->node->get('field_relationships')->getValue());
     $this->assertEquals(200, $response->getStatusCode());
diff --git a/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php b/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
index ff368a3..a2ac745 100644
--- a/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
+++ b/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php
@@ -9,6 +9,7 @@ use Drupal\Core\Field\FieldStorageDefinitionInterface;
 use Drupal\file\Entity\File;
 use Drupal\jsonapi\JsonApiResource\ErrorCollection;
 use Drupal\jsonapi\JsonApiResource\NullEntityCollection;
+use Drupal\jsonapi\JsonApiResource\ResourceObject;
 use Drupal\jsonapi\LinkManager\LinkManager;
 use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
 use Drupal\node\Entity\Node;
@@ -218,12 +219,14 @@ class JsonApiDocumentTopLevelNormalizerTest extends JsonapiKernelTestBase {
   public function testNormalize() {
     list($request, $resource_type) = $this->generateProphecies('node', 'article');
 
-    $includes = $this->includeResolver->resolve($resource_type, $this->node, 'uid,field_tags,field_image');
+    $resource_object = new ResourceObject($resource_type, $this->node);
+
+    $includes = $this->includeResolver->resolve($resource_type, $resource_object, 'uid,field_tags,field_image');
 
     $jsonapi_doc_object = $this
       ->getNormalizer()
       ->normalize(
-        new JsonApiDocumentTopLevel($this->node, $includes, []),
+        new JsonApiDocumentTopLevel($resource_object, $includes, []),
         'api_json',
         [
           'resource_type' => $resource_type,
@@ -347,9 +350,10 @@ class JsonApiDocumentTopLevelNormalizerTest extends JsonapiKernelTestBase {
    */
   public function testNormalizeUuid() {
     list($request, $resource_type) = $this->generateProphecies('node', 'article', 'uuid');
+    $resource_object = new ResourceObject($resource_type, $this->node);
     $include_param = 'uid,field_tags';
-    $includes = $this->includeResolver->resolve($resource_type, $this->node, $include_param);
-    $document_wrapper = new JsonApiDocumentTopLevel($this->node, $includes, []);
+    $includes = $this->includeResolver->resolve($resource_type, $resource_object, $include_param);
+    $document_wrapper = new JsonApiDocumentTopLevel($resource_object, $includes, []);
 
     $request->query = new ParameterBag([
       'fields' => [
@@ -420,7 +424,8 @@ class JsonApiDocumentTopLevelNormalizerTest extends JsonapiKernelTestBase {
    */
   public function testNormalizeConfig() {
     list($request, $resource_type) = $this->generateProphecies('node_type', 'node_type', 'id');
-    $document_wrapper = new JsonApiDocumentTopLevel($this->nodeType, new NullEntityCollection(), []);
+    $resource_object = new ResourceObject($resource_type, $this->nodeType);
+    $document_wrapper = new JsonApiDocumentTopLevel($resource_object, new NullEntityCollection(), []);
 
     $jsonapi_doc_object = $this
       ->getNormalizer()
@@ -693,11 +698,12 @@ class JsonApiDocumentTopLevelNormalizerTest extends JsonapiKernelTestBase {
    */
   public function testCacheableMetadata(CacheableMetadata $expected_metadata, $fields = NULL, $includes = NULL) {
     list($request, $resource_type) = $this->generateProphecies('node', 'article');
+    $resource_object = new ResourceObject($resource_type, $this->node);
     $context = [
       'resource_type' => $resource_type,
       'account' => NULL,
     ];
-    $jsonapi_doc_object = $this->getNormalizer()->normalize(new JsonApiDocumentTopLevel($this->node, new NullEntityCollection(), []), 'api_json', $context);
+    $jsonapi_doc_object = $this->getNormalizer()->normalize(new JsonApiDocumentTopLevel($resource_object, new NullEntityCollection(), []), 'api_json', $context);
     $this->assertArraySubset($expected_metadata->getCacheTags(), $jsonapi_doc_object->getCacheTags());
     $this->assertArraySubset($expected_metadata->getCacheContexts(), $jsonapi_doc_object->getCacheContexts());
     $this->assertSame($expected_metadata->getCacheMaxAge(), $jsonapi_doc_object->getCacheMaxAge());
diff --git a/tests/src/Unit/Normalizer/ConfigEntityNormalizerTest.php b/tests/src/Unit/Normalizer/ConfigEntityNormalizerTest.php
deleted file mode 100644
index 79987c4..0000000
--- a/tests/src/Unit/Normalizer/ConfigEntityNormalizerTest.php
+++ /dev/null
@@ -1,103 +0,0 @@
-<?php
-
-namespace Drupal\Tests\jsonapi\Unit\Normalizer;
-
-use Drupal\Core\Config\Entity\ConfigEntityInterface;
-use Drupal\Core\Entity\EntityFieldManagerInterface;
-use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Drupal\Core\Field\FieldTypePluginManagerInterface;
-use Drupal\jsonapi\ResourceType\ResourceType;
-use Drupal\jsonapi\ResourceType\ResourceTypeRepository;
-use Drupal\jsonapi\Normalizer\ConfigEntityNormalizer;
-use Drupal\jsonapi\LinkManager\LinkManager;
-use Drupal\Tests\UnitTestCase;
-use Prophecy\Argument;
-
-/**
- * @coversDefaultClass \Drupal\jsonapi\Normalizer\ConfigEntityNormalizer
- * @group jsonapi
- *
- * @internal
- */
-class ConfigEntityNormalizerTest extends UnitTestCase {
-
-  /**
-   * The normalizer under test.
-   *
-   * @var \Drupal\jsonapi\Normalizer\ConfigEntityNormalizer
-   */
-  protected $normalizer;
-
-  /**
-   * {@inheritdoc}
-   */
-  public function setUp() {
-    $link_manager = $this->prophesize(LinkManager::class);
-
-    $field_mapping = array_fill_keys([
-      'lorem',
-      'ipsum',
-      'dolor',
-      'sid',
-      'amet',
-      'ra',
-      'foo',
-    ], TRUE);
-    $resource_type = new ResourceType('dolor', 'sid', NULL, FALSE, TRUE, TRUE, $field_mapping);
-    $resource_type->setRelatableResourceTypes([]);
-    $resource_type_repository = $this->prophesize(ResourceTypeRepository::class);
-    $resource_type_repository->get(Argument::type('string'), Argument::type('string'))
-      ->willReturn($resource_type);
-
-    $this->normalizer = new ConfigEntityNormalizer(
-      $link_manager->reveal(),
-      $resource_type_repository->reveal(),
-      $this->prophesize(EntityTypeManagerInterface::class)->reveal(),
-      $this->prophesize(EntityFieldManagerInterface::class)->reveal(),
-      $this->prophesize(FieldTypePluginManagerInterface::class)->reveal()
-    );
-  }
-
-  /**
-   * @covers ::normalize
-   * @dataProvider normalizeProvider
-   */
-  public function testNormalize($input, $expected) {
-    $entity = $this->prophesize(ConfigEntityInterface::class);
-    $entity->toArray()->willReturn(['amet' => $input]);
-    $entity->getCacheContexts()->willReturn([]);
-    $entity->getCacheTags()->willReturn([]);
-    $entity->getCacheMaxAge()->willReturn(-1);
-    $entity->getEntityTypeId()->willReturn('');
-    $entity->bundle()->willReturn('');
-    $normalized = $this->normalizer->normalize($entity->reveal(), 'api_json', []);
-    $first = $normalized->getValues();
-    $first = reset($first);
-    $this->assertSame($expected, $first->rasterizeValue());
-  }
-
-  /**
-   * Data provider for the normalize test.
-   *
-   * @return array
-   *   The data for the test method.
-   */
-  public function normalizeProvider() {
-    return [
-      ['lorem', 'lorem'],
-      [
-        ['ipsum' => 'dolor', 'ra' => 'foo'],
-        ['ipsum' => 'dolor', 'ra' => 'foo'],
-      ],
-      [
-        ['ipsum' => 'dolor'],
-        ['ipsum' => 'dolor'],
-      ],
-      [
-        ['lorem' => ['ipsum' => ['dolor' => 'sid', 'amet' => 'ra']]],
-        ['lorem' => ['ipsum' => ['dolor' => 'sid', 'amet' => 'ra']]],
-      ],
-    ];
-  }
-
-}
diff --git a/tests/src/Unit/Normalizer/Value/EntityNormalizerValueTest.php b/tests/src/Unit/Normalizer/Value/EntityNormalizerValueTest.php
deleted file mode 100644
index a3857f8..0000000
--- a/tests/src/Unit/Normalizer/Value/EntityNormalizerValueTest.php
+++ /dev/null
@@ -1,149 +0,0 @@
-<?php
-
-namespace Drupal\Tests\jsonapi\Unit\Normalizer\Value;
-
-use Drupal\Core\DependencyInjection\ContainerBuilder;
-use Drupal\Core\Entity\EntityInterface;
-use Drupal\jsonapi\ResourceType\ResourceType;
-use Drupal\jsonapi\LinkManager\LinkManager;
-use Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue;
-use Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue;
-use Drupal\jsonapi\Normalizer\Value\RelationshipNormalizerValue;
-use Drupal\jsonapi\Normalizer\Value\FieldNormalizerValueInterface;
-use Drupal\node\NodeInterface;
-use Drupal\Tests\UnitTestCase;
-use Prophecy\Argument;
-
-/**
- * @coversDefaultClass \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue
- * @group jsonapi
- *
- * @internal
- */
-class EntityNormalizerValueTest extends UnitTestCase {
-
-  /**
-   * The EntityNormalizerValue object.
-   *
-   * @var \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue
-   */
-  protected $object;
-
-  /**
-   * The cache contexts manager.
-   *
-   * @var \Drupal\Core\Cache\Context\CacheContextsManager|\PHPUnit_Framework_MockObject_MockObject
-   */
-  protected $cacheContextsManager;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp() {
-    parent::setUp();
-
-    $this->cacheContextsManager = $this->getMockBuilder('Drupal\Core\Cache\Context\CacheContextsManager')
-      ->disableOriginalConstructor()
-      ->getMock();
-    $this->cacheContextsManager->method('assertValidTokens')->willReturn(TRUE);
-
-    $container = new ContainerBuilder();
-    $container->set('cache_contexts_manager', $this->cacheContextsManager);
-    \Drupal::setContainer($container);
-
-    $field1 = $this->prophesize(FieldNormalizerValueInterface::class);
-    $field1->getPropertyType()->willReturn('attributes');
-    $field1->rasterizeValue()->willReturn('dummy_title');
-    $field1->getCacheContexts()->willReturn(['ccbar']);
-    $field1->getCacheTags()->willReturn(['ctbar']);
-    $field1->getCacheMaxAge()->willReturn(20);
-    $field2 = $this->prophesize(RelationshipNormalizerValue::class);
-    $field2->getPropertyType()->willReturn('relationships');
-    $field2->rasterizeValue()->willReturn(['data' => ['type' => 'node', 'id' => 2]]);
-    $field2->getCacheContexts()->willReturn(['ccbaz']);
-    $field2->getCacheTags()->willReturn(['ctbaz']);
-    $field2->getCacheMaxAge()->willReturn(25);
-    $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class);
-    $included[0]->rasterizeValue()->willReturn([
-      'data' => [
-        'type' => 'node',
-        'id' => '199c681d-a9dc-4b6f-a4dc-e3811f24141b',
-        'attributes' => ['body' => 'dummy_body1'],
-      ],
-    ]);
-    $included[0]->getCacheContexts()->willReturn(['lorem', 'ipsum']);
-    // Type & id duplicated on purpose.
-    $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class);
-    $included[1]->rasterizeValue()->willReturn([
-      'data' => [
-        'type' => 'node',
-        'id' => '199c681d-a9dc-4b6f-a4dc-e3811f24141b',
-        'attributes' => ['body' => 'dummy_body2'],
-      ],
-    ]);
-    $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class);
-    $included[2]->rasterizeValue()->willReturn([
-      'data' => [
-        'type' => 'node',
-        'id' => '83771375-a4ba-4d7d-a4d5-6153095bb5c5',
-        'attributes' => ['body' => 'dummy_body3'],
-      ],
-    ]);
-    $context = [
-      'resource_type' => new ResourceType('node', 'article',
-        NodeInterface::class),
-    ];
-    $entity = $this->prophesize(EntityInterface::class);
-    $entity->uuid()->willReturn('248150b2-79a2-4b44-9f49-bf405a51414a');
-    $entity->isNew()->willReturn(FALSE);
-    $entity->getEntityTypeId()->willReturn('node');
-    $entity->bundle()->willReturn('article');
-    $entity->getCacheContexts()->willReturn(['ccfoo']);
-    $entity->getCacheTags()->willReturn(['ctfoo']);
-    $entity->getCacheMaxAge()->willReturn(15);
-    $link_manager = $this->prophesize(LinkManager::class);
-    $link_manager
-      ->getEntityLink(Argument::any(), Argument::any(), Argument::type('array'), Argument::type('string'))
-      ->willReturn('dummy_entity_link');
-
-    // Stub the addCacheableDependency on the SUT. We'll test the cacheable
-    // metadata bubbling using Kernel tests.
-    $this->object = $this->getMockBuilder(EntityNormalizerValue::class)
-      ->setMethods(['addCacheableDependency'])
-      ->setConstructorArgs([
-        ['title' => $field1->reveal(), 'field_related' => $field2->reveal()],
-        $context,
-        $entity->reveal(),
-        ['link_manager' => $link_manager->reveal()],
-      ])
-      ->getMock();
-    $this->object->method('addCacheableDependency');
-  }
-
-  /**
-   * @covers ::__construct
-   */
-  public function testCacheability() {
-    $this->assertSame(['ccbar', 'ccbaz', 'ccfoo'], $this->object->getCacheContexts());
-    $this->assertSame(['ctbar', 'ctbaz', 'ctfoo'], $this->object->getCacheTags());
-    $this->assertSame(15, $this->object->getCacheMaxAge());
-  }
-
-  /**
-   * @covers ::rasterizeValue
-   */
-  public function testRasterizeValue() {
-    $this->assertEquals([
-      'type' => 'node--article',
-      'id' => '248150b2-79a2-4b44-9f49-bf405a51414a',
-      'attributes' => ['title' => 'dummy_title'],
-      'relationships' => [
-        'field_related' => ['data' => ['type' => 'node', 'id' => 2]],
-      ],
-      'links' => [
-        'self' => ['href' => 'dummy_entity_link'],
-      ],
-    ], $this->object->rasterizeValue());
-  }
-
-}
