Symfony и Command Bus

в 7:58, , рубрики: command bus, php, symfony

Уже больше года использую паттерн 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 AppBundleEntity;

use DoctrineORMMapping as ORM;
use SymfonyBridgeDoctrineValidatorConstraints;

/**
 * @ORMTable(name="projects")
 * @ORMEntity
 * @ConstraintsUniqueEntity(fields={"name"}, message="Проект с таким названием уже существует.")
 */
class Project
{
    const MAX_NAME        = 25;
    const MAX_DESCRIPTION = 100;

    /**
     * @var int ID.
     *
     * @ORMColumn(name="id", type="integer")
     * @ORMId
     * @ORMGeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string Название проекта.
     *
     * @ORMColumn(name="name", type="string", length=25)
     */
    private $name;

    /**
     * @var string Описание проекта.
     *
     * @ORMColumn(name="description", type="string", length=100, nullable=true)
     */
    private $description;
}

Форма

namespace AppBundleForm;

use AppBundleEntityProject;
use SymfonyComponentFormAbstractType;
use SymfonyComponentFormExtensionCoreTypeTextType;
use SymfonyComponentFormFormBuilderInterface;
use SymfonyComponentValidatorConstraints;

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 ConstraintsNotBlank(),
                new ConstraintsLength(['max' => Project::MAX_NAME]),
            ],
        ]);

        $builder->add('description', TextType::class, [
            'label'       => 'Описание проекта',
            'required'    => false,
            'attr'        => ['maxlength' => Project::MAX_DESCRIPTION],
            'constraints' => [
                new ConstraintsLength(['max' => Project::MAX_DESCRIPTION]),
            ],
        ]);
    }

    /**
     * {@inheritdoc}
     */
    public function getBlockPrefix()
    {
        return 'project';
    }
}

Контроллер (создание проекта)

namespace AppBundleController;

use AppBundleEntityProject;
use AppBundleFormProjectForm;
use SensioBundleFrameworkExtraBundleConfiguration;
use SymfonyBundleFrameworkBundleControllerController;
use SymfonyComponentHttpFoundationRequest;

class ProjectController extends Controller
{
    /**
     * Отображает страницу с формой, а также обрабатывает ее "сабмит".
     *
     * @ConfigurationRoute("/new")
     * @ConfigurationMethod({"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 AppBundleController;

use AppBundleEntityProject;
use AppBundleFormProjectForm;
use SensioBundleFrameworkExtraBundleConfiguration;
use SymfonyBundleFrameworkBundleControllerController;
use SymfonyComponentHttpFoundationJsonResponse;
use SymfonyComponentHttpFoundationRequest;

class ProjectController extends Controller
{
    /**
     * Возвращает HTML-код формы.
     *
     * @ConfigurationRoute("/new", condition="request.isXmlHttpRequest()")
     * @ConfigurationMethod("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(),
        ]);
    }

    /**
     * Обрабатывает "сабмит" формы.
     *
     * @ConfigurationRoute("/new", name="new_project", condition="request.isXmlHttpRequest()")
     * @ConfigurationMethod("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) }}

Simple Bus

Существует множество реализаций 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 AppBundleSimpleBusProject;

use SymfonyComponentValidatorConstraints;

/**
 * Create new project.
 *
 * @property string $name        Project name.
 * @property string $description Description.
 */
class CreateProjectCommand
{
    /**
     * @ConstraintsNotBlank()
     * @ConstraintsLength(max = "25")
     */
    public $name;

    /**
     * @ConstraintsLength(max = "100")
     */
    public $description;
}

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

namespace AppBundleSimpleBusProjectHandler;

use AppBundleEntityProject;
use AppBundleSimpleBusProjectCreateProjectCommand;
use SymfonyBridgeDoctrineRegistryInterface;
use SymfonyComponentHttpKernelExceptionBadRequestHttpException;
use SymfonyComponentValidatorValidatorValidatorInterface;

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: AppBundleSimpleBusProjectHandlerCreateProjectCommandHandler
        tags: [{ name: command_handler, handles: AppBundleSimpleBusProjectsCreateProjectCommand }]
        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 AppBundleSimpleBusMiddleware;

use PsrLogLoggerInterface;
use SimpleBusMessageBusMiddlewareMessageBusMiddleware;
use SymfonyComponentValidatorValidatorValidatorInterface;

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: AppBundleSimpleBusMiddlewareValidationMiddleware
        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
{
    /**
     * @ConstraintsNotBlank()
     */
    public $id;

    /**
     * @ConstraintsNotBlank()
     * @ConstraintsLength(max = "25")
     */
    public $name;

    /**
     * @ConstraintsLength(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-код формы.
     *
     * @ConfigurationRoute("/edit/{id}", requirements={"id"="d+"}, condition="request.isXmlHttpRequest()")
     * @ConfigurationMethod("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(),
        ]);
    }

    /**
     * Обрабатывает "сабмит" формы.
     *
     * @ConfigurationRoute("/edit/{id}", name="edit_project", requirements={"id"="d+"}, condition="request.isXmlHttpRequest()")
     * @ConfigurationMethod("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-тест наследует от SymfonyBundleFrameworkBundleTestWebTestCase. При этом в любом случае нам больше не нужно использовать Symfony crawler (который, к слову, заметно замедляет тесты), чтобы вызвать тот или иной «экшен». Честно говоря, теперь я порой вообще не покрываю «экшены» тестами, разве что проверяю их на доступность, как рекомендует документация Symfony.

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

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

Автор: arodygin

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js