Skip to content

Where and how to handle command (superficial) and domain validation? #44

@webdevilopers

Description

@webdevilopers

Originally posted by @gquemener :

Let's define domain related validation once and for all within well-defined VO and expose those errors to the world. > https://gist.github.com/gquemener/09b2bc303e63dfc3123f7d540e9891a0

It prevents to add extra constraint metadata!
Is this too much magic?

@matthiasnoback and @AntonStoeckl joined the discussion.

@AntonStoeckl also blogged about a solution in GO:

On our case:

We decided to work w/ the #symfonymessenger usind the mentioned validation middleware based on symfony constraints.
VOs are created from command getters. Every domain exception is caught by an listener and converted into human-readable messages.

Example:

# config/packages/messenger.yaml
framework:
  messenger:
    default_bus: messenger.bus.commands
    buses:
      messenger.bus.commands:
        middleware:
          - validation
# config/services.yaml
services:
    Acme\Common\Infrastructure\Symfony\EventListener\ExceptionListener:
        tags:
            - { name: kernel.event_listener, event: kernel.exception }
        arguments: ['@translator.default']
<?php

namespace Acme\Common\Infrastructure\Symfony\EventListener;

use DomainException;
use Prooph\EventStore\Exception\ConcurrencyException;
use Acme\Common\Presentation\Model\NotFoundException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\Messenger\Exception\HandlerFailedException;
use Symfony\Component\Messenger\Exception\RuntimeException;
use Symfony\Component\Messenger\Exception\ValidationFailedException;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
 * Handles bad requests and returns a 400 status with human-readable errors to the client.
 * Returns a 500 status otherwise.
 */
final class ExceptionListener
{
    /** @var TranslatorInterface */
    private $translator;

    private $translationDomain = 'exception';

    private $locale = 'de';

    public function __construct(TranslatorInterface $translator)
    {
        $this->translator = $translator;
    }

    public function onKernelException(ExceptionEvent $event)
    {
        // You get the exception object from the received event
        $exception = $event->getThrowable();

        if (!$exception instanceof RuntimeException) {
            return;
        }

        $response = new JsonResponse();
        $response->setStatusCode(Response::HTTP_BAD_REQUEST);

        if ($exception instanceof ValidationFailedException) {
            // Handle domain specific exceptions - public to the client
            $errors = [];

            /** @var ConstraintViolation $violation */
            foreach ($exception->getViolations() as $violation) {
                $errors[] = [
                    'message' => $violation->getMessage(),
                    'path' => $violation->getPropertyPath()
                ];
            }

            $response->setContent(json_encode(['errors' => $errors]));
            $event->setResponse($response);

            return;
        }

        if ($exception instanceof HandlerFailedException) {
            // Handle presentation and domain specific exceptions - public to the client
            $errors = [];

            if ($exception->getPrevious() instanceof NotFoundException) {
                $errors = [
                    'message' => $exception->getMessage(),
                    'path' => null
                ];

                $response->setContent(json_encode(['errors' => $errors]));
                $response->setStatusCode(Response::HTTP_NOT_FOUND);

                $event->setResponse($response);

                return;
            }

            if ($exception->getPrevious() instanceof DomainException) {
                $errors[] = [
                    'message' => $this->translator->trans(
                        $exception->getPrevious()->getMessage(), [], $this->translationDomain, $this->locale
                    ),
                    'path' => null
                ];

                $response->setContent(json_encode(['errors' => $errors]));
                $event->setResponse($response);

                return;
            }

            // Handle individual server errors if relevant to the client
            switch (get_class($exception->getPrevious())) {
                case ConcurrencyException::class:
                    $errors = [
                        'message' => 'Duplicate entry',
                        'path' => null
                    ];

                    $response->setContent(json_encode(['errors' => $errors]));
                    $event->setResponse($response);

                    return;

                    break;
            }
        }
    }
}

The listener can be expanded for individual exceptions. Exception messages are simply translated.
If it is a Symfony Constraint error message it normally was already translated. In addtion the path will be added.

The JSON result normally looks like this:

errors: {
    message: "Some error in the command DTO"
    path: "firstName"
}
errors: {
    message: "Some error in the domain e.g. thrown inside value object"
    path: null
}

AFAIK Symfony Messenger can be used without Symfony. You can use it as a standalone service bus.
The Validation middleware is included too. Putting the "logic" of the listener elsewhere should be no problem.

WDYT?

Older possibly related issues:

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions