Symfony и Command Bus

Уже больше года использую паттерн Command Bus в своих Symfony-проектах и наконец решил поделиться опытом. В концев концов обидно, что в Laravel это есть «из коробки», а в Symfony, из которого Laravel во многом вырос — нет, хотя самому понятию Command/Query Separation уже не менее 10 лет. И если с буквой «Q» из аббревиатуры «CQRS» еще понятно что делать (лично меня вполне устраивают custom repositories), то куда приткнуть букву «C» — неясно.

На самом деле, даже в банальных CRUD-приложениях Command Bus дает очевидные преимущества:

  • контроллеры становятся «худыми» (редкий «экшен» занимает более 15 строк),
  • бизнес-логика покидает контроллеры и становится максимально независимой от фреймворка (в результате ее несложно повторно использовать в других проектах, даже если они написаны не на Symfony),
  • упрощается unit-тестирование бизнес-логики,
  • сокращается дублирование кода (когда, например, необходимо реализовать «фичу» как через Web UI, так и через API).

КДПВ

Декорации


Предположим, у нас приложение, в котором можно регистрировать некие проекты. Проект как сущность включает в себя:

  • обязательное название,
  • необязательное описание.

Код, реализованный по родной документации Symfony, мог бы выглядеть как-то так:

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;
}


Форма
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';
    }
}


Контроллер (создание проекта)
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(),
        ]);
    }
}


Я привел этот контроллер больше для сравнения — именно так он выглядит с точки зрения Symfony-документации. На самом же деле «веб 2.0» давно победил, сосед по команде ваяет «фронтэнд» проекта на Angular, а формы конечно же прилетают в AJAX-запросах.

Поэтому контроллер выглядит иначе.
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);
        }
    }
}


«Вью» формы
{{ form_start(form) }}
{{ form_row(form.name) }}
{{ form_row(form.description) }}
{{ form_end(form) }}

SimpleBus


Существует множество реализаций Command Bus для PHP — от «thephpleague» до откровенных NIH-велосипедов. Лично мне понравилась версия от Matthias Noback (у него в блоге есть серия статей, посвященных Command Bus) — SimpleBus. Библиотека не зависит от конкретного фреймворка и ее можно использовать в любом PHP-проекте. Для облегчения интеграции библиотеки с Symfony есть готовый bundle от того же автора, его и поставим:

composer require simple-bus/symfony-bridge

Любая команда — не более, чем структура входных данных, обработка которых находится в отдельном обработчике. Бандл добавляет новый сервис command_bus, который и вызывает предварительно зарегистрированные обработчики.

Попробуем «отрефакторить» наш «экшен» создания нового проекта. HTML-форма — не единственный возможный источник входных данных (проект можно создать через API, или соответствующим сообщением в SOA-системе, или… да мало ли как еще), поэтому я намеренно переношу валидацию данных поближе к самой бизнес-логике (частью которой валидация и является), т.е. из формы в обработчик команды. В общем случае при любом количестве точек входа мы идем в один и тот же обработчик, который и выполняет валидацию. В случае ошибок валидации (да и любых других) мы эскалируем ошибки обратно в виде исключений. В итоге любой «экшен» — это короткий try-catch, в котором мы преобразуем данные из запроса в команду, вызываем обработчик, а затем возвращаем «200 OK»; секция catch возвращает HTTP-код «4xx» с конкретным сообщением об ошибке. Посмотрим, как это выглядит в деле:

Форма


Тут мы просто выбрасываем валидацию, в остальном форма никак не изменилась.

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';
    }
}

Команда


А вот тут валидация наоборот появляется.

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;
}

Обработчик команды


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();
    }
}

Регистрация команды


Чтобы command_bus нашел наш обработчик, его надо зарегистрировать как сервис, пометив специальным тэгом.

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

Контроллер


Функция showNewFormAction никак не изменилась (для краткости опустим ее), поменялся лишь 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());
        }
    }
}

Если посчитать, то мы увидим, что прежняя версия «экшена» содержала 12 строк кода, в то время как новая содержит 11 строк. Но во-первых, мы только начали (дальше будет короче и изящнее), а во-вторых, у нас сферический пример в вакууме. В реальной жизни усложнение бизнес-логики будет «раздувать» контроллер в первом случае, и совершенно никак его не затронет во втором.

Есть еще один интересный ньюанс. Допустим, пользователь ввел название уже существующего проекта. В entity-классе у нас есть соответствующая аннотация, но форма-то при этом остается корректной. Из-за этого в первом случае нередко приходится городить дополнительную обработку ошибок.

В нашей же command-версии при вызове persist($entity) в обработчике команды возникнет exception — его создаст сама ORM, добавив в него то самое сообщение, которое мы указали в аннотации класса Project («Проект с таким названием уже существует»). В результате сам «экшен» никак не изменился — мы просто ловим любое исключение, на каком бы уровне оно не произошло, и превращаем его в «HTTP 400».

Кстати, на хабре (и не только) уже было сломано немало копий на тему «исключения против ошибок». Например, в одной из последних подобных статей AlexLeonov предложил нечто близкое к моему подходу (ошибки валидации через исключения), и, судя по комментариям к его статье, мне тоже достанется. Я призываю на этот раз не холиварить, а принять как данность мою слабость к простоте кода, и простить мне ее, если сможете (тут был смайлик, но он испугался модераторов и исчез).

Автовалидация команд


Если присмотреться к функции handle в обработчике команды, можно заметить, что валидация и обработка ее результата:

  • составляет примерно половину кода функции,
  • явно будет повторяться из команды в команду,
  • легко может быть забыта в очередном обработчике.

К счастью, SimpleBus поддерживает «middlewares» — промежуточные функции, которые будут автоматически вызываться при обработке любой команды. Middleware-функций может быть сколько угодно, вы можете заставить одни из них вызываться до команд, а другие — после, вы даже можете назначать им приоритеты, если последовательность выполнения каких-то middleware-функций важна. Очевидно, имеет смысл обернуть валидацию команд в middleware-функцию и забыть о ней вовсе.

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);
    }
}

Регистрируем наш middleware:

services:
    middleware.validation:
        class: AppBundle\SimpleBus\Middleware\ValidationMiddleware
        public: false
        tags: [{ name: command_bus_middleware }]
        arguments: [ "@logger", "@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();
    }
}

Множественные ошибки валидации


Многие из вас уже наверное задались вопросом, как же быть, если результатом валидации является не одна ошибка, а целый набор. Действительно, не самая удачная идея возвращать их пользователю по одной — хотелось бы отметить все некорректные поля формы за один раз.

Это, наверное, единственное «узкое» место подхода. Я не придумал ничего лучше, кроме как кидать специальное исключение с массивом ошибок. Мой внутренний перфекционист очень страдает от этого, но возможно он не прав, буду рад успокоительным комментариям. Также приветствуется, если кто-то предложит более удачное решение.

А пока — наше собственное исключение валидации:

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;
    }
}

Слегка поправим наш валидирующий 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);
    }
}

Ну и конечно же сам контроллер (появилась дополнительная секция 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());
        }
    }
}

Теперь в случае ошибки валидации «экшен» вернет JSON-структуру, где ключами будут имена HTML-элементов, а значениями — сообщения об ошибке для соответствующих полей. Например, если не указать название проекта и одновременно ввести слишком длинное описание:

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

На самом деле ключами конечно будут имена свойств в классе команды, но мы же не случайно назвали их идентично полям формы. Впрочем, способ связи свойств класса с полями формы может быть абсолютно произвольной — это вам решать, как вы будете привязывать прилетевшие сообщения к элементам «фронтэнда». Для «затравки» вот вам пример моего error-обработчика подобного AJAX-запроса:

$.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('<p class="form-error">' + message + '</p>');
                }
            });
        }
        else {
            alert(response);
        }
    },
    beforeSend: function() {
        $('.form-error').remove();
    }
});

Автозаполнение команды


Каждый наш «экшен» начинается с запроса, из которого мы каждый раз копируем данные в команду, чтобы затем передать ее на обработку. После первых пяти «экшенов» это копирование начинает раздражать и требовать автоматизации. Напишем trait, который будет добавлять в наши команды конструктор-инициализатор:

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

Готово. «Лишние» значения будут игнорироваться, недостающие — оставлять соответствующие свойства объекта в NULL-состоянии.

Теперь «экшен» может выглядеть так:

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());
        }
    }
}

А что, если нам понадобится добавить какие-то дополнительные данные помимо значений из объекта запроса? Например, в editAction очевидно будет еще один параметр — ID проекта. И очевидно, что соответствующая команда будет на одно свойство больше:

/**
 * 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;
}

Давайте добавим второй массив с альтернативными значениями:

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;
            }
        }
    }
}

Теперь наш гипотетический editAction мог бы выглядеть следующим образом:

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());
        }
    }
}

Почти все хорошо, но есть ньюанс — если пользователь оставит описание проекта пустым, к нам прилетит пустая строка, которая в итоге и будет сохранена в базу, хотя в подобных случаях хотелось бы писать в базу NULL. Расширим наш trait еще немного:

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;
            }
        }
    }
}

Здесь мы просто добавили анонимную функцию (чтобы не плодить сущностей), которая рекурсивно (массив может быть вложенным) проходит по исходным значениям и меняет пустые строки на NULL.

События


Помимо команд SimpleBus умеет также и события. Строго говоря, разница между ними невелика. Реализованы они идентично — вы точно также создаете класс-событие, но обработчиков (точнее — подписчиков) у него может быть много (или вообще ни одного). Регистрируются подписчики аналогично обработчикам (лишь с немного другим тэгом), а управляет ими другой специальный сервис, реализованный в SimpleBusevent_bus.

Поскольку и simple_bus, и event_bus — обычные Symfony-сервисы, вы можете внедрять их в качестве зависимостей куда угодно, в том числе и в ваши обработчики. Например, чтобы команда создания проекта послала событие о том, что был создан новый проект.

Вместо заключения


Помимо того, что мы получили «тощие» контроллеры, мы также упростили себе unit-тестирование. Действительно, гораздо проще оттестировать отдельно взятый класс-обработчик, причем можно «замокать» его зависимости, а можно внедрить настоящие, если ваш unit-тест наследует от Symfony\Bundle\FrameworkBundle\Test\WebTestCase. При этом в любом случае нам больше не нужно использовать Symfony crawler (который, к слову, заметно замедляет тесты), чтобы вызвать тот или иной «экшен». Честно говоря, теперь я порой вообще не покрываю «экшены» тестами, разве что проверяю их на доступность, как рекомендует документация Symfony.

Еще одним неоспоримым преимуществом является то, что мы по сути оторвали нашу бизнес-логику от фреймворка (насколько это возможно, конечно). Необходимые зависимости внедряются в обработчики, а из какого фреймворка они приходят — уже не важно. Однажды FIG закончит стандартизацию всех ключевых интерфейсов, и мы сможем брать наши обработчики и просто переносить их из-под капота одного фреймворка под капот другого. Даже раздробленность бизнес-логики по обработчикам окажется плюсом, если однажды вас или ваш проект укусит SOA.

Кстати, если вы (как и я) никогда не писали на Java, и большое количество коротких классов не ассоциируется для вас со словом «гармония», то вы даже не обязаны держать каждый обработчик в отдельном классе (хотя лично мне нравится). SimpleBus позволяет объединять обработчики в один класс, так что вы вполне можете иметь по классу обработчиков на каждый entity, функции которых будут обработчиками конкретных операций.
Share post

Comments 121

    +1
    Правильно ли я понимаю что это сильно похоже на паттерн Command?
    https://ru.wikipedia.org/wiki/%D0%9A%D0%BE%D0%BC%D0%B0%D0%BD%D0%B4%D0%B0_(%D1%88%D0%B0%D0%B1%D0%BB%D0%BE%D0%BD_%D0%BF%D1%80%D0%BE%D0%B5%D0%BA%D1%82%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D1%8F)
      0
      Да, так и есть. Мне "Command Bus" всегда казался просто одним из вариантов реализации этого паттерна. Если я прав, то "Command Bus" по сути — частный случай "Command".
        0
        Команда вообще очень крутой паттерн. Если кроме handle добавить к примеру prepare, validate, undo, то можно делать откаты, макросы. Если реализовать CompositeCommand то можно батчить выполнение и откат команд. В одном проекте в методе prepare у меня было получение эксклюзивных локов в базе, чтобы избежать дедлоков во время handle. Ошибки валидации можно не исключением кидать, а к примеру возвращать false из handle, а методом getErrors() получать массив ошибок. Тоже вроде как удобно получается.
          0
          command bus — скорее архитектурный сахарок над command. Всегда приятно просто кинуть сообщение в шину, не заботясь о том, что там происходит. Ну и плюс декорирует работу с хэндлерами команд, позволяя в рантайме/конфиге добавлять еще обработчиков или события.
      • UFO just landed and posted this here
          +2
          Это этим комментарием происходит валидация?

          Да. Но это лишь один из доступных способов; можно задать те же constraints и "классически" — через PHP-код (в первой версии формы под спойлером так и было сделано, кстати).
          Фреймворк заново считывает файл и парсит его?

          Аннотации кэшатся. Про opcode вообще молчу.
          А если на продакшене удаляются комментарии? То все слетит? :)

          Да, так и есть. А зачем удалять комментарии на продакшене? Это такой наивный способ увеличить производительность? :)
          В Yii метод более логично называется actionNew. Да и вообще, функции по нормальному должны начинаться с глагола.

          Ну, суффикс "Action" — это скорее дань традициям, заложенным еще в Symfony 1. Сейчас "экшены" можно называть вообще как угодно.
          (На всякий случай: "action" — существительное.)
          • UFO just landed and posted this here
              0
              js/css грузятся с сервера в браузер клиента. Минифицируются они чтобы загрузиться быстрее. Комбинируются в один файл — чтобы не занимать соединение (кол-во одновременных загрузок ограничено).
              Минификация PHP, который исполняется на сервере?
              • UFO just landed and posted this here
                  +2
                  AnnotationParser выбросит исключение, если на сервере стоит что-нибудь, что удаляет комментарии.
                  А в целом, удалять комментарии на продакшене, как и минифицировать серверный код, как минимум бессмысленно
                  UPD. Возможно Вы имеете ввиду штуки, типа ionCube и прочего? Когда код "шифруют", дабы в нем стало невозможно поковыряться? С таким кейсом аннотации всё равно будут работать
            +2
            что во фреймворках все чрезмерно сложно. :)

            То есть выходит любой проект — сложно, поскольку как бы вы не крутились, вы просто напишите свой фреймворк. Это в конце концов инфраструктура вокруг приложения и не более.
            Это этим комментарием происходит валидация?

            Это типа декларативный конфиг. Аннотации — это для удобства, вы можете впихнуть это дело в YML/XML/PHP или вообще свой загрузчик конфигов написать. Это как кому больше нравится.
            Фреймворк заново считывает файл и парсит его?

            PHP каждый раз парсит PHP файлы? Ответ — смотря как настроен opcache. Тут так же — если кэш включен — то не будет.
            А если на продакшене удаляются комментарии? То все слетит? :)

            Крайне маловероятный случай, но да, в opcache например есть настроечка "не хранить комменты при парсинге", которая все сломает. Но в таком случае можно просто в yml перекинуть конфиги. Или в XML.
            В Yii метод более логично называется actionNew.

            Это субъективизм. Лично я называю их createPost или registerUser.
            • UFO just landed and posted this here
                0
                Фреймворки уровня symfony обязаны покрывать 90% кейсов, случающихся в разработке и не должны препятствовать расширению для покрытия оставшихся 10%. А это вынуждает делать "чуть сложнее", В принципе для среднего программиста это все не должно быть сложно.
                • UFO just landed and posted this here
                    +2
                    По факту фреймворк решает непойми какие задачи, никому в большинстве случаев ненужные.

                    Знаете, я вот проходил этап "писать свой фреймворк", "писать на плохом фреймворке", "писать на хорошем фреймворке" (и не только в php), "писать на своем фреймворке из компонентов других", и могу вам сказать что я слишком ленив для того что бы делать очередной фреймворк. Если вам симфони жмет — ок, composer же, ставим отдельно какой роутер, php-di, доктрину, еще парочку вещей, мидлвэры под PSR-7 и радуемся. Все под нас и только то что нужно.
                    Мне кажется, что это лишь неумение делать просто. :)

                    Опишите пожалуйста как сделать "просто" более-менее универсальный компонент для работы с формами. Ну вот на вскидку. Или как сделать "более-менее просто" security-layer. Или как сделать так, что бы не нужно было проектировать БД а сначала проектировать бизнес-объекты и таким образом очень быстро накидывать прототипы бизнес-логики.
                    А чтобы нормально понять, нужно времени много убить.

                    Капитан очевидность. Мое мнение — разработчиков нельзя подпускать к коммерческим проектам без года "теории и практики для себя". причем не тупо бложики пописать, а именно полноценное обучение. Двух курсов должно хватать для основ. "уровень входа" — это отговорка. Проблема PHP — бывшие школьники которые не освоили даже базовые понятия CS.
                    • UFO just landed and posted this here
                        0
                        Я не считаю, что пишу свой фреймворк. Так, ядро общего функционала для работы сайтов.

                        Ядро… давайте так, вы пишите инфраструктуру. А что есть фреймворк как не инфраструктура приложения?
                        Вообще не делать. Это выходит объять необъятное. Эти компоненты потом криво кастомизируются.

                        Ну а вот представьте что таки сделали и он даже покрывает необъятное и вполне себе удобно кастомизируется. С токчи зрения пользователя фреймворка это дает нехилий такой буст в упрощении работы с формами и увеличении скорости разработки.
                        Это что такое? Защита от CSRF, XSS?

                        Нет, аунтентификация и авторизация. Сможете к своему чуду за пару часов логин через фэйсбук какой прикрутить? Или там быстро JWT прикрутить для апишки.
                        Зачем использовать сложный продукт, если есть простой?

                        Потому что "сложный продукт" покрывает намного больше нежели простой. Есть ситуации когда лучше взять что-то простое и маленькое, но никак не писать свое. Это в большинстве случаев пустая трата времени разработчиков и ущерб для бизнеса. Исключения есть но их крайне мало.

                        Современный цикл разработки продукта это все же — MVP на базе фреймворка с последующей заменой универсальных компонентов на более специфичные если это требуется (например это узкие места приложения). Типа взять для ускорения разработки Doctrine2 а потом в "узких местах" планомерно заменять на какой Spot2 или вообще PDO, когда проект уже выстрелил и нам это дает профит.
                        Лень искать кто создатели фреймворков, сколько им лет и год выхода.

                        Если вы про Symfony — вот эти ребята: https://sensiolabs.com/ Причем большую часть симфони перенял у Django и Spring-а.
                        • UFO just landed and posted this here
                            0
                            Разработчики Symfony: https://github.com/orgs/symfony/people
                            Главный у них: Fabien Potencier
                            На тему скорости разработки:
                            • Когда я начинал изучать Symfony я написал сайт с нуля за одну неделю
                            • Недавно писал CRM с нуля. Через неделю у меня был уже рабочий прототип, а еще через неделю я сдал проект

                            Ваша система может похвастаться такими скоростями?
                            • UFO just landed and posted this here
                                0
                                Там нету их возраста :)

                                У этих людей есть профили в linkedin. А ваш возраст позвольте узнать.
                                • UFO just landed and posted this here
                                    +1
                                    В целом по комментариям чувствуется, что вы этакий бунтарь-одиночка. В этом есть свои преимущества, но все-таки вопросы:
                                    1) Как насчет командной работы? Предположим, ваш сайт выстрелил, сможете организовать команду, которая эффективно сможет работать над вашим проектом, без необходимости месяцок вникать в ваш самописный движок (на что не каждый вообще согласится)?
                                    2) Безопасность: что лучше, самопис, который видела только одна пара глаз или фреймворк, который шерстят сотни тысяч человек (с учетом того, что большинство не заглядывает в его код)?
                                  +1
                                  Я на вашем месте не гордился бы этим
                                  • UFO just landed and posted this here
                                      0
                                      Такой проект спокойно делается на чем угодно за два человеко-дня. Тут как бы, особо не померять эффективность.
                                      Скажем если мы берем такой проект на Symfony:
                                      • инит проекта (git и т.д.), прочитка требований — часа 2. Раз мы не дизайнеры возьмем бутстрапчик.
                                      • логин форма, регистрация, восстановление пароля — берем готовое.
                                      • каталог, поиск по каталогу — часа 4-6 на какой-нибудь монге например для удобства.
                                      • админка, управление каталогом — еще часов 8 на всяких ангулярах, с прогоном картинок через оптимизаторы (что бы контент менедер не накосячил)
                                      • статические странички по вкусу, если без админки то за час закончим. Если с админкой в простом вариенте — еще часа 4.

                                      И вуаля. Захочет чувак потом прикрутить к каталогу товаров какую-то форму для связи — быстро и удобно для пользователя запилим. Захочет чатик между потенциальным клиентом и ладельцем объявления — без проблем. Ну и т.д.
                                      • UFO just landed and posted this here
                                          0
                                          Ну… я вот как-то ковыряюсь в "болоте" уже лет 6, и когда попадаются самописы плачу.
                                0
                                Как правило фреймворк вызывает пользовательские методы, у меня я сам вызываю методы ядра явно без ненужной магии.

                                Не верно. Для начала задумайтесь о словообразовании слово framework, посмотрите в толковом славарике если хотите.
                                Для генерации HTML вредно использовать PHP

                                Он не генерирует HTML, он мэпит данные между приложением и формами, производит трансформацию значений и т.д.
                                Я прикручивал за вечер логин через вконтакте. Зато я сам знаю, что я запрашиваю у пользователя, что и куда сохраняю.

                                А с фреймворками не знаете? Список "разрешений" вы как бы сами задаете.
                                Я ближе к UNIX way, не нужно сложных продуктов :)

                                Симфони состоит из кучи более простых компонентов. А теперь посмотрите на system.d, lxc, cgroups и т.д. Простые проекты которые большие и сложные внутри.
                                У меня процес создания нового сайта — это скопировать папку /system/ и вперед.

                                Что происходит с сайтом когда вы уже им не занимаетесь? Например заказчик решил сменить исполнителя или взять себе свою команду. Подумайте о других.
                                В большинстве случаев разработка на фреймворках такая долгая и сложная, что проще было реализовать только то, что нужно самому.

                                Почитайте про технический долг и прикиньте свою скорость разработки если вы будете постепенно из проекта типа "обычный интернет магазин" к проекту "сеть интернет магазинов со своими центрами логистическими". И потом будете грустить.
                                Ничто не мешает иметь свои классы для работы с БД.

                                Слыша эти слова уже понимаешь уровень человека их произнесшего. Вы не программист. Вы web-мастер клепающий сайтики.
                                Это ru.wikipedia.org/wiki/Model-View-Presenter Понапридумывают аббревиатур.

                                Minimum Valuable Product. Одна из базовых концепций для любого предпринимателя.
                                • UFO just landed and posted this here
                                    0
                                    Тро-ло-ло. А Доктрина, АктивРекод это что такое?

                                    Active Record — мегапримитивный паттерн, грубо говоря объеденение Domain Object и Row Data Gateway.
                                    Doctrine — это data mapper + unit of work + repository + entity manager. Это очень большой и жирный компонент, который предоставляет нам практически полную абстракцию от хранилища, позволяя нам пользоваться принципом persistence ignorance не тратя ни единой минуты на имплементацию. OO-first и все такое, мы как-будто бы работаем с сущностями так, будто бы они в памяти валяются и нет никакой базы данных.
                                    Так на фреймворке выходит как правило дороже. :)

                                    Подкрепите свои слова какой-либо статистикой. Если мы говорим о рейтах разработчиков — то да, дороже. Как и любой толковый специалист — будет дороже но зато рисков меньше. А если же мы говорим именно о стоимости разработки — то нет. На проектах сложнее лэндинга или блога уже получаем нехилый профит.
                            0

                            Найдутся такие, кто скажет, что за всей этой кучей зависимостей (если собирать «фреймворк» из отдельных пакетов) еще следить надо, не дай бог что-то обновится и всё поломает.

                  0
                  CommandHandler-ы в вашем случае ничего не возвращают. Типичный REST при создании чего-то должен вернуть идентификатор созданного ресурса. Как поступаете? И еще непонтяно, если у Вас фронтенд на ангуларах, зачем юзаете симфони формы?
                    –1
                    Типичный REST при создании чего-то должен вернуть идентификатор созданного ресурса.

                    Вы сейчас мыслите в категории "а как же автоинкременты мои любимые". Используйте UUID и тогда все ок.

                    Вообще это самая здравая идея из всех. Все действия мутирующие состояние — void. Это сильно упрощает дальнейшую поддержку и избавляет нас от необходимости вносить изменения всякий раз, как что-то поменялось в требованиях. separation of concerns и protected variations в действии.


                    Конечно есть исключения, когда запись и чтение должны происходить атомарно (пример — array_pop), но это не такой уж частый кейс или хотя бы можно минимизировать возникновение таких вот штук.
                      –1
                      Вы сейчас мыслите в категории «а как же автоинкременты мои любимые». Используйте UUID и тогда все ок.

                      Замечательный подход к проблеме. Если задачу нельзя решить заданным патерном проектирования — нужно менять задачу!
                        0
                        Это не смена задачи. Я использую UUID почти всегда что бы не зависить от базы данных. Ну и в последнее время все чаще в моих проектах возникает необходимости создавать ресурсы на клиенте, что бы можно было просто синхронизировать это дело. В этом случае клиент генерит мне идентификатор ресурса.
                        С точки зрения клиента (ну то есть штуки которая использует мою апишку) оно так и так идентификатор получит. Какая разница? Зато у нас потом нет никаких проблем.
                          0
                          А валидация сгенерированного UUID ведется же? Приходящим данным с клиента мы же не верим обычно.
                          Ну сгенерил клиент UUID, но все равно он же получает результат выполнения/невыполнения запроса, так?
                          А в API тогда так же торчат UUID-ы? Ну в духе: GET /item/550e8400-e29b-41d4-a716-446655440000
                            0
                            А валидация сгенерированного UUID ведется же?


                            нет. Их нет смысла валидировать. Вставка удачна или нет — вот и вся проверка. Уникальный индекс гарантирует нам консистентность. В случае генерации ресурса на клиенте можно предварительно сделать HEAD запрос на ресурс с таким uuid и таким образом резко снизить вероятность коллизии (ибо на большом количестве распределенных девайсов вероятность все же имеется).
                            Ну сгенерил клиент UUID, но все равно он же получает результат выполнения/невыполнения запроса, так?


                            ну так у нас в контроллере есть uuid, а стало быть мы можем потом запросить что нужно по этому uuid. В этом собственно соль. Методы которые мутируют состояние — void, а методы получающие состояние — не мутируют оное. Вот примерчик (не стоит воспринимать его буквально, это схематично).
                            public function someAction(Request $request)
                            {
                                 $this->handleCommand(new MyActionCommand(
                                      $request->get('uuid'),
                                      $request->get('some_data')
                                 ));
                            
                                 return $this->handleQuery(new MyActionResultQuery($request->get('uuid'));
                            }

                            А в API тогда так же торчат UUID-ы? Ну в духе: GET /item/550e8400-e29b-41d4-a716-446655440000


                            именно так. Если ресурсы редко создаются можно генерить slug какой более короткий. Но это не принципиально.
                              +2
                              У нас ведётся валидация на уровне запроса по формату UUID (как одного из полей «формы» регуляркой, 400 ответ, если формат не соблюден) и на уровне репозитория проверка на уникальность (409 если дублируется). Собственно так же как и по любому другому полю.

                              Да, в query UUID по умолчанию. Иногда есть требование давать что-то более человекочитаемое, тогда формируем, например, из числовой последовательности «slug» и отправляем его в Location, иногда давая «каноничную» (с UUID в теле) в теле или других заголовках, иногда — нет.
                                0
                                А что, бывает 409 приходится возвращать? Понятно, что все зависит от качества реализации алгоритма в той или иной имплементации (вспоминается история с привязкой при генерации к MAC адресу сетевой карты), но по определению же UUID уникален всегда. Т.е. все сводиться к выбору хорошей библиотеки для генерации UUID. Кстати, какие используйте?
                                  0
                                  но по определению же UUID уникален всегда


                                  зависит от качества энтропии используемой генератором псевдорандомных чисел. При очень большом потоке UUID-ов могут появляться коллизии. Вероятность оных не велика но она не равна нулю. Сейчас ситуация с этим получше чем пару лет назад. Можете посмотреть например тут: https://github.com/ramsey/uuid/issues/80
                                  А что, бывает 409 приходится возвращать?


                                  юзер регистрируется повторно с теми же креденшелами например.
                                    +1
                                    Приходящим данным с клиента мы же не верим обычно


                                    :)

                                    Реальная коллизия одна была на моей памяти на UUID v1, но вот действия пользователей типа нажать ф5 до получения ответа или просто сделать «дабл-клик» на кнопке отправки, встречаются гораздо чаще.
                          0
                          CommandHandler-ы в вашем случае ничего не возвращают. Типичный REST при создании чего-то должен вернуть идентификатор созданного ресурса. Как поступаете?

                          Сам паттерн подразумевает, что команды ничего не возвращают. Относительно возврата значения — обработчик команды кидает событие с идентификатором.
                          И еще непонтяно, если у Вас фронтенд на ангуларах, зачем юзаете симфони формы?

                          Это я просто так приплел, чтобы аргументировать AJAX.
                            0
                            И еще непонтяно, если у Вас фронтенд на ангуларах, зачем юзаете симфони формы?

                            Добавлю, что формы Symfony можно использовать и при REST (особенно PUT, POST, PATCH). Сильно облегчают разработку. На выходе вы сразу получаете провалидированную сущность.
                            https://symfony.com/doc/master/bundles/FOSRestBundle/2-the-view-layer.html#forms-and-views
                              +1
                              Как по мне, создание более-менее сложной формы превращается в написание кастомных тайпов, всяческих листенров на PRE/POST_SET_DATA, и сплошную боль. Формы пытаются делать слишком много и из-за этого их приходится постоянно докручивать. Не вижу никаких плюсов при написании REST-й апихи вообще смотреть на формы.
                              По поводу плюса в получении провалидированной сущности", нет ничего сложного вызвать компонент валидатора руками, передав ему сущность с замапленными изменениями.
                          0
                          CommandHandler-ы в вашем случае ничего не возвращают.

                          В типичном CQRS они и не должны, максимум, что они могут делать — бросать события, чтобы обновить Read БД


                          Типичный REST при создании чего-то должен вернуть идентификатор созданного ресурса.

                          Здесь вероятно может помочь перевод идентификаторов сущностей на UUID тогда айдишник будет известен еще в момент создания сущности.

                          0
                          Я не придумал ничего лучше, кроме как кидать специальное исключение с массивом ошибок. Мой внутренний перфекционист очень страдает от этого, но возможно он не прав, буду рад успокоительным комментариям. Также приветствуется, если кто-то предложит более удачное решение.

                          Паттерн "Мультиисключение": https://habrahabr.ru/post/279501/ и https://habrahabr.ru/post/279737/#comment_8813461
                            0
                            ну список ошибок — это не исключения, так что я бы сказал что это просто исключение которое содержит детали. И в этом нет ничего эдакого. Зато удобно.
                            +2
                            А почему бы не вынести try… catch на более глобальный уровень, и не дублировать его в каждом контроллере?
                            Вот тут написано как сделать это в Laravel https://laravel.com/docs/5.2/errors#the-exception-handler, в симфони это можно сделать создав ExceptionListener.
                            А почему не передать в UpdateProjectCommand первым параметром id, примерное так UpdateProjectCommand($id, $data), это разве будет не более грамотно?
                            Также интересно в чем плюс SimpleBus по сравнению с tactician?
                            Ну и по возможности мне кажется лучше использовать ParamConverter как рекомендует Symfony BestPractice, он сильно облегчает код.
                              0
                              А почему бы не вынести try… catch на более глобальный уровень, и не дублировать его в каждом контроллере?
                              Вот тут написано как сделать это в Laravel laravel.com/docs/5.2/errors#the-exception-handler, в симфони это можно сделать создав ExceptionListener.
                              Вообще, можно. Но во-первых меня смущает, что это спрячется под капот — не отпускает ощущение, что с точки зрения maintainability кода станет хуже. Во-вторых, не все контроллеры возвращают json, есть и HTML и даже stream.

                              А почему не передать в UpdateProjectCommand первым параметром id, примерное так UpdateProjectCommand($id, $data), это разве будет не более грамотно?
                              Нет, это ломает паттерн. Все входные данные должны быть единой структурой (командой).

                              Также интересно в чем плюс SimpleBus по сравнению с tactician?
                              С tactician не работал, не могу сказать.

                              Ну и по возможности мне кажется лучше использовать ParamConverter как рекомендует Symfony BestPractice, он сильно облегчает код.
                              Стыдно признаться, но я как-то уже и забыл о такой фиче. Спасибо что напомнили. )
                                0
                                Можно рулить всем в ExceptionListener проверяя заголовки, и отдавая результат в нужном виде. Может это и немного нарушает какие-то принципы, но это очень удобно.
                                Почему в UpdateProjectCommand все данные должны быть единой структурой? Насколько я понимаю Command в данном случае просто агрегирует нужные нам данные для выполнения команды, и может принимать эти данные в любом виде, через установку переменных, через set функции, или через массив в конструкторе, разве принятия 2 переменных в конструкторе как-то нарушает этот паттерн Command?
                                  +1
                                  ExceptionListener — надо будет пожалуй попробовать, вдруг мне даже понравится.
                                  Насчет двух переменных в конструкторе — да, безусловно, можно и так. Признаться, я сначала второпях решил, что ты пишешь про вызов обработчика, типа:
                                  $this->container->get('command_bus')->handle($id, $command);
                                  Это действительно нарушало бы паттерн. Но коли речь про инициализацию самой команды — лично я не возражаю.
                                +1
                                Также интересно в чем плюс SimpleBus по сравнению с tactician?
                                Поковырял я Tactician, посравнивал исходники этих двух библиотек, посмотрел на историю развития и даже погуглил альтернативы. Результаты:

                                1. Кроме этих двух библиотек достойных альтернатив не найдено. Обе предоставляют «plain vanilla» реализацию, которую можно использовать в любом PHP-проекте, а также целую пачку плагинов, с помощью которых можно прикрутиться к Symfony, Laravel, привязаться к Doctrine (для автотранзакций команд), или завязаться на RabbitMQ, или на Bernard (чтобы выполнять команды в фоне).

                                2. Обе библиотеки появились примерно в одно время, имеют сравнительно равное количество форков и звезд. В настоящий момент архитектура и технические решения обеих библиотек в целом идентичны (хотя год назад SimpleBus был сильно другим). То ли они эволюционировали одинаково, то ли SimpleBus в итоге «причесался» под Tactician — не знаю.

                                3. Сами авторы (Ross Tuck и Matthias Noback) постоянно ссылаются на библиотеку «соседа» в своих постах, твитах, и даже на конференциях, когда выступают на эту тематику. Matthias Noback (SimpleBus) также признался, что когда начинал работу над SimpleBus, много общался с Ross Tuck на тему дизайна такой библиотеки. Может, поэтому они такие одинаковые. )

                                4. Плюсы Tactician — можно сконфигурировать несколько command bus.

                                5. Плюсы SimpleBus — помимо command bus есть еще и event bus.

                                Других отличий не нашел.
                                  +1
                                  > 5. Плюсы SimpleBus — помимо command bus есть еще и event bus.

                                  Уже есть и в Tactician
                                0
                                Немного смущает то, что валидация получается как-то размазана. В entity, в command хандлере, в контролере при edit.
                                Имхо, лучшим вариантом было бы описывать валидацию где-нибудь в одном месте и валидировать реквест. Да, как в ларе)
                                А по поводу эксепшенов валидации, то у вас немного другой случай по сравнению со статьёй которую вы скинули. Там предлагалось на ошибку сразу же выкидывать эксепшен, когда у вас выкидывается только по факту существования ошибок. Другими словами, вы просто в другом месте делаете if(count($violations) != 0).
                                Спасибо за статью)
                                  0
                                  Вообще-то валидацию можно помещать в группы и использовать ту или другую группу для различных операций.
                                    0
                                    Ну как размазана… В editAction ее нет, там только проверка entity на существование, да и то это легко выпиливается через ParamConverter, как посоветовали выше. Действительно, она есть и в команде (валидация входных данных), и в entity (валидация на уникальность). Но это двойственность присуща и без Command Bus (вместо команды она в форме). Она не появилась, она просто не исчезла. ;)
                                      0
                                      и в entity (валидация на уникальность)

                                      Должна ли она быть там?
                                      Ещё проскочила мысль:
                                      Должна ли бизнес логика находиться в хандлере? Мне кажется в команде она выглядела бы логичней. Даже по названию абстракций.
                                      Банальный пример, к примеру нам нужно логировать именно эту команду, миддлварю для этого писать как-то не айс, и тогда в хандлере код логирования смешается с кодом бизнес логики. Более симпатичней, на мой взгляд, если бы хандлер выглядел как-то так:
                                      public function handle(CreateProjectCommand $command, array $data)
                                      {
                                      $this->logger->log('Creating new project');
                                      $entity = new Project();
                                      $command->execute($entity, $data);
                                      $this->logger->log('New project created');
                                      }

                                      Таким образом хандлер получается этаким экшеном для команд.
                                      В контроллерах экшены для реквестов, а в хандлерах — для команд) Так же возможно имеет смысл в этом месте снабжать команды всеми необходимыми зависимостями, которые будут резолвиться в конструкторе хандлера.
                                      0
                                      то, что валидация в двух местах это не страшно.
                                      Валидация в контроллере может проверять по Read DB (читай кешу), и должна ответить на вопросы:
                                      1. Может ли данная команда быть успешной
                                      2. Правильно ли сформирована команда с учетом входных данных

                                      Валидация Бизнес Логики, осуществляемая в хендлере команды — это валидация по Write DB, и эта валидация учитывает непосредственное состояние системы перед ее изменением.

                                      Таким образом мы получаем отказ при Command валидации в очень редких случаях.
                                      Подробнее можно послушать здесь
                                      https://channel9.msdn.com/Blogs/MichaelLehman/PP-Symposium-2010-The-Commmand-Query-Separation-Pattern-Chris-Tavares
                                      Начиная с 15:00
                                      0
                                      Ок. А почему нельзя тоже самое сделать стандарными методами Symfony? Там есть механизм EventSubscriber. Вешаем событие с определенным тегом, например 'user_registration', создаем UserRegistrationEvent, а дальше $eventDispatcher->dispatch('user_registration', $event). Причем можно подцепить любое количество слушателей в нужно приоритете, которые обрабатывают данные.
                                      Статья интересная, но я не вижу смыслы цеплять левую компоненту в фреймворк, где уже есть похожие механизмы.
                                        0
                                        А почему нельзя тоже самое сделать стандарными методами Symfony?

                                        Потому что CommandBus работает чуть чуть подругому нежели ивент диспатчер. Очень похоже но все же нет, и в теории можно запилить на диспатчере реализацию комманд баса.
                                          +1
                                          Не увидел того самого "чуть чуть", что нельзя бы было сделать через EventSubscriber....
                                            +1
                                            CommandBus — частный случай EventSubscriber. В CommandBus всегда 1 обработчик, а в EventSubscriber — любое число обработчиков (в т.ч. — 0). То есть CommandBus гарантирует а) что кто-то обработает вашу команду, б) что ее обработают не более 1 раза
                                              0
                                              Согласен. Вопрос в другом, зачем подключать внешнюю компоненту, если с таким же успехом можно использовать имеющийся функционал?
                                                0
                                                Потому что команды и события — разные вещи. Даже если одно можно имитировать через другое, это не означает что так и стОит делать. Подключение внешней компоненты — столь малый «оверхэд», что лично я не вижу смысла акушерствовать через гланды в имитации команд через события.

                                                А вот вопрос «зачем использовать event_bus из третьей компоненты, если в Symfony уже есть EventDispatcher» был бы вполне легитимен. И я бы не стал настаивать — вполне можно использовать команды из SimpleBus, а события — из стандартного EventDispatcher'а. Руки никто не выкручивает.
                                                  +1

                                                  Потому что использование event dispatcher для этого хоть и возможно, но требует написания кода. Подключить же стороннее решение — composer require + прописать в AppKernel. Мне что-то подсказывает что это проще и быстрее.

                                          0
                                          Я не понимаю. Это чем-то отличается от вынесения всей бизнес-логики в сервисы, которые можно использовать где угодно?
                                          То есть в чём выигрыш от подключения этого бандла?
                                          При использовании сервисов, опять же, можно пользоваться di-контейнером, а не создавать классы этих CommandBus через new.
                                          Эти же сервисы после этого можно, при желании, использовать в не-симфони проектах.
                                            +1
                                            обработчики команд — это старые добрые сервисы уровня приложения. Ничего нового. Только разделено на отдельные маленькие сервисы, каждый из которых хэндлит свой юзкейс. И самое важное — все хэндлеры команд — void. То есть они не имеют права отдавать результат операции.

                                            По сути разделение ответственности позволяет нам комбинировать различные штуки как мы того хотим. Скажем если у нас было какое-то действие, в рамках которого мы должны отдать одну выборку, а потом нам надо переделать ее на другую — мы не меняем хэндлер команды и просто добавляем новый обработчик запросов. То есть вместо "изменения" текущего кода мы только добавляем периодически избавляясь от того что уже не нужно. Это в свою очередь существенно сокращает вероятность ошибок.

                                            Так же отдельные команды полностью описывают выполняемые приложением юзкейсы, что немаловажно при DDD. То есть все же мы бизнес логику должны делать а для этого анализировать. Форсит нас делать декомпозицию и т.д.

                                            Ну и это дает огромнейший простор для того что бы крутить и вертеть архитектурой приложения. Например можно сверху поставить какой-нибудь Ayres с event loop и конвертить реквесты в команды, отправляя их в очередь, и сделать парочку демонов которые будут далее выполнять команды, скейлить их на множество инстансов и т.д. Ну это из экзотики. Хотя конечно ограничений так же не мало.
                                            То есть в чём выигрыш от подключения этого бандла?

                                            не надо делать свой command bus. Вот и все.
                                            При использовании сервисов, опять же, можно пользоваться di-контейнером, а не создавать классы этих CommandBus через new.

                                            Руками создаются только команды, они как DTO, тупо данные без поведения. А вот уже хэндлеры — это полноценные сервисы и они уже создаются DI-контейнером.
                                            Эти же сервисы после этого можно, при желании, использовать в не-симфони проектах.

                                            Тут как бы тоже самое. В крайнем случае можно воспользоваться принципом инверсии зависимости (то есть объявить свои интерфейсы и реализовать их используя этот бандл).
                                              0
                                              По сути разделение ответственности позволяет нам комбинировать различные штуки как мы того хотим.
                                              Так же отдельные команды полностью описывают выполняемые приложением юзкейсы

                                              Теперь лучше понял :) То есть лишняя возможность отделить мух от котлет, использовать явные "точки входа". Интересно, постараюсь обдумать и попробовать применить, спасибо.
                                            0
                                            идея конечно интересная, но вот с реализацией я не согласен
                                            1. Валидирование размазано по командам.
                                              Правила валидирования должны описываться в сущности, то есть в Project. А так вы дублируете правила валидации от команды к команде и велика вероятность что где-то что-то потеряете. А бизнес логика должна быть рядом с сущностью
                                            2. Не нужно создавать анонимную функцию $empty2null.
                                              1. Ни что не мешает создать приватную функцию
                                              2. Правильней использовать трансформеры

                                            3. Для преобразования запроса в сущность лучше подходит механизм форм

                                            Такой вариант проще и эластичней
                                            $project = new Project();
                                            
                                            $form = $this->createForm(ProjectForm::class, $project);
                                            $form->handleRequest($request);

                                            чем такой
                                            $data = $request->request->get('project');
                                            
                                            $entity = (new Project())
                                                ->setName($data['name'])
                                                ->setDescription($data['description']);

                                            это у вас только 2 простых поля, а что если полей 15 и некоторые из них являются связями
                                            Ну и напоследок.
                                            На мой взгляд будет проще и удобней если команда на вход будет принимать http запрос и внутри его преобразовывать в сущность.
                                            То есть команда инкапсулирует преобразование запроса в сущность, а обработчик команды уже сохраняет сущность из команды, обрабатывает ошибки, логирует что надо и бросает евенты какие надо.
                                              +1
                                              Валидирование размазано по командам. [...] А так вы дублируете правила валидации от команды к команде и велика вероятность что где-то что-то потеряете.
                                              Валидирование как раз сконцентировано в командах. Некоторое дублирование имеет место между «парными» операциями типа «создать/изменить», но такие «парные» операции далеко не всегда пересекаются настолько сильно. Лично мне это кажется меньшим из зол, и я до сих пор ничего не терял, несмотря на упомянутую вероятность.

                                              Правила валидирования должны описываться в сущности, то есть в Project.
                                              Если честно, я с этим постулатом в корне не согласен. Дело в том, что сущности бывают разные. Есть ORM-сущности, которые по сути описывают схему данных и являются прямым отображением ваших таблиц из БД в код. А есть объекты предметной области, они же бизнес-объекты, они же domain-сущности. Они описывают те объекты, с которыми работает ваша бизнес-логика.

                                              ORM-сущности не должны содержать никакой логики вообще. Это схема данных. А вот domain-сущности наоборот — хорошо, когда они не только описывают объект предметной области, но и предоставляют функции работы с ним. Еще один ньюанс между этими двумя типами сущностей — они далеко не всегда соотносятся один-в-один. Например, в некой предметной области некоего проекта есть понятие «адрес». Обычный физический адрес — город, улица, номер дома, и пр. У меня будет соответствующая domain-сущность, реализующая заодно и необходимую бизнес-логику (например, найти latitude/longitude адреса). А вот ORM-сущностей у меня будет несколько, т.к. есть отдельно таблица городов, отдельно — стран, и мало ли чего еще, и все они друг с другом связаны на уровне БД (например, «один ко многим», хотя может быть что угодно).

                                              Более того, бывает даже наоборот — одна таблица в базе, а в предметной области у нее несколько domain-сущностей. Поэтому я никогда не смею добавлять в ORM-сущности ни логику, ни валидацию, ни что-либо еще кроме «геттеров»/«сеттеров» и комментариев. Честно говоря, я бы и «сеттеры» припрятал, чтобы возвращаемые из базы ORM-сущности оставались immutable value objects, какими они на мой взгляд и должны быть (но не хочется потом городить огород с фасадами, с которыми возможно потом придется разбираться другим разработчикам — проще оставить в том виде, в каком оно навязано Symfony-генератором).

                                              Не нужно создавать анонимную функцию $empty2null
                                              Чем плоха анонимная функция в данном контексте? «Можно и без нее» — слабый аргумент. :)

                                              Для преобразования запроса в сущность лучше подходит механизм форм
                                              Я упоминал в статье, что формы — не единственный источник данных. Идея как раз в том, что преобразование запроса непосредственно в сущность — частный случай. В реальной жизни значительная часть реквестов вообще ни в какие сущности не преобразуются. Строго говоря: запросы — это действия, сущности — это субъекты, в эти действия вовлеченные. Нельзя замапить действие на субъект, можно выполнить первое над последним.
                                                0
                                                Некоторое дублирование имеет место между «парными» операциями типа «создать/изменить»

                                                Если есть дублирование, то может стоит от него избавится. Вы так не считаете?
                                                Есть ORM-сущности, которые по сути описывают схему данных и являются прямым отображением ваших таблиц из БД в код

                                                Для какого ни будь Yii это так, но в случае Doctrine нет. Как говорили выше, Doctrine предлагает работать с сущностями не зацикливаясь на структуре БД. Почему вы считаете что ORM не может быть объектом предметной области?
                                                Да, ORM только описывает сами сущность и не позволяет выполнять какие-то действия напрямую из сущность как например в случае Active Record. Но у нас есть еще один уровень Repository который позволяет выполнять бизнес процессы над конкретной сущностью.
                                                Можно даже пойти дальше и создать сервис который помимо действий заложенных в Repository сможет выполнять дополнительные действия обращаясь к своим зависимостям. Например логировать события добавления новой записи.
                                                А валидация это неотъемлемая часть бизнес логики и должна быть как можно ближе к сущности, то есть в аннотациях ORM, и не как не в командах которые ничего не знают о бизнес сущностях.
                                                PS: Можно конечно валидацию и в конфиги вынести, но это не best practice.
                                                Чем плоха анонимная функция в данном контексте? «Можно и без нее» — слабый аргумент. :)

                                                1. такой код ИМХО некрасивый
                                                2. есть более подходящие инструменты для этой задачи
                                                3. я могу ошибаться, но по моему анонимные функции создаются каждый раз заново при каждом выполнении куска кода с ее инициализацией

                                                в общем это мое субъективное мнение
                                                  0
                                                  Валидации не должно быть в entity, entity должен быть всегда валидным, он не может быть не валидным, а соответственно еще на этапе его создания все должно свалится с ошибкой, если мы пытаемся установить не верные данные.
                                                    0
                                                    А я и не говорил что валидация должна выполнятся в entity. Я говорю что валидация и entity это связанные вещи. Бизнес сущность, которой является entity, определяет правила собственной валидации. И я не считаю правельным описывать правила валидации сущности вне её контекста. Об этом же говорит best practice от Symfony
                                                      +1
                                                      Давайте различать валидацию бизнес ограничений, которая выполняется в методах объектов предметной области перед изменением состояния, и тупой валидацией через symfony/validation.
                                                    0
                                                    Если есть дублирование, то может стоит от него избавится. Вы так не считаете?
                                                    Если честно — нет. Принцип DRY надо готовить с умом и без фанатизма. Если следовать ему слепо, например генерируя общие классы-предки только для того, чтобы вынести туда три повторяющихся поля, то вы:
                                                    • усложняете ваше семейство классов,
                                                    • усложняете понимание системы новым на проекте людям,
                                                    • вырываете себе волосы, когда один из двух классов-потомков вдруг меняется так, что исчезает та часть, которую вы считали общей, и ваш класс-предок отныне имеет лишь одного потомка.

                                                    Почему вы считаете что ORM не может быть объектом предметной области?
                                                    Да я в общем-то так и не считаю. Я лишь считаю, что ORM не обязана быть объектом предметной области.

                                                    Хотите пример? На одном моем проекте пользователи могут создавать шаблоны неких псевдо-документов, указывая, какие поля эти документы должны иметь. Поля могут быть разных типов — логическое (что в UI превращается в checkbox), целочисленное, вещественное и строковое. Создавая в своем шаблоне строковое поле, пользователь должен указать ограничение на длину значений в этом поле. Создавая численные — диапазон допустимых значений. У логических полей вообще нет ограничений.

                                                    Все эти «кастомные» поля хранятся в одной таблице БД. В таблице три столбца (помимо FK на шаблон) — type, parameter1 (nullable), parameter2 (nullable). Если поле строковое, ограничение на длину значений хранится в «parameter1», а «parameter2» — NULL. Если поле целочисленное, минимальное значение хранится в «parameter1», максимальное — в «parameter2». Если поле вещественное, то минимальное и максимальное значения хранятся в другой таблице, а в «parameter1» и «parameter2» лежат ключи на нее. Если поле логическое — оба столбца содержат NULL.

                                                    В данном случае моя ORM-сущность (Field) содержит геттеры «getParameter1» и «getParameter2». А еще у меня есть несколько классов-фасадов (доменных сущностей), например:

                                                    class StringField
                                                    {
                                                        protected $field;
                                                    
                                                        public function __construct(Field $field)
                                                        {
                                                            $this->field = $field;
                                                        }
                                                    
                                                        /**
                                                         * Sets maximum allowed length of field values.
                                                         *
                                                         * @param int $length
                                                         *
                                                         * @return self
                                                         */
                                                        public function setMaxLength($length)
                                                        {
                                                            $this->field->setParameter1($length);
                                                    
                                                            return $this;
                                                        }
                                                    
                                                        /**
                                                         * Returns maximum allowed length of field values.
                                                         *
                                                         * @return int
                                                         */
                                                        public function getMaxLength()
                                                        {
                                                            return $this->field->getParameter1();
                                                        }
                                                    }
                                                    


                                                    А в фасаде «IntegerField» нет геттеров/сеттеров «MaxLength», зато есть два других — «MinValue» и «MaxValue». А в фасаде «FloatValue» такие же две пары геттеров/сеттеров как и в «IntegerField», но их реализация иная (и вообще этот фасад имеет дополнительную DI-зависимость от соответствующего репозитория). А еще типов полей больше, чем я перечислил. А еще иногда пользователи просят добавить новый тип (например, дату). Как всю эту волшебную бизнес-логику втоптать в одну несчастную ORM-сущность, не превращая ее в монстра?

                                                    Вот именно поэтому ORM-сущности и domain-сущности — это имхо разные вещи. Это как «вордовский» документ — вроде, открывая его в редакторе, вы видите текст, но если посмотреть в файл на бинарном уровне — там непонятная жесть. Так вот ваш WYSIWYG-редактор — это доменная сущность, а бинарное представление в файле — ORM. В случае plain text вы можете не заметить разницы, но не дайте себя обмануть.

                                                    Конечно же, не утверждаю, что на каждую ORM-сущность вы всегда должны иметь отдельную domain-сущность — нет, только когда необходимо. Если ORM-сущности мне достаточно для работы (а это в большинстве случаев так) — я использую ее как есть. Но это не повод пихать domain-правила в ORM-представление.

                                                    я могу ошибаться, но по моему анонимные функции создаются каждый раз заново при каждом выполнении куска кода с ее инициализацией
                                                    Да и пусть создается. Если бы там не нужна была рекурсия, там бы был просто цикл прохода по массиву. И если бы я этот цикл вынес в отдельную функцию, я сам бы себя заминусовал за это. А так — часть алгоритма хочет вызывать саму себя рекурсивно — отличное применение для анонимной функции, как мне кажется. И хотя я считаю адекватными оба варианта, а не только свой — мое мнение в этом вопросе не менее субъективно, чем ваше, так что предлагаю оставить эту часть дискуссии. :)
                                                      +1
                                                      Принцип DRY надо готовить с умом и без фанатизма.

                                                      Если у вас дублируется код — то согласен, а вот если поведение — это уже надо устранять. Тут есть тонкая грань.
                                                      На одном моем проекте пользователи могут создавать шаблоны неких псевдо-документов

                                                      Года полтора назад у меня был схожий проект. Из вашего описания я предполагаю что появилось множество if-в (возможно не у вас а у того кто использовал ваши "сущности".
                                                      К примеру вместо сеттеров setMaxLength у меня была просто коллекция констрейнтов, которая валялась в базе в сериализованном виде (выборки ж по ней делать не надо?). В итоге интерфейс был сильно упрощен до одного метода — addConstraint.
                                                      на основании шаблона генерилась форма:
                                                      $form = Form::fromTemplate($formTemplate);

                                                      А для валидации у каждого контрола имелся простой метод validate. Правда правила валидации у меня были весьма скромные — есть данные, нет данных, тип данных. Для удобства я пользовался OptionsResolver-ом в моей обертке.
                                                      $errors = $userForm->validate($data);
                                                      if (empty($errors)) {
                                                          $userForm->fillWithData($data);
                                                      }

                                                      А форма уже сама пробегалась по своей структуре. Причем это даже спасало пару раз когда надо было сильно поменять внутреннюю реализацию документа.
                                                      Что до редактирования шаблона — мне нужна была функциональность логирования изменений, потому у меня был отдельный объект описыващий что хотели сделать с формой (например добавили контрол, удалили контрол, сменили лэйблочку и т.д.) и простой метод applyChanges который это дело просто запихивал в коллекцию + проигрывал изменения. Функциональность ролбэка делать не нужно было, хоть мысли такие и были, но если что можно было бы просто сделать rollback($revision) и откатить состояние.
                                                      Словом… вся соль этого всего — что бы о внутренней структуре формы, то как оно у меня что хранится, знала только сущность. Это и тестирование серьезно упрощало, и дальнейшие изменения. Структура формы как я уже сказал менялась 3 раза из-за изменения требований.
                                                      Но в целом тут можно и ваш подход применить ибо как таковой бизнес логики у этой сущности нет, это тупо структура данных. И если бы я даже сделал все на сеттерах/геттерах можно было бы еще как-то крутить и вертеть все это дело, вынес всю эту логику в сервисы.
                                                      Но вот если мы будем говорить о проектах, где есть именно бизнес логика, скажем интернет магазин, с сеттерами мы уже можем проиграть. У меня буквально недавно был проект, где надо было впилить логирование изменений по определенным тригерам. И с сеттерами это было сделать крайне тежело — пришлось на Unit-of-work завязаться. А по логике у меня должен был быть просто один единственный метод в сущности через которое атомарно пименялись изменения, и тогда все было бы хорошо.
                                                    +1
                                                    ORM-сущности не должны содержать никакой логики вообще. Это схема данных.

                                                    Ваше заявление несколько противоречин "официальной позиции" разработчиков Doctrine. Когда мы говорим о сущностях доктрины, мы говорим просто о сущностях, бизнес объектах. Вся работа ORM абстрагирована за счет использования коллекций и identity map. То есть когда мы говорим о доктрине мы говорим только о том как обычные объекты взаимодействуют друг с другом.
                                                    Если вы используете сущности доктрины не как объекты предметной области, то я могу вам только сказать что это нерациональное использование инструмента. Если вам так уж нужен ORM + DataMapper — есть намного более простые реализации, не несущие такой сокрытой сложности.
                                                    Если хотите, посмотрите это: Marco Pivetta — Doctrine ORM Good Practices and Tricks. А поскольку Doctrine не является частью Symfony о каких best practice мы вообще говорим?
                                                    Более того, бывает даже наоборот — одна таблица в базе, а в предметной области у нее несколько domain-сущностей.

                                                    Вообще-то скорее одна сущность и внутри может быть иерархия value object-ов.
                                                      0
                                                      Ваше заявление несколько противоречин «официальной позиции» разработчиков Doctrine
                                                      Беда. :)

                                                      Если вы используете сущности доктрины не как объекты предметной области, то я могу вам только сказать что это нерациональное использование инструмента.
                                                      А вот тут вы не ошиблись — я действительно использую Doctrine больше как «маппер». Возможно, не прав — посмотрю вашу ссылку, попробую переосмыслить.

                                                      А поскольку Doctrine не является частью Symfony о каких best practice мы вообще говорим?
                                                      Я такого не говорил (хотя пара других комментаторов действительно их упоминали).

                                                      Вообще-то скорее одна сущность и внутри может быть иерархия value object-ов.
                                                      Я выше привел пример — было бы интересно узнать, как бы вы рекомендовали реализовать это.
                                                        +1
                                                        Doctrine слишком сложна что бы использовать ее тупо как мэппер, есть решения намного более простые (Spot2 например). Да и с версии 2.5 и введением Embeddable закончились те времена когда использовать сущности доктрины как именно сущности было не столь удобно.
                                                          0
                                                          Да, похоже я застрял где-то в 2.3 еще. За подсказку спасибо, плюсанул.
                                                      +1
                                                      Есть ORM-сущности, которые по сути описывают схему данных и являются прямым отображением ваших таблиц из БД в код. А есть объекты предметной области, они же бизнес-объекты, они же domain-сущности. Они описывают те объекты, с которыми работает ваша бизнес-логика.

                                                      Паттерн DataMapper, который активно используется в Doctrine, не предполагает ни прямого отображения, ни разделения. Ровно наоборот, он предполагает, что есть только объекты доменной области со своим состоянием и логикой, плюс правила, заданные где-то сбоку (аннотации в классе объекта тоже сбоку, они не являются кодом объекта, они никак не влияют на его данные или поведение), по которым они отображаются на на базу данных, причём прямое отображение — лишь простейший частный случай отображения. Не редко свидетельствующий о плохом знании Doctrine, в частности о её способности работать с value object, или просто о забивании гвоздей микроскопом. А адрес, как правило, в предметной области является именно value object, он не обладает значимой для бизнес-задач идентичностью, если бизнес-задача не лежит в области различных кадастров.

                                                        0
                                                        Каюсь, виноват. Сейчас мне уже очевидно, что у меня извращенное понимание бизнес-логики. Для меня это «юз-кейсы», типа «отредактировать юзера», «послать юзеру письмо» и пр. И мне странно, когда люди создают в сущностях соответствующие функции, типа «editUser» или «sendEmail». Тем более что во многих «юз-кейсах» вовлечено более одного типа сущностей.

                                                        Безусловно, существует некая часть логики, которая требуется лишь в рамках конкретного типа сущности, и предназначена либо для сохранения валидности сущности (например, функция «setUserType», которая не дает назначить значение вне допустимого диапазона), либо для простейших «служебных» операций (например, добавить юзера в группу, при условии наличия соответствующего отношения).

                                                        Возможно, кто-то скажет «тонкая грань», но для меня критерий очевиден — если некая функция требует внешних зависимостей, значит это бизнес-логика, которую надо выносить из сущности. Делать сущность с DI-зависимостями — преступление. А передавать эти зависимости через параметры функции — по-моему, неудобно.
                                                          0
                                                          Для меня это «юз-кейсы»


                                                          Юз-кейсы описывают именно пользовательские сценарии, очень высокоуровневое описание бизнес логики. В рамках юзкейсов можно выделить еще отдельные бизнес правила и ограничения вроде «пользователь всегда должен иметь email и пароль». То есть все бизнес правила и ограничения, относящиеся к отдельным сущностям, должны быть прописаны именно в этих сущностях. Если у нас есть бизнес правила, которые описывают работу нескольких сущностей (например бизнес-ограничение на максимальное количество заказов в день) — тут сущность уже не владеет необходимым количеством информации и мы вынуждены вводить сервисы.

                                                          А юзкейсы — это отдельные сервисы для оркестрации. То есть у нас обычно есть какой-то сервис уровня приложения, который отвечает за выполнение всего юзкейса, и который только делигирует отдельные задачи другим сущностям/сервисам.
                                                            0
                                                            Ну, тогда лично я буду считать, что мы разобрались. Говорим мы об одном и том же, даже если я и путаюсь в используемой вами терминологии.
                                                            0
                                                            Внешние зависимости внешним зависимостям рознь. С одной стороны, впихивать логику отправки письма пользователю в сущность этого пользователя не есть хорошо чисто семантически (письма отправляет мэйлер, а не пользователь, и отправляет он даже не пользователю, а на адрес пользователя), но с другой сложно найти более подходящее место для бизнес-логики, агрегирующей, например, по сложной логике кучу связанных с пользователем других сущностей.
                                                              0
                                                              впихивать логику отправки письма пользователю в сущность этого пользователя не есть хорошо чисто семантически


                                                              public function notify(Notifier $notifier) : void {
                                                                  // сложная логика нотификаций внутри сущности пользователя
                                                                  // дабы не нарушать инкапсуляцию ибо мега специфичный кейс
                                                                  // и делать миллион геттеров не хочется.
                                                              }
                                                              


                                                              Ну это к вопросу «не есть хорошо чисто семантически». Мы просто ложим интерфейс Notifier-а рядом с сущностью и передаем реализацию через дабл диспатч. и все круто, красиво и т.д.

                                                              Для более простых вещей, если мы все еще не хотим заводить сервисы уровня приложения или нам хочется разделить важное и не очень важное — то доменные ивенты хорошо подходят.
                                                      0
                                                      Я в симфе не так давно но мне не понятен профит данного CommandBus. С таким же успехом я могу создать некий сервис(ProjectManager или ProjectHandler) и передавать сервису чистый request. Если нам не нужен твиговский рендер то содержимое контроллера можно вообще свести к 1й строчке, спрятав всю бизнес логику в сервисе.
                                                        0
                                                        Вот здесь уже хорошо ответили, посмотрите, пожалуйста.
                                                        0
                                                        Недавно столкнулся с подобной проблемой т.е. вынесение логики в отдельные независимые классы так же пробывал подход с командами, но меня ни как не устраивало в данном паттерне, то что он не возвращает значение. Может вы мне подскажите как бы вы решили проблемы с которыми я столкнулся так как я ещё пока на распутье.
                                                        Первое мое требование было однообразие в архитектуре проекта т.е. некие большие действия должны хранится и вызываться однотипно чтобы примерно знать где искать что и как работает. Это требование отлично ложится под данный паттерн.
                                                        Второе иметь возможность комбинировать действия между друг другом, а в это моменте проблема так как данный паттерн не позволяет возвращать значения.
                                                        Например:
                                                        Пусть будет 2 варианта генерации рекламной компании, которые мы знаем. Плюс нам известно что скорей всего будет другие типы очень похожие. Я немного упрощу задачу, но попытаюсь объяснить проблемы.
                                                        Тип 1: генерация состоит из нескольких шагов

                                                        Входные данные
                                                        Генерация слов на основе удалённого сервиса(те тут не просто функция из 10-20 строчек тут весьма большой и цельный процесс, где вызывает REST API, фильтрация слов по некому справочнику с использование Apache Solr и тд)
                                                        Создание рекламных блоков например текстовые блоки

                                                        Тип 2:

                                                        Входные данные другой источник
                                                        Обработка данных например выкидывание чего либо
                                                        Генерация слов(тоже самое что и тип 1)
                                                        Создание рекламных блоков например картинок

                                                        Те мы тут имеем одинаковый процесс генерации слов и подобных процессов будет/есть много причём не только связанных с созданием компании.

                                                        Имея команду генерации слов я не как не смогу с ними дальше работать так как нет возвращаемого результат
                                                        Под каждый процесс создавать некий сервис c одной функцией тогда теряется однообразность и возрастает сложность поиска где что хранится типа KeywordGenerator или KeywordManger или KeywordHandler…
                                                        Иметь KeywordService где будет 100500 функций совсем плохо

                                                        Сейчас я сделал некий симбиоз паттерна команда и просто сервисов хранящихся в контейнере те сервисы имеют одну функция у функции один агрумент как в паттерне но есть возможность возвращать значения назвал я эти сервисы Activity :)
                                                        Но как было замечено в статье внутренний перфекционист негодует так как нет чётких ограничение кроме устных и нет единой точки вызова сервисов они ижектятся где нужно и плюс изобретён велосипед паттерна.
                                                          0
                                                          Второе иметь возможность комбинировать действия между друг другом, а в это моменте проблема так как данный паттерн не позволяет возвращать значения.

                                                          Этот паттерн — это по сути слой оркестрации. То есть внутри сервиса хэндлера должны дергаться другие сервисы, которые уже выполняют реальную работу. К примеру у меня большая часть логики находится прямо в сущностях, и команде остается только вызвать один метод сущности, и возможно сделать потом еще какие-то вещи если того требует юзкейс.

                                                          И вот тут самое важное. Надо понимать что такое юзкейс. Команда покрывает один юзкейс целиком о полностью. Это очень удобно когда наша бизнес логика на высоком уровне расписана где-нибудь. Например в дальнейшем если мы будем переходить на микросервисы, мы можем вызывы отдельных сервисов разнести по отдельным микросервисам а хэндлеры команд заменить на имплементацию Saga.

                                                          Собственно в вашем примере у вас будет два хэндлера команды (поскольку это две разные команды), которые будут вызывать методы отдельных сервисов, которые уже будут выполнять свою часть работы.
                                                          Сейчас я сделал некий симбиоз паттерна команда и просто сервисов хранящихся в контейнере

                                                          я уже писал выше на эту тему, это не симбиоз, это просто сервисный слой.
                                                            +1
                                                            Врать не буду, когда я только начал работать с этим паттерном, мне тоже было тесно от этого ограничения — команды ничего не возвращают. [краснея] Я даже навелосипедил свой Command Bus поначалу, который умел возвращать что мне нужно. Но когда я начал его использовать, с опытом пришло понимание и в итоге я выпилил его нафик.

                                                            В Command/Query Separation помимо "Command" есть и "Query". Грубо говоря, "command" — запись, "query" — чтение. Command Bus — это только "commands". В вашем конкретном примере я бы сделал этот генератор как "query", т.е. он ничего никуда не пишет, он просто отдает мне данные (неважно — вычитал он их из базы, или придумал сам). Если же мне нужно сохранять в базу сгенеренные значения — дополнительная команда именно на сохранение.
                                                              0
                                                              Хмм кажется вы представляете о чём я говорю -).
                                                              Есть у вас возможность написать как в коде у вас это выглядит потому что у меня я в проекте как раз имеются Query объекты, но я почему-то был зациклен что они работают с хранилищем.
                                                              Может сделаете пример взаимодействия Command и Query мне кажется это довольно сильно дополнило бы статью?
                                                              Было бы неплохо увидеть два типа Query один для работы с базой, а другой например нечто абстрактное.
                                                              Заранее благодарен.
                                                                0
                                                                Честно говоря, даже не знаю, может написать отдельную статью в качестве продолжения? Общую мысль я выразил в этом комментарии, возможно, этого будет достаточно.

                                                                Сам я в качестве Query просто использую custom repositories, которые оформляю как сервисы. Т.е. например я могу описать для себя интерфейс ProjectRepositoryInterface и реализовать класс, вроде такого:


                                                                    class ProjectRepository extends EntityRepository implements ProjectRepositoryInterface
                                                                    {
                                                                        ...
                                                                    }
                                                                

                                                                в который спрятать все нюансы получения данных из БД. Далее регистрируем репозиторий как сервис:


                                                                    repository.projects:
                                                                        class: Doctrine\ORM\EntityRepository
                                                                        factory: [ "@doctrine.orm.default_entity_manager", "getRepository" ]
                                                                        arguments: [ AppBundle\Entity\Project ]
                                                                

                                                                и спокойно использовать его в контроллерах, или "инжектить" его в качестве зависимости в те команды, которым он необходим.

                                                                Обращаю внимание — используя подобные репозитории вам не придется "инжектить" в команды саму "@doctrine", вместо этого вы "инжектите" только те репозитории, которые там реально нужны. Сильно рекомендую.

                                                                P.S. Кто-нибудь знает, как сделать оформленный код в комментариях? В статье работает, в комментариях — нет. Или у меня просто карма маленькая?
                                                                  0
                                                                  Спасибо понятно, что вы имели в виду. Но я пришёл пока с совместному использованию двух методов для выборки данных.
                                                                  1) Для простых выборок типа get(id) findBySomeThing(condition, start, length) главное что методы в репозиторий супер простые и их количество должно быть минимально, иначе репозиторий разбухнет и его коде можно будет потеряться.
                                                                  2) Как написано в комментарии ниже https://habrahabr.ru/post/280512/#comment_8834588 для сложных запросов использую query объёкты они по структуре практически идентичны командам
                                                                  $query = new SomeQuery();
                                                                  $iterator = $queryRegister->handle($query)
                                                                  единственное отличие от команд, что всегда возвращается итератор
                                                                  Я считаю что использование одних репозитариев приведёт к разбуханию классов но полный отказ от них приведёт к неудобству когда надо найти объект по ид и для этого нужно создавать квери объект.
                                                              0
                                                              $command = new SomeDoCommand($id, ...);
                                                              $commandBus->handle($command);
                                                              
                                                              $query = new SomeModelQuery($id);
                                                              $someModel = $queryBus->handle($query);
                                                                0
                                                                но вообще cqrs обычно подразумевает разные хранилища, поэтому запись и синхронизация с хранилищем для чтения может быть разнесена по времени.
                                                                как вариант: что-то типа identityMap — шина команд туда запишет, шина запросов оттуда прочтет.
                                                                  0
                                                                  обычно подразумевает разные хранилища,

                                                                  Суть как раз в том что бы не думать об этом. Разные хранилища, одно хранилище… Суть в том что бы побочные эффекты write операции превратить в желаемые, а у read операций вообще небыло сайд эффектов. Такой код намного проще скейлить и намного проще тестить.
                                                                  типа identityMap — шина команд туда запишет, шина запросов оттуда прочтет.

                                                                  тут немного странно, скорее шина запросов в рамках этого запроса должна дождаться завершения команды и последующей синхронизации данных между write и read model (если это нужно). Опять же такие вещи нужны в очень малом проценте запросов. Вообще в случае апишек большая часть запросов на запись не должна возвращать вообще ничего.
                                                                    0
                                                                    Вообще в случае апишек большая часть запросов на запись не должна возвращать вообще ничего.
                                                                    Что-то читаю комментарии, а понять не могу.
                                                                    Вы уже второй раз пишете об этом (первый раз не так явно).
                                                                    Создаю я к примеру «пост» по апишке и в ответ получаю «ничего», дальнейшие действия какие? Как мне этот пост теперь получить, если получается что я о нем ничего не знаю?
                                                                      0
                                                                      при создании поста по сути на клиенте есть все необходимые данные, нет только идентификатора ресурса, а его мы можем создать хоть на клиенте хоть в контроллере, не задействуя базу данных. С учетом модного нынче offline-first это более актуально.
                                                                        0
                                                                        Дело в том, что Command Bus все же больше заточен под распределяемые системы, а с распределенностью приходит асинхронность. В асинхронной системе вы не станете ждать завершения команды, вы отдадите ее на исполнение и пойдете дальше заниматься другими делами. Когда команда завершена, вам прилетит сообщение, содержащее необходимую инфу (например, тот самый ID созданного ресурса), которую вы отправите обратно клиенту.

                                                                        Впихивая Command Bus в синхронное CRUD-приложение, приходится отказываться от прямого получения ID созданного ресурса (строго говоря, это единственное, от чего мне пришлось отказаться при переходе на Command Bus). Если я использую UUID (а вам стоит его использовать, если система планируется быть распределенной), то я просто генерирую очередной UUID до вызова команды и передаю его в команду; после успешного завершения команды я возвращаю UUID клиенту. Если у меня автоинкремент, то как правило на соответствующей таблице БД есть другие уникальные индексы (название проекта в примере из статьи, или login только что зарегистрированного пользователя), по которым можно найти только что созданную запись и вернуть ее ID. Хотя это уже немного "костыльно", не спорю.

                                                                        В целом — Command Bus не является единственной панацеей от "толстых" контроллеров и от привязанной к фреймворку бизнес-логики. Это лишь пример подхода. С таким же успехом можно перенести бизнес-логику в сервисы, где вместо хэндлеров команд будут аналогичные функции, и вызывать их вместо "command_bus->handle". Руки никто не выкручивает. ;)
                                                                          0
                                                                          Благодарю, господа. Теперь я понял.
                                                                          Просто как-то привык что генерацией id всегда занимается "третья сторона" (в моем случае всегда субд) и как-то даже мысли не возникло генерить их самому.
                                                                          Стоит наверное подробнее ознакомиться с UUID.
                                                                            0
                                                                            Если что, вот хорошая реализация UUID — ramsey/uuid.
                                                                              0
                                                                              спасибо. Интересно будет код поглядеть.
                                                                                0
                                                                                Мне почему-то больше нравится CUID хотя полноценного исследования какая реализация UUID лучше я пока не проводил и не искал подобных.
                                                                                  0
                                                                                  Тот же Marco Pivetta, видео которого вы мне ранее порекомендовали посмотреть (кстати, посмотрел — спасибо), тоже использует "ramsey/uuid" — http://ocramius.github.io/doctrine-best-practices/#/64.
                                                                                    +3
                                                                                    на самом деле большинство используют rames/uuid только для генерации uuid4, что в принципе удобно, т.к. либа покрывает зоопарк различных криптографических библиотек, сдабривая все это обильным количеством фабрик, конфигураций и прочего.
                                                                                    Но после выхода paragonie/random_compat, который полифилит random_bytes из php7, всю реализацию умещаем в 10 строк — https://github.com/zelenin/ddd-core/blob/master/Infrastructure/Service/IdentityGenerator/RandomUuidGenerator.php
                                                                      0
                                                                      Я немного описал выше что использую некий регистр который по входному квери находит хендлер и выполняет запрос, код идентичен вашему единственное что я решил что всегда возвращаю итератор(может быть конечно любой имплементации)
                                                                      У меня к вам вопрос для чего нужно использовать именно QueryBus? Пока не могу для себя найти что это даёт?
                                                                        0
                                                                        цели те же — инкапсулировать логику работы с хэндлерами.
                                                                    +1
                                                                    По-моему, вы слишком буквально подошли к реализации CQRS. После некоторых экспериментов с SimpleBus я пришёл к выводу, что для веб-приложений (не важно, классических или веб2.0, но без WS или SE, на чистом http) лучше относить Command и Query к уровню доменных сервисов, но не к HTTP API. При этом контроллер имеет право оперировать и тем, и другим в одном обработчике роута.

                                                                    То есть, в контроллер приходит http-запрос, он преобразуется в команду (хоть тупо в самом контроллере установкой свойств команды из параметров запроса, хоть формой, хоть ParamConverter'ом), проходя легкую техническую валидацию (валидный json/xml, правильный формат команды и т. п.), вызывает обработчик команды, который, или создаёт новую сущность, сохраняя её в «репозитории», или достаёт существующую из репозитория и вызывает метод (очень редко несколько), изменяющие её состояние, плюс сервисные штуки типа логирования или генерации событий. Обработчик команды не возвращает результата напрямую (кроме исключений, информирующих об ошибке в обработке команды, в том числе об ошибках бизнес-валидации, но в последнее время и от этого собираюсь уходить, обрабатывая исключения на уровне обработчика и лишь генерируя события о них, а контроллер HTTP API результат будет возвращать анализируя их, исключение, дошедшее до контроллера будет означать что произошла совершенно непредвиденная ошибка в обработке, 500), но контроллер после успешного выполнения команды вызывает соответствующий запрос или запросы (особенно характерно для классических приложений) для получения данных для представления.

                                                                    То есть, нарушение CQRS происходит исключительно на уровне контроллера, служащего фасадом к логике именно для HTTP API и позволяющего минимизировать число запросов от клиента к серверу. Контроллер получает запрос с командой и возвращает результат, например для команд, создающих ресурсы (персистентные сущности, как правило), возвращает, как минимум, Location нового ресурса, а чаще его полное представление, равно как для команд изменения состояния существующих ресурсов возвращает новое представление. HTTP API не является CQRS API, но преобразование http-запросов в последовательность Command и Query происходит исключительно в контроллере (с выносом в stateless сервисы повторяющегося кода), а вот взаимодействие контроллера и остального приложения происходит исключительно согласно CQRS.

                                                                    С таким подходом мы убиваем двух зайцев: модель и остальные сервисы (внутренний API приложения) чётко разделяются на команды и запросы в терминах CQRS (результаты обработки команд, типа id новой сущности идут через EvevntBus как бы от модели, хотя на самом деле от обработчика команды), позволяя легко прикрутить любое новое API на другом транспорте, например, на WebSocket, при этом HTTP API в виде контроллера-фасада к внутреннему API избавляет от множества лишних http-запросов, преобразуя http-запросы в последовательность CQRS Command и Query, руководствуясь спецификой HTTP вообще и логикой работы конкретного Web-based UI.

                                                                    Такой же подход хорошо работает и для CLI — команды на, например, создание сущностей возвращают, как минимум, её id, который с помощью pipe передаётся на дальнейшую обработку или просто выводится пользователю для информации. Тут опять обработчики CLI команд преобразует их в последовательность CQRS Command и Query внутреннего API, руководствуясь спецификой CLI вообще и логикой работы конкретного console-based UI.
                                                                      0
                                                                      То есть, в контроллер приходит http-запрос, он преобразуется в команду (хоть тупо в самом контроллере установкой свойств команды из параметров запроса, хоть формой, хоть ParamConverter'ом), проходя легкую техническую валидацию (валидный json/xml, правильный формат команды и т. п.), вызывает обработчик команды, который, или создаёт новую сущность, сохраняя её в «репозитории», или достаёт существующую из репозитория и вызывает метод (очень редко несколько), изменяющие её состояние, плюс сервисные штуки типа логирования или генерации событий. Обработчик команды не возвращает результата напрямую (кроме исключений, информирующих об ошибке в обработке команды, в том числе об ошибках бизнес-валидации, [...]), но контроллер после успешного выполнения команды вызывает соответствующий запрос или запросы (особенно характерно для классических приложений) для получения данных для представления.

                                                                      Не уловил — в чем буквальность? Вы же по сути слово в слово изложили то, что я и делаю. :) Мне импонирует, что кто-то еще делает также, но непонятно, почему я оказался «буквальнее» вас.
                                                                        0
                                                                        У вас экшены не возвращают по сути результата запроса, если всё хорошо. Сам метод экшена у вас является обёрткой для запуска команды. У меня же экшены типа create и update возвращают новую сущность или новое состояние сущности, выполняя после команды ещё и запрос, передавая в ответе его результат.
                                                                          0
                                                                          Да, пожалуй, пример получился слишком уж «сферическим», но поверьте — я этого не имел в виду. Экшен, реагирующий на запрос создания, и в моем бы исполнении возвращал ID созданного ресурса, но обязательно не из команды, а именно последующим запросом непосредственно в экшене (я даже упоминал об этом вскользь в этом комментарии). Но, сфокусировавшись на «С»-составляющей из CQRS и стремясь максимально упростить код, я действительно опустил это в «newAction» из примера. Только после вашего коммента я понял, что этого не следовало делать — это позволило бы избежать повторяющегося вопроса в комментариях «а что делать, если мне нужно вернуть ID ресурса». Честно говоря, когда я писал статью, я почему-то думал, что мне не нужно объяснять, что такое Command Bus и как его готовить (я, разумеется, не про вас) — целью статьи было лишь поделиться частным опытом конкретной реализации. Был не прав. )
                                                                        0
                                                                        относить Command и Query к уровню доменных сервисов, но не к HTTP API.


                                                                        Команды — это domain layer, это описание юзкейса, его название + входные данные. Хэндлеры команд — это уже application layer. Какой-то сервис, реализующий конкретный юзкейс.

                                                                        А Query — это просто отдельные сервисы (у меня это как правило в репозитории инкапсулировано) которые предоставляют какое-то состояние.

                                                                        С таким разделением у нас HTTP запрос на изменение данных вполне себе может отдавать какой-то результат, просто в контроллере для этого у нас будет два вызова двух разных сервисов. И это замечательно, полное разделение ответственности. В этом плане можно много чего менять не внося изменений в код, а только добавляя новые методы и т.д (обычно UI меняется чуть чаще бизнес логики). Вероятность багов меньше, скорость разработки повышается. А еще тестировать все это намного удобнее.
                                                                        0
                                                                        (промахнулся)
                                                                          +1

                                                                          Насколько я понял, Command не может влиять на состояние CommandBus (например, хендлить какие-то другие команды). Как в таком случае мне быть в таком случае:


                                                                          1. Читаем JSON файл с сервера и парсим (ReadFileFromServerCommand)
                                                                          2. В файле содержатся имена других файлов для парсинга (например next, prev). По идее я должен создать новую команду ReadFileFromServerCommand с новыми аргументами и засунуть ее в CommandBus. Но я не могу из комманды влиять на состояние CommandBus.
                                                                          3. Копать в сторону EventBus или что-то еще?

                                                                          За статью, большое спасибо

                                                                            0
                                                                            Command не может влиять на состояние CommandBus

                                                                            В идеале у CommandBus вообще состояния нет.


                                                                            Как в таком случае мне быть в таком случае:

                                                                            тут два варианта. либо мы ложим информацию о том что надо что-то еще распарсить в очередь, и тогда обработчик очереди сформирует команду и отправит ее на выполнение. Ну или мы тупо дернем метод сервисного слоя, не затрагивая команды, но этот способ плохо масштабируется.

                                                                              0

                                                                              Спасибо. Попробовал оба метода. С очередью реально проще, причем использовал обычную SplQueue

                                                                              0
                                                                              Копать в сторону EventBus или что-то еще?
                                                                              Да, я бы так и делал. Можно просто кидать ивент, мол, вот файл для парсинга; обработчик ивента во время обработки может накидать аналогичных ивентов — своеобразная рекурсия. А инициатором всего этого может быть команда, которая сама по себе вообще ничего не парсит, а лишь кидает первый ивент для парсинга первого файла.
                                                                                +1
                                                                                Как уже заметили, у шины (в принципе любой) в идеале не должно быть состояния, по крайней мере значимого для клиентов в плане возможности или невозможности отдачи той или иной команды в тот или иной момент времени, её задача — маршрутизация команд, а основное состояние — карта команд и их обработчиков, обычно инициализируемая при инстанцировании шины. Так что ничто не мешает из одной команды отдавать другие, включая динамически генерируемые, включая рекурсивные.

                                                                                С другой стороны, через шину имеет смысл отдавать команды абстрактному исполнителю, для избежания захардкоженных связей, в вашем же случае, похоже, что исполнитель всегда конкретный и всё что нужно это рекурсивно вызывать самого себя, возможно даже не формируя собственно команду, если хэндлер команды лишь адаптер для какого-то сервиса. Может быть когда-нибудь вам нужно будет из конкретного обработчика вызывать какой-то абстрактный (например шина будет решать кому отдиспатчить команду в зависимости от её параметров), но пока рекурсивный вызов команды самой по себя выглядит оверинженерингом.

                                                                              Only users with full accounts can post comments. Log in, please.