diff --git a/core/modules/rest/src/Plugin/rest/resource/EntityResourceValidationTrait.php b/core/modules/rest/src/Plugin/rest/resource/EntityResourceValidationTrait.php
index 09b4b64bae..c1637e9fc1 100644
--- a/core/modules/rest/src/Plugin/rest/resource/EntityResourceValidationTrait.php
+++ b/core/modules/rest/src/Plugin/rest/resource/EntityResourceValidationTrait.php
@@ -2,10 +2,9 @@
 
 namespace Drupal\rest\Plugin\rest\resource;
 
-use Drupal\Component\Render\PlainTextOutput;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\FieldableEntityInterface;
-use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
+use Drupal\serialization\Exception\EntityValidationException;
 
 /**
  * @internal
@@ -19,7 +18,7 @@
    * @param \Drupal\Core\Entity\EntityInterface $entity
    *   The entity to validate.
    *
-   * @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
+   * @throws \Drupal\serialization\Exception\EntityValidationException
    *   If validation errors are found.
    */
   protected function validate(EntityInterface $entity) {
@@ -34,13 +33,7 @@ protected function validate(EntityInterface $entity) {
     $violations->filterByFieldAccess();
 
     if ($violations->count() > 0) {
-      $message = "Unprocessable Entity: validation failed.\n";
-      foreach ($violations as $violation) {
-        // We strip every HTML from the error message to have a nicer to read
-        // message on REST responses.
-        $message .= $violation->getPropertyPath() . ': ' . PlainTextOutput::renderFromHtml($violation->getMessage()) . "\n";
-      }
-      throw new UnprocessableEntityHttpException($message);
+      throw new EntityValidationException($violations, "Validation of the {$entity->getEntityTypeId()} entity failed");
     }
   }
 
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
index 713d403218..cc21b48868 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
@@ -696,7 +696,14 @@ public function testPost() {
     $response = $this->request('POST', $url, $request_options);
     $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
     $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
-    $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\n$label_field: $label_field_capitalized: this field cannot hold more than 1 values.\n", $response);
+    $entity_type_id = static::$entityTypeId;
+    $error = [
+      'message' => "Validation of the $entity_type_id entity failed",
+      'errors' => [
+        $label_field => ["$label_field_capitalized: this field cannot hold more than 1 values."],
+      ],
+    ];
+    $this->assertResourceResponse(422, $this->serializer->encode($error, static::$format), $response);
 
 
     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
@@ -704,7 +711,13 @@ public function testPost() {
 
     // DX: 422 when invalid entity: UUID field too long.
     $response = $this->request('POST', $url, $request_options);
-    $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nuuid.0.value: UUID: may not be longer than 128 characters.\n", $response);
+    $error = [
+      'message' => "Validation of the $entity_type_id entity failed",
+      'errors' => [
+        'uuid.0.value' => ['UUID: may not be longer than 128 characters.'],
+      ],
+    ];
+    $this->assertResourceResponse(422, $this->serializer->encode($error, static::$format), $response);
 
 
     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3;
@@ -885,7 +898,13 @@ public function testPatch() {
     $response = $this->request('PATCH', $url, $request_options);
     $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName;
     $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel();
-    $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\n$label_field: $label_field_capitalized: this field cannot hold more than 1 values.\n", $response);
+    $error = [
+      'message' => "Validation of the {$this->entity->getEntityTypeId()} entity failed",
+      'errors' => [
+        $label_field => ["$label_field_capitalized: this field cannot hold more than 1 values."],
+      ],
+    ];
+    $this->assertResourceResponse(422, json_encode($error), $response);
 
 
     $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2;
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php
index 04fe435a42..5cbbafb7b5 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/User/UserResourceTestBase.php
@@ -163,7 +163,13 @@ public function testPatchDxForSecuritySensitiveBaseFields() {
 
     // DX: 422 when changing email without providing the password.
     $response = $this->request('PATCH', $url, $request_options);
-    $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the Email.\n", $response);
+    $error = [
+      'message' => "Validation of the {$this->entity->getEntityTypeId()} entity failed",
+      'errors' => [
+        'mail' => ["Your current password is missing or incorrect; it's required to change the Email."],
+      ],
+    ];
+    $this->assertResourceResponse(422, $this->serializer->encode($error, static::$format), $response);
 
 
     $normalization['pass'] = [['existing' => 'wrong']];
@@ -171,7 +177,8 @@ public function testPatchDxForSecuritySensitiveBaseFields() {
 
     // DX: 422 when changing email while providing a wrong password.
     $response = $this->request('PATCH', $url, $request_options);
-    $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nmail: Your current password is missing or incorrect; it's required to change the Email.\n", $response);
+    // An error remains unchanged.
+    $this->assertResourceResponse(422, $this->serializer->encode($error, static::$format), $response);
 
 
     $normalization['pass'] = [['existing' => $this->account->passRaw]];
@@ -192,7 +199,13 @@ public function testPatchDxForSecuritySensitiveBaseFields() {
 
     // DX: 422 when changing password without providing the current password.
     $response = $this->request('PATCH', $url, $request_options);
-    $this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\npass: Your current password is missing or incorrect; it's required to change the Password.\n", $response);
+    $error = [
+      'message' => 'Validation of the user entity failed',
+      'errors' => [
+        'pass' => ["Your current password is missing or incorrect; it's required to change the Password."],
+      ],
+    ];
+   $this->assertResourceResponse(422, $this->serializer->encode($error, static::$format), $response);
 
 
     $normalization['pass'][0]['existing'] = $this->account->pass_raw;
diff --git a/core/modules/rest/tests/src/Unit/EntityResourceValidationTraitTest.php b/core/modules/rest/tests/src/Unit/EntityResourceValidationTraitTest.php
index 8c9758e8a0..9af138c90a 100644
--- a/core/modules/rest/tests/src/Unit/EntityResourceValidationTraitTest.php
+++ b/core/modules/rest/tests/src/Unit/EntityResourceValidationTraitTest.php
@@ -58,6 +58,7 @@ public function testFailedValidate() {
       ->will($this->returnValue([]));
 
     $entity->validate()->willReturn($violations);
+    $entity->getEntityTypeId()->willReturn('entity_test');
 
     $trait = $this->getMockForTrait('Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait');
 
diff --git a/core/modules/serialization/serialization.services.yml b/core/modules/serialization/serialization.services.yml
index 3e47b005cb..3d74e19779 100644
--- a/core/modules/serialization/serialization.services.yml
+++ b/core/modules/serialization/serialization.services.yml
@@ -64,6 +64,10 @@ services:
     class: Drupal\serialization\Normalizer\TypedDataNormalizer
     tags:
       - { name: normalizer }
+  serializer.normalizer.entity_validation_exception:
+    class: \Drupal\serialization\Normalizer\EntityValidationExceptionNormalizer
+    tags:
+      - { name: normalizer }
   serializer.encoder.json:
     class: Drupal\serialization\Encoder\JsonEncoder
     tags:
diff --git a/core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php b/core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php
index a1e7bad2f8..5fc27fa823 100644
--- a/core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php
+++ b/core/modules/serialization/src/EventSubscriber/DefaultExceptionSubscriber.php
@@ -62,13 +62,12 @@ protected static function getPriority() {
    *   The event to process.
    */
   public function on4xx(GetResponseForExceptionEvent $event) {
-    /** @var \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $exception */
+    /** @var \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface|\Exception $exception */
     $exception = $event->getException();
     $request = $event->getRequest();
 
     $format = $request->getRequestFormat();
-    $content = ['message' => $event->getException()->getMessage()];
-    $encoded_content = $this->serializer->serialize($content, $format);
+    $encoded_content = $this->serializer->serialize($exception, $format);
     $headers = $exception->getHeaders();
 
     // Add the MIME type from the request to send back in the header.
diff --git a/core/modules/serialization/src/Exception/EntityValidationException.php b/core/modules/serialization/src/Exception/EntityValidationException.php
new file mode 100644
index 0000000000..caf5de74a8
--- /dev/null
+++ b/core/modules/serialization/src/Exception/EntityValidationException.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Drupal\serialization\Exception;
+
+use Drupal\Core\Entity\EntityConstraintViolationListInterface;
+use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
+
+/**
+ * An exception thrown when entity validation failed.
+ *
+ * This exception is normalized/encoded into a nice machine readable error
+ * message.
+ *
+ * @see \Drupal\serialization\Normalizer\EntityValidationExceptionNormalizer
+ */
+class EntityValidationException extends UnprocessableEntityHttpException {
+
+  /**
+   * @var \Drupal\Core\Entity\EntityConstraintViolationListInterface
+   */
+  protected $violations;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(EntityConstraintViolationListInterface $violations, $message = NULL, \Exception $previous = NULL, $code = 0) {
+    parent::__construct($message, $previous, $code);
+
+    $this->violations = $violations;
+  }
+
+  /**
+   * @return \Drupal\Core\Entity\EntityConstraintViolationListInterface
+   */
+  public function getViolations() {
+    return $this->violations;
+  }
+
+}
diff --git a/core/modules/serialization/src/Normalizer/EntityValidationExceptionNormalizer.php b/core/modules/serialization/src/Normalizer/EntityValidationExceptionNormalizer.php
new file mode 100644
index 0000000000..54b3b574e5
--- /dev/null
+++ b/core/modules/serialization/src/Normalizer/EntityValidationExceptionNormalizer.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\serialization\Normalizer;
+
+use Drupal\Component\Render\PlainTextOutput;
+use Drupal\serialization\Exception\EntityValidationException;
+use Symfony\Component\Validator\ConstraintViolation;
+
+/**
+ * Normalizes entity validation errors.
+ */
+class EntityValidationExceptionNormalizer extends NormalizerBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $supportedInterfaceOrClass = \Exception::class;
+  /**
+   * List of supported request formats.
+   *
+   * @var string[]
+   *
+   * @see checkFormat()
+   */
+  protected $format = ['hal_json', 'json'];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function normalize($object, $format = NULL, array $context = []) {
+    $result = [];
+
+    $result['message'] = $object->getMessage();
+
+    if ($object instanceof EntityValidationException) {
+      // Group violations by field path.
+      $result['errors'] = array_reduce(iterator_to_array($object->getViolations()), function (array $carry, ConstraintViolation $violation) {
+        $carry[$violation->getPropertyPath()][] = PlainTextOutput::renderFromHtml($violation->getMessage());
+        return $carry;
+      }, []);
+    }
+
+    return $result;
+  }
+
+}
diff --git a/core/modules/serialization/src/Normalizer/NormalizerBase.php b/core/modules/serialization/src/Normalizer/NormalizerBase.php
index 90d9b75258..41ad2e9268 100644
--- a/core/modules/serialization/src/Normalizer/NormalizerBase.php
+++ b/core/modules/serialization/src/Normalizer/NormalizerBase.php
@@ -13,7 +13,7 @@
   /**
    * The interface or class that this Normalizer supports.
    *
-   * @var string|array
+   * @var string|string[]
    */
   protected $supportedInterfaceOrClass;
 
@@ -21,39 +21,57 @@
    * {@inheritdoc}
    */
   public function supportsNormalization($data, $format = NULL) {
-    // If we aren't dealing with an object or the format is not supported return
-    // now.
-    if (!is_object($data) || !$this->checkFormat($format)) {
-      return FALSE;
-    }
-
-    $supported = (array) $this->supportedInterfaceOrClass;
-
-    return (bool) array_filter($supported, function($name) use ($data) {
-      return $data instanceof $name;
-    });
+    return is_object($data) && $this->checkSupport($data, $format);
   }
 
   /**
-   * Implements \Symfony\Component\Serializer\Normalizer\DenormalizerInterface::supportsDenormalization()
+   * Implements method of DenormalizerInterface.
    *
    * This class doesn't implement DenormalizerInterface, but most of its child
    * classes do, so this method is implemented at this level to reduce code
    * duplication.
+   *
+   * @see \Symfony\Component\Serializer\Normalizer\DenormalizerInterface::supportsDenormalization()
    */
   public function supportsDenormalization($data, $type, $format = NULL) {
-    // If the format is not supported return now.
+    return $this->checkSupport($type, $format);
+  }
+
+  /**
+   * Check whether item supports (de-)normalization.
+   *
+   * @param string|object $object
+   *   Fully-qualified object name or object itself.
+   * @param null|string $format
+   *   The format being (de-)serialized from or into.
+   *
+   * @return bool
+   *   Whether item supports (de-)normalization.
+   */
+  protected function checkSupport($object, $format = NULL) {
     if (!$this->checkFormat($format)) {
       return FALSE;
     }
 
     $supported = (array) $this->supportedInterfaceOrClass;
 
-    $subclass_check = function($name) use ($type) {
-      return (class_exists($name) || interface_exists($name)) && is_subclass_of($type, $name, TRUE);
-    };
+    if (is_object($object)) {
+      $object = get_class($object);
+    }
+
+    // Handle immediate match.
+    if (in_array($object, $supported)) {
+      return TRUE;
+    }
+
+    // Handle inheritance.
+    foreach ($supported as $type) {
+      if ((class_exists($type) || interface_exists($type)) && is_subclass_of($object, $type, TRUE)) {
+        return TRUE;
+      }
+    }
 
-    return in_array($type, $supported) || array_filter($supported, $subclass_check);
+    return FALSE;
   }
 
   /**
