Symfony and Command Bus

For more than a year now I have been using the Command Bus pattern in my Symfony projects and finally decided to share my experience. In the end, it’s a shame that in Laravel this is “out of the box”, but in Symfony, from which Laravel has grown in many respects, it doesn’t, although the very concept of Command / Query Separation is at least 10 years old. And if with the letter “Q” from the abbreviation “CQRS” it is still clear what to do (I personally am quite comfortable with custom repositories), then where to stick the letter “C” is unclear.

In fact, even in commonplace CRUD applications, Command Bus offers obvious advantages:

  • controllers become "thin" (a rare "action" takes more than 15 lines),
  • the business logic leaves the controllers and becomes as independent of the framework as possible (as a result, it is easy to reuse in other projects, even if they are not written in Symfony),
  • simplified unit testing of business logic,
  • code duplication is reduced (when, for example, it is necessary to implement a “feature” both through the Web UI and through the API).

KDPV

Scenery


Suppose we have an application in which you can register some projects. The project as an entity includes:

  • required name
  • optional description.

Code implemented from Symfony's native documentation might look something like this:

Entity
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints;
/**
 * @ORM\Table(name="projects")
 * @ORM\Entity
 * @Constraints\UniqueEntity(fields={"name"}, message="Проект с таким названием уже существует.")
 */
class Project
{
    const MAX_NAME        = 25;
    const MAX_DESCRIPTION = 100;
    /**
     * @var int ID.
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;
    /**
     * @var string Название проекта.
     *
     * @ORM\Column(name="name", type="string", length=25)
     */
    private $name;
    /**
     * @var string Описание проекта.
     *
     * @ORM\Column(name="description", type="string", length=100, nullable=true)
     */
    private $description;
}


The form
namespace AppBundle \ Form;

use AppBundle \ Entity \ Project;
use Symfony \ Component \ Form \ AbstractType;
use Symfony \ Component \ Form \ Extension \ Core \ Type \ TextType;
use Symfony \ Component \ Form \ FormBuilderInterface;
use Symfony \ Component \ Validator \ Constraints;

class ProjectForm extends AbstractType
{
    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('name', TextType::class, [
            'label'       => 'Название проекта',
            'required'    => true,
            'attr'        => ['maxlength' => Project::MAX_NAME],
            'constraints' => [
                new Constraints\NotBlank(),
                new Constraints\Length(['max' => Project::MAX_NAME]),
            ],
        ]);
        $builder->add('description', TextType::class, [
            'label'       => 'Описание проекта',
            'required'    => false,
            'attr'        => ['maxlength' => Project::MAX_DESCRIPTION],
            'constraints' => [
                new Constraints\Length(['max' => Project::MAX_DESCRIPTION]),
            ],
        ]);
    }
    /**
     * {@inheritdoc}
     */
    public function getBlockPrefix()
    {
        return 'project';
    }
}


Controller (project creation)
namespace AppBundle\Controller;
use AppBundle\Entity\Project;
use AppBundle\Form\ProjectForm;
use Sensio\Bundle\FrameworkExtraBundle\Configuration;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
class ProjectController extends Controller
{
    /**
     * Отображает страницу с формой, а также обрабатывает ее "сабмит".
     *
     * @Configuration\Route("/new")
     * @Configuration\Method({"GET", "POST"})
     */
    public function newAction(Request $request)
    {
        $project = new Project();
        $form = $this->createForm(ProjectForm::class, $project);
        $form->handleRequest($request);
        if ($form->isValid()) {
            $this->getDoctrine()->getManager()->persist($project);
            $this->getDoctrine()->getManager()->flush();
            return $this->redirectToRoute('projects');
        }
        return $this->render('project/form.html.twig', [
            'form' => $form->createView(),
        ]);
    }
}


I brought this controller more for comparison - this is how it looks from the point of view of the Symfony documentation. In fact, Web 2.0 won a long time ago, a teammate sculpts the front end of a project on Angular, and forms certainly arrive in AJAX requests.

Therefore, the controller looks different.
namespace AppBundle\Controller;
use AppBundle\Entity\Project;
use AppBundle\Form\ProjectForm;
use Sensio\Bundle\FrameworkExtraBundle\Configuration;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
class ProjectController extends Controller
{
    /**
     * Возвращает HTML-код формы.
     *
     * @Configuration\Route("/new", condition="request.isXmlHttpRequest()")
     * @Configuration\Method("GET")
     */
    public function showNewFormAction()
    {
        $form = $this->createForm(ProjectForm::class, null, [
            'action' => $this->generateUrl('new_project'),
        ]);
        return $this->render('project/form.html.twig', [
            'form' => $form->createView(),
        ]);
    }
    /**
     * Обрабатывает "сабмит" формы.
     *
     * @Configuration\Route("/new", name="new_project", condition="request.isXmlHttpRequest()")
     * @Configuration\Method("POST")
     */
    public function newAction(Request $request)
    {
        $project = new Project();
        $form = $this->createForm(ProjectForm::class, $project);
        $form->handleRequest($request);
        if ($form->isValid()) {
            $this->getDoctrine()->getManager()->persist($project);
            $this->getDoctrine()->getManager()->flush();
            return new JsonResponse();
        }
        else {
            $error = $form->getErrors(true)->current();
            return new JsonResponse($error->getMessage(), JsonResponse::HTTP_BAD_REQUEST);
        }
    }
}


"View" form
{{ form_start(form) }}
{{ form_row(form.name) }}
{{ form_row(form.description) }}
{{ form_end(form) }}

Simplebus


There are many Command Bus implementations for PHP, from thephpleague to explicit NIH bikes. Personally, I liked the version from Matthias Noback (he has a series of articles on Command Bus on his blog ) - SimpleBus . The library is independent of a specific framework and can be used in any PHP project. To facilitate the integration of the library with Symfony, there is a ready bundle from the same author, and we will put it:

composer require simple-bus/symfony-bridge

Any command is nothing more than an input data structure, the processing of which is in a separate handler. The bundle adds a new service command_busthat calls pre-registered handlers.

Let's try to “refactor” our “action” of creating a new project. An HTML form is not the only possible source of input data (a project can be created through an API, or with a corresponding message in an SOA system, or ... you never know how else), so I intentionally transfer data validation closer to the business logic itself (part of which validation and is), i.e. from the form to the command handler. In the general case, for any number of entry points, we go to the same handler, which performs validation. In the case of validation errors (and any other), we escalate the errors back as exceptions. As a result, any “action” is a short try-catch, in which we convert the data from the request to the command, call the handler, and then return “200 OK”; sectioncatchreturns a 4xx HTTP code with a specific error message. Let's see how it looks in business:

The form


Here we just throw out the validation, otherwise the form has not changed.

class ProjectForm extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('name', TextType::class, [
            'label'    => 'Project name',
            'required' => true,
            'attr'     => ['maxlength' => Project::MAX_NAME],
        ]);
        $builder->add('description', TextType::class, [
            'label'    => 'Project description',
            'required' => false,
            'attr'     => ['maxlength' => Project::MAX_DESCRIPTION],
        ]);
    }
    public function getBlockPrefix()
    {
        return 'project';
    }
}

Command


And here, on the contrary, validation appears.

namespace AppBundle\SimpleBus\Project;
use Symfony\Component\Validator\Constraints;
/**
 * Create new project.
 *
 * @property string $name        Project name.
 * @property string $description Description.
 */
class CreateProjectCommand
{
    /**
     * @Constraints\NotBlank()
     * @Constraints\Length(max = "25")
     */
    public $name;
    /**
     * @Constraints\Length(max = "100")
     */
    public $description;
}

Command handler


namespace AppBundle\SimpleBus\Project\Handler;
use AppBundle\Entity\Project;
use AppBundle\SimpleBus\Project\CreateProjectCommand;
use Symfony\Bridge\Doctrine\RegistryInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class CreateProjectCommandHandler
{
    protected $validator;
    protected $doctrine;
    /**
     * Dependency Injection constructor.
     *
     * @param ValidatorInterface $validator
     * @param RegistryInterface  $doctrine
     */
    public function __construct(ValidatorInterface $validator, RegistryInterface $doctrine)
    {
        $this->validator = $validator;
        $this->doctrine  = $doctrine;
    }
    /**
     * Creates new project.
     *
     * @param  CreateProjectCommand $command
     * @throws BadRequestHttpException
     */
    public function handle(CreateProjectCommand $command)
    {
        $violations = $this->validator->validate($command);
        if (count($violations) != 0) {
            $error = $violations->get(0)->getMessage();
            throw new BadRequestHttpException($error);
        }
        $entity = new Project();
        $entity
            ->setName($command->name)
            ->setDescription($command->description);
        $this->doctrine->getManager()->persist($entity);
        $this->doctrine->getManager()->flush();
    }
}

Team Registration


To command_busfind our handler, it must be registered as a service, marked with a special tag.

services:
    command.project.create:
        class: AppBundle\SimpleBus\Project\Handler\CreateProjectCommandHandler
        tags: [{ name: command_handler, handles: AppBundle\SimpleBus\Projects\CreateProjectCommand }]
        arguments: [ "@validator", "@doctrine" ]

Controller


The function has showNewFormActionnot changed in any way (let’s omit it for brevity), it has only changed newAction.

class ProjectController extends Controller
{
    public function newAction(Request $request)
    {
        try {
            // Наша форма имеет префикс "project". Иначе достаточно "$request->request->all()".
            $data = $request->request->get('project');
            $command = new CreateProjectCommand();
            $command->name        = $data['name'];
            $command->description = $data['description'];
            $this->container->get('command_bus')->handle($command);
            return new JsonResponse();
        }
        catch (\Exception $e) {
            return new JsonResponse($e->getMessage(), $e->getStatusCode());
        }
    }
}

If we count, we will see that the previous version of the "action" contained 12 lines of code, while the new one contains 11 lines. But firstly, we have just begun (it will be shorter and more elegant further), and secondly, we have a spherical example in a vacuum. In real life, the complication of business logic will “inflate” the controller in the first case, and absolutely will not affect it in the second.

There is another interesting nuance. Suppose a user has entered the name of an existing project. In the entity class, we have the corresponding annotation, but the form remains valid at the same time. Because of this, in the first case, it is often necessary to block the additional error handling.

In our command version, when calledpersist($entity)an exception will occur in the command handler - it will be created by ORM itself, adding to it the same message that we specified in the class annotation Project( "A project with the same name already exists" ). As a result, the "action" itself has not changed in any way - we just catch any exception, at whatever level it occurs, and turn it into "HTTP 400".

By the way, many copies on the subject of “exceptions against errors” were already broken on the hub (and not only). For example, in one of the last similar articles by AlexLeonovproposed something close to my approach (validation errors through exceptions), and judging by the comments on his article, I will also get it. This time, I urge you not to cheat, but to take for granted my weakness for the simplicity of the code, and forgive me if you can (there was a smiley face, but it got scared of the moderators and disappeared).

Team Auto Validation


If you look at the function handlein the command handler, you will notice that the validation and processing of its result:

  • is about half the function code,
  • will obviously be repeated from team to team,
  • can easily be forgotten in the next handler.

Fortunately, SimpleBus supports "middlewares" - intermediate functions that will be automatically called when any command is processed. There can be any number of middleware functions, you can force some of them to be called before commands, and others after, you can even assign them priorities if the sequence of execution of some middleware functions is important. Obviously, it makes sense to wrap the command validation in a middleware function and forget about it at all.

namespace AppBundle\SimpleBus\Middleware;
use Psr\Log\LoggerInterface;
use SimpleBus\Message\Bus\Middleware\MessageBusMiddleware;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class ValidationMiddleware implements MessageBusMiddleware
{
    protected $logger;
    protected $validator;
    /**
     * Dependency Injection constructor.
     *
     * @param LoggerInterface    $logger
     * @param ValidatorInterface $validator
     */
    public function __construct(LoggerInterface $logger, ValidatorInterface $validator)
    {
        $this->logger    = $logger;
        $this->validator = $validator;
    }
    /**
     * {@inheritdoc}
     */
    public function handle($message, callable $next)
    {
        $violations = $this->validator->validate($message);
        if (count($violations) != 0) {
            $error = $violations->get(0)->getMessage();
            $this->logger->error('Validation exception', [$error]);
            throw new BadRequestHttpException($error);
        }
        $next($message);
    }
}

Register our middleware:

services:
    middleware.validation:
        class: AppBundle\SimpleBus\Middleware\ValidationMiddleware
        public: false
        tags: [{ name: command_bus_middleware }]
        arguments: [ "@logger", "@validator" ]

We simplify the command handler (do not forget to remove the unnecessary dependency on the validator):

class CreateProjectCommandHandler
{
    protected $doctrine;
    /**
     * Dependency Injection constructor.
     *
     * @param RegistryInterface $doctrine
     */
    public function __construct(RegistryInterface $doctrine)
    {
        $this->doctrine = $doctrine;
    }
    /**
     * Creates new project.
     *
     * @param CreateProjectCommand $command
     */
    public function handle(CreateProjectCommand $command)
    {
        $entity = new Project();
        $entity
            ->setName($command->name)
            ->setDescription($command->description);
        $this->doctrine->getManager()->persist($entity);
        $this->doctrine->getManager()->flush();
    }
}

Multiple Validation Errors


Many of you have probably already wondered what to do if the result of validation is not one mistake, but a whole set. Indeed, it is not a good idea to return them to the user one at a time - I would like to mark all incorrect form fields at a time.

This is probably the only bottleneck in the approach. I did not come up with anything better than throwing a special exception with an array of errors. My inner perfectionist suffers a lot from this, but maybe he’s wrong, I will be glad for reassuring comments. Also welcome if someone offers a better solution.

In the meantime, our own exception to validation:

class ValidationException extends BadRequestHttpException
{
    protected $messages = [];
    /**
     * {@inheritdoc}
     */
    public function __construct(array $messages, $code = 0, \Exception $previous = null)
    {
        $this->messages = $messages;
        parent::__construct(count($messages) ? reset($this->messages) : '', $previous, $code);
    }
    /**
     * @return array
     */
    public function getMessages()
    {
        return $this->messages;
    }
}

Slightly fix our validating middleware:

class ValidationMiddleware implements MessageBusMiddleware
{
    public function handle($message, callable $next)
    {
        $violations = $this->validator->validate($message);
        if (count($violations) != 0) {
            $errors = [];
            foreach ($violations as $violation) {
                $errors[$violation->getPropertyPath()] = $violation->getMessage();
            }
            $this->logger->error('Validation exception', $errors);
            throw new ValidationException($errors);
        }
        $next($message);
    }
}

And of course, the controller itself (an additional section appeared catch):

class ProjectController extends Controller
{
    public function newAction(Request $request)
    {
        try {
            $data = $request->request->get('project');
            $command = new CreateProjectCommand();
            $command->name        = $data['name'];
            $command->description = $data['description'];
            $this->container->get('command_bus')->handle($command);
            return new JsonResponse();
        }
        catch (ValidationException $e) {
            return new JsonResponse($e->getMessages(), $e->getStatusCode());
        }
        catch (\Exception $e) {
            return new JsonResponse($e->getMessage(), $e->getStatusCode());
        }
    }
}

Now, in case of a validation error, the "action" will return a JSON structure, where the keys are the names of the HTML elements and the values ​​are the error messages for the corresponding fields. For example, if you do not specify the name of the project and at the same time enter a too long description:

{
    "name": "Значение не может быть пустым.",
    "description": "Значение не должно превышать 100 символов."
}

In fact, the keys will of course be the names of the properties in the class of the command, but it was not by chance that we named them identically to the form fields. However, the way the class properties are connected with the form fields can be completely arbitrary - it is up to you how you will attach the arrived messages to the front-end elements. For the “seed” here is an example of my error handler for such an AJAX request:

$.ajax({
    // ...
    error: function(xhr) {
        var response = xhr.responseJSON ? xhr.responseJSON : xhr.responseText;
        if (typeof response === 'object') {
            $.each(response, function(id, message) {
                var name = $('form').prop('name');
                var $control = $('#' + name + '_' + id);
                if ($control.length === 0) {
                    alert(message);
                }
                else {
                    $control.after('

' + message + '

'); } }); } else { alert(response); } }, beforeSend: function() { $('.form-error').remove(); } });

Team autocomplete


Each of our "action" begins with a request, from which we copy the data to the team each time, and then transfer it to processing. After the first five "actions" this copying starts to annoy and require automation. Let's write trait, which will add a constructor-initializer to our commands:

trait MessageTrait
{
    /**
     * Инициализирует объект значениями из указанного массива.
     *
     * @param array $values Массив с начальными значениями объекта.
     */
    public function __construct(array $values = [])
    {
        foreach ($values as $property => $value) {
            if (property_exists($this, $property)) {
                $this->$property = $value;
            }
        }
    }
}

Done. "Extra" values ​​will be ignored, missing - leave the corresponding properties of the object in a NULL state.

Now the "action" may look like this:

class ProjectController extends Controller
{
    public function newAction(Request $request)
    {
        try {
            $data = $request->request->get('project');
            $command = new CreateProjectCommand($data);
            $this->container->get('command_bus')->handle($command);
            return new JsonResponse();
        }
        catch (ValidationException $e) {
            return new JsonResponse($e->getMessages(), $e->getStatusCode());
        }
        catch (\Exception $e) {
            return new JsonResponse($e->getMessage(), $e->getStatusCode());
        }
    }
}

But what if we need to add some additional data besides the values ​​from the request object? For example, in editActionobviously there will be another parameter - the project ID. And it’s obvious that the corresponding command will have one more property:

/**
 * Update specified project.
 *
 * @property int    $id          Project ID.
 * @property string $name        New name.
 * @property string $description New description.
 */
class UpdateProjectCommand
{
    /**
     * @Constraints\NotBlank()
     */
    public $id;
    /**
     * @Constraints\NotBlank()
     * @Constraints\Length(max = "25")
     */
    public $name;
    /**
     * @Constraints\Length(max = "100")
     */
    public $description;
}

Let's add a second array with alternative values:

trait MessageTrait
{
    /**
     * Инициализирует объект значениями из указанного массива.
     *
     * @param array $values Массив с начальными значениями объекта.
     * @param array $extra  Массив с дополнительными значениями объекта.
     *                      В случае конфликта ключей этот массив переписывает значение из предыдущего.
     */
    public function __construct(array $values = [], array $extra = [])
    {
        $data = $extra + $values;
        foreach ($data as $property => $value) {
            if (property_exists($this, $property)) {
                $this->$property = $value;
            }
        }
    }
}

Now our hypothetical editActioncould look like this:

class ProjectController extends Controller
{
    /**
     * Возвращает HTML-код формы.
     *
     * @Configuration\Route("/edit/{id}", requirements={"id"="\d+"}, condition="request.isXmlHttpRequest()")
     * @Configuration\Method("GET")
     */
    public function showEditFormAction($id)
    {
        $project = $this->getDoctrine()->getRepository(Project::class)->find($id);
        if (!$project) {
            throw $this->createNotFoundException();
        }
        $form = $this->createForm(ProjectForm::class, $project, [
            'action' => $this->generateUrl('edit_project'),
        ]);
        return $this->render('project/form.html.twig', [
            'form' => $form->createView(),
        ]);
    }
    /**
     * Обрабатывает "сабмит" формы.
     *
     * @Configuration\Route("/edit/{id}", name="edit_project", requirements={"id"="\d+"}, condition="request.isXmlHttpRequest()")
     * @Configuration\Method("POST")
     */
    public function editAction(Request $request, $id)
    {
        try {
            $project = $this->getDoctrine()->getRepository(Project::class)->find($id);
            if (!$project) {
                throw $this->createNotFoundException();
            }
            $data = $request->request->get('project');
            $command = new UpdateProjectCommand($data, ['id' => $id]);
            $this->container->get('command_bus')->handle($command);
            return new JsonResponse();
        }
        catch (ValidationException $e) {
            return new JsonResponse($e->getMessages(), $e->getStatusCode());
        }
        catch (\Exception $e) {
            return new JsonResponse($e->getMessage(), $e->getStatusCode());
        }
    }
}

Almost everything is fine, but there is a nuance - if the user leaves the project description empty, an empty line will fly to us, which will eventually be saved to the database, although in such cases I would like to write to the database NULL. Let's expand our trait a little more:

trait MessageTrait
{
    public function __construct(array $values = [], array $extra = [])
    {
        $empty2null = function ($value) use (&$empty2null) {
            if (is_array($value)) {
                foreach ($value as &$v) {
                    $v = $empty2null($v);
                }
                return $value;
            }
            return is_string($value) && strlen($value) === 0 ? null : $value;
        };
        $data = $empty2null($extra + $values);
        foreach ($data as $property => $value) {
            if (property_exists($this, $property)) {
                $this->$property = $value;
            }
        }
    }
}

Here we just added an anonymous function (so as not to produce entities) that recursively (an array can be nested) iterates over the original values ​​and changes the empty lines to NULL.

Events


In addition to SimpleBus commands , events are also capable. Strictly speaking, the difference between them is small. They are implemented identically - you also create an event class, but it can have many handlers (or rather, subscribers) (or none at all). Subscribers are registered similarly to handlers (only with a slightly different tag), and they are controlled by another special service implemented in SimpleBus - event_bus.

Since both simple_bus, and, event_busare ordinary Symfony services, you can embed them as dependencies anywhere, including in your handlers. For example, for a project creation team to send an event that a new project has been created.

Instead of a conclusion


Besides the fact that we got “skinny” controllers, we also simplified the unit testing. Indeed, it is much easier to test a single handler class, and you can "lock up" its dependencies, or you can implement real ones if your unit test inherits from Symfony\Bundle\FrameworkBundle\Test\WebTestCase. In this case, in any case, we no longer need to use the Symfony crawler (which, by the way, noticeably slows down the tests) to trigger one or another “action”. Honestly, now I sometimes don’t cover “actions” with tests at all, unless I check their availability, as the Symfony documentation recommends.

Another indisputable advantage is that we essentially tear off our business logic from the framework (as much as possible, of course). The necessary dependencies are embedded in the handlers, and from which framework they come from is no longer important. Once FIG will complete the standardization of all key interfaces, we can take our handlers and simply transfer them from under the hood of one framework under the hood of another. Even the fragmentation of business logic by processors will be a plus if one day or your project is bitten by SOA.

By the way, if you (like me) have never written in Java, and a large number of short classes are not associated with the word “harmony” for you, then you do not even have to keep each handler in a separate class (although I personally like it). Simplebus allows you to combine handlers in one class, so you may well have a class of handlers for each entity, whose functions will be handlers of specific operations.

Also popular now: