Давайте представим ситуацию: у вас есть заказ в интернет магазине (Entity). Заказ имеет некий статус. При смене статуса заказа необходимо провести кучу сопутствующих действий, например:


  • сохранить в заказе дату последнего изменения
  • записать в историю по заказу информацию о смене статуса
  • отослать письмо / sms клиенту
  • вызвать метод API службы доставки / платежной системы / партнера и т.д.

Возникает вопрос как все это правильно организовать с точки зрения программного кода.
Все ниже описанное справедливо для Doctrine 2 и Symfony > 3.1


Если вы не знакомы с событийной моделью Doctrine, то сначала рекомендую ознакомиться с документацией.


Приведу пример простейшего кода для Entity заказа:


Код Entity заказа
/**
 * Order
 *
 * @ORM\Table(name="order")
 */
class Order
{
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(name="fio", type="string", length=100)
     */
    private $fio;

    /**
     * @ORM\Column(name="last_update_date", type="datetime")
     */
    private $lastUpdateDate;

    /**
     * @ORM\Column(name="create_date", type="datetime")
     */
    private $createDate;

    /**
     * @ORM\Column(name="status_id", type="integer")
     */
    private $statusId;

    // дальше getter/setter методы
}

Начнем с самого простого — нам нужно, чтобы при создании заказа, в поле create_date была записана дата создания, а при любом изменении заказа, в поле last_update_date, дата последнего изменения.


Самое простое — это явно добавить параметры в том месте, где заказ создается и обновляется (в контроллере или специальном сервисе).


$order = new Order();
$order->setCreateDate(new \DateTime());
$order->setLastUpdateDate(new \DateTime());
// ....
$em->persist($order);
$em->flush();

Минусы такого подхода очевидны — если заказ создается, а тем более, обновляется в нескольких местах — нужно будет в каждом месте повторять эту логику. К счастью Doctrine содержит в себе обработку событий (LifecycleEvents).


Добавляем в описание Entity конструкцию, которая говорит Doctrine, что Entity содержит в себе некие события, которые нужно обработать:


/**
* @ORM\HasLifecycleCallbacks()
*/

и создаем методы, которые будут "реагировать" на эти события. В нашем случае будут два метода:


/**
*  @ORM\PrePersist
*/
public function setCreateDate()
{
     $this->createDate = new \DateTime();
}
/**
*  @ORM\PreFlush
*/
public function setLastUpdateDate()
{
     $this->lastUpdateDate = new \DateTime();
}

@ORM\PrePersist и @ORM\PreFlush говорят Doctrine выполнить соответствующие методы соответственно при создании Entity и при каждом ее обновлении. Теперь нет нужды отдельно устанавливать эти даты. Полный список возможных событий можно посмотреть здесь


Текущий вид Entity заказа
/**
 * Order
 *
 * @ORM\Table(name="order")
 * @ORM\HasLifecycleCallbacks()
 */
class Order
{
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(name="fio", type="string", length=100)
     */
    private $fio;

    /**
     * @ORM\Column(name="last_update_date", type="datetime")
     */
    private $lastUpdateDate;

    /**
     * @ORM\Column(name="create_date", type="datetime")
     */
    private $createDate;

    /**
     * @ORM\Column(name="status_id", type="integer")
     */
    private $statusId;

    // дальше getter/setter методы

    /**
     *  @ORM\PrePersist
     */
    public function setCreateDate()
    {
        $this->createDate = new \DateTime();
    }
    /**
     *  @ORM\PreFlush
     */
    public function setLastUpdateDate()
    {
        $this->lastUpdateDate = new \DateTime();
    }
}

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


OrderHistory: Entity записи в истории по заказу
/**
 * OrderHistory
 *
 * @ORM\Table(name="order_status_history")
 * @ORM\HasLifecycleCallbacks()
 */
class OrderHistory
{
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(name="order_id", type="integer")
     */
    private $orderId;

    /**
     * @ORM\Column(name="manager_id", type="integer")
     */
    private $managerId;

    /**
     * @ORM\Column(name="status_id", type="integer")
     */
    private $statusId;

    /**
     * @ORM\ManyToOne(targetEntity="OrderStatus")
     * @ORM\JoinColumn(name="status_id", referencedColumnName="id")
     */
    private $status;

    /**
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\Manager")
     * @ORM\JoinColumn(name="manager_id", referencedColumnName="id")
     */
    private $manager;

    /**
     * @ORM\ManyToOne(targetEntity="Order", inversedBy="orderHistory")
     * @ORM\JoinColumn(name="order_id", referencedColumnName="id")
     */
    private $order;    

    // дальше getter/setter методы

    /**
     * @ORM\Column(name="create_date", type="datetime")
     */
    private $createDate;

    /**
     *  @ORM\PrePersist
     */
    public function setCreateDate()
    {
        $this->createDate = new \DateTime();
    }
}

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


Для этого в Doctrine есть EntityListeners — класс, который отслеживает изменения; место, где можно держать всю логику обработки событий.


Есть два варианта: либо мы добавляем обработчик событий на уровне описания Entity:


/**
* @ORM\EntityListeners({"AppBundle\EntityListeners\OrderListener"})
*/

И создаем класс Listener-а


class OrderHistoryListener
{
    public function postUpdate(Order $order, LifecycleEventArgs $event)
    {
         // some code
    }
}

Первый параметр — ссылка на объект, в котором произошли события. Второй — это объект события (о нем мы поговорим ниже).


Либо,


  • у нас много логики, которая реагирует на события, мы хотим разнести ее по разным классам
  • EntityListener должен реагировать не только на события конкретного класса (например одинаковое письмо отсылаем по событиям нескольких видов Entity)

можно зарегистрировать обработчики через стандартные сервисы Symfony:


services:
  order.history.status.listener:
        class: AppBundle\EntityListeners\OrderListener
        tags:
            - { name: doctrine.event_listener, event: preUpdate, method: preUpdate }
            - { name: doctrine.event_listener, event: prePersist, method: prePersist }

Параметр event определяет событие, на которое будет вызван данный сервис, method — определяет конкретный метод, внутри сервиса. Т.е. сервис может быть один, но обрабатывать разные события для разных Entity.


В этом случае Listener будет реагировать на события вообще любого Entity и внутри класса нужно будет проверять тип объекта.


class OrderHistoryListener
{
    public function preUpdate(PreUpdateEventArgs $event)
    {
        if ($event->getEntity() instanceof Order) {

        }
    }
}

EntityListener может содержать различные методы (handlers), в зависимости от того, на какое событие мы хотим получить реакцию.


Объект $event уже содержит в себе ссылки на EntityManager и на UnitOfWork. Соответственно уже есть все, чтобы работать с объектами Doctrine. Вы можете вытаскивать необходимые объекты, обновлять и удалять их.


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


В первом случае мы создаем запись вида, которая внедрит зависимости в EntityListener


services:
    app.doctrine.listener.order:
        class: AppBundle\EntityListeners\OrderListener
        public: false
        arguments: ["@mailer", "@security.token_storage"]
        tags:
            - { name: "doctrine.orm.entity_listener" }

Во втором, просто добавляем строку с зависимостями


services:
  order.history.status.listener:
        class: AppBundle\EntityListeners\OrderListener
        arguments: ["@mailer", "@security.token_storage"]
        tags:
            - { name: doctrine.event_listener, event: preUpdate, method: preUpdate }
            - { name: doctrine.event_listener, event: prePersist, method: prePersist }

Дальше все как с обычным Symfony-сервисом.


Внутри Listener можно получить проверку на то, изменилось ли поле, а также получить текущее и предыдущее значения.


if ($event->hasChangedField('status_id')) {
    $oldValue = $event->getOldValue('status_id');
    $newValue = $event->getNewValue('status_id');
}

Окончательный вид Entity заказа
/**
 * Order
 *
 * @ORM\Table(name="order")
 * @ORM\EntityListeners({"AppBundle\EntityListeners\OrderListener"})
 * @ORM\HasLifecycleCallbacks()
 */
class Order
{
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(name="fio", type="string", length=100)
     */
    private $fio;

    /**
     * @ORM\Column(name="last_update_date", type="datetime")
     */
    private $lastUpdateDate;

    /**
     * @ORM\Column(name="create_date", type="datetime")
     */
    private $createDate;

    /**
     * @ORM\Column(name="status_id", type="integer")
     */
    private $statusId;

    // дальше getter/setter методы

    /**
     *  @ORM\PrePersist
     */
    public function setCreateDate()
    {
        $this->createDate = new \DateTime();
    }
    /**
     *  @ORM\PreFlush
     */
    public function setLastUpdateDate()
    {
        $this->lastUpdateDate = new \DateTime();
    }
}

Код OrderListener
class OrderListener {

    private
        $_securityContext = null,
        $_mailer = null;

    public function __construct(\SwiftMailer $mailer, TokenStorage $securityContext)
    {
        $this->_mailer = $mailer;
        $this->_securityContext = $securityContext;
    }

    public function postUpdate(Order $order, LifecycleEventArgs $event)
    {
        $em = $event->getEntityManager();

        if ($event->hasChangedField('status_id')) {

            $status = $em->getRepository('AppBundle:OrderStatus')->find($event->getNewValue('status_id'));

            $history = new OrderHistory();
            $history->setManager($this->_securityContext->getToken()->getUser());
            $history->setStatus($status);
            $history->setOrder($order);

            $em->persist($history);
            $em->flush();

            // код для отправки письма с помощью SwiftMailer

        }
    }
}

Комментарии (49)


  1. malinichev
    09.10.2017 00:01

    Интересно) Надо будет на Yii2 попробовать такое разделение и автоматизацию…


    1. Botchal
      09.10.2017 01:48

      На Yii2 это можно решить поведениями в любом наследнике yii\base\Component. Практически один в один стандартные события как в Lifecycle Events. Что Вы имели в виду под разделением?


      1. malinichev
        09.10.2017 12:11
        -1

        Та достался мне тут один проект на поддержку, где модель служит только для запросов к базе данных… Вся бизнес-логика в контроллерах и вьюхах(
        Теперь переписываю, боясь что-то сломать.
        Просто понравилась реализация истории заказа, я наверное не додумался бы до такого)


        1. Botchal
          09.10.2017 15:36

          На самом деле самая тривиальная проблема мейнтейнить рабочий проект, в котором вот такое как Вы сказали. Самим часто достаётся подобное. Вы написали про разделение, в Yii2 поведениями решается всё, от трансформации атрибутов модели до запусков бизнес логики. Поэтому у нас в компании действует правило, мы не пишем бизнес логику в поведениях. Та часть процессов, запуск которых связан с изменением AR мы явно вызываем в переопределённых методах insert(), update(), delete().

          public function insert($runValidation = true, $attributes = null)
          {
              Yii::$app->MoexOperation->clearing($this);
              return parent::insert($runValidation, $attributes);
          }
          

          Ничего поэтичного зато выделено. А вы как разделяете?


          1. malinichev
            09.10.2017 16:09
            -1

            Я сделал trait, который берёт из свойств $beforeSave и $afterSave массив функций(с ключом приоритета), и вызывает их в аналогичных методах с приоритетом


  1. pudovMaxim
    09.10.2017 08:25

    А что если при событии необходимо изменять (и сильно) другие сущности? Есть какой-то путь для этого?


    1. DVLev Автор
      09.10.2017 09:02

      Если я правильно понял вопрос, то для Вашего случая подходят EntityListener (описаны в статье). Внутри LifecycleEventArgs, который передается в качестве события во внутрь EntityListener, есть ссылка на EntityManager, через который, в свою очередь, через getRepository() Вы можете получить доступ к любому объекту, а также к его изменению.


    1. dizzy7
      09.10.2017 12:18
      +1

      Делать это на событиях доктрины — очень плохая практика. docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/events.html#implementing-event-listeners — здесь подробно описаны ограничения- что можно и чего нельзя делать в каждом конкретном событии, но даже после досконального изучения можно словить очень нетривиальные ошибки.


      1. pudovMaxim
        09.10.2017 12:28

        В том-то и беда, что практика уже была :) Столкнулись со всеми прелестями после обновления доктрины, поэтому и нужны пути обхода.


        1. dizzy7
          09.10.2017 12:43

          Мы пришли к использованию своих событий через dispatcher, это даёт больше контроля и снимает привязку к доктрине.


  1. VlastV
    09.10.2017 08:58
    +4

    Don't use Lifecycle Callbacks for Business Logic/Events by @ocramius


    1. DVLev Автор
      09.10.2017 09:07
      +1

      Никто и не использует Lifecycle Callbacks для бизнес логики. Вряд ли установку текущей даты внутри Entity можно отнести к бизнес-логике.


      1. lexxpavlov
        09.10.2017 11:13
        +1

        А к чему же отнести, как не к бизнес-логике? Она, самая. Информация о изменении заказов нужна менеджеру, нужна бизнес-аналитику.


        1. godzie
          09.10.2017 12:17

          Ну, между прочим, в презентации выше сказано что установка даты изменения — это ок.


          1. Fesor
            09.10.2017 12:18

            Но формирование аудита или лога изменений — уже не ок.


            1. DVLev Автор
              09.10.2017 12:44

              Вы говорите про LifecycleCallbacks (в котором бизнес логике быть не должно) или про внешние EntityListener / DoctrineEventListener, в которых формирование аудита или формирование лога изменений (особенно если этот лог не связан с БД в которой идут изменения, например лог пишется в файл) вполне ОК?

              Вот посмотрите пример на самом doctrine
              docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/events.html#preupdate

              public function preUpdate(PreUpdateEventArgs $eventArgs)
                  {
                      if ($eventArgs->getEntity() instanceof Account) {
                          if ($eventArgs->hasChangedField('creditCard')) {
                              $this->validateCreditCard($eventArgs->getNewValue('creditCard'));
                          }
                      }
                  }
              


              Валидация номера кредитной карты — это вполне себе бизнес логика.


              1. Fesor
                09.10.2017 13:10

                вполне ОК?

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


                Вот посмотрите пример на самом doctrine

                Увы документация к доктрине далеко не соответствует их же лучшим практикам. Ребята вроде Окромиуса это довольно часто говорят и это один из основных пунктов к "переписать" для 3-ей версии.


                Валидация номера кредитной карты — это вполне себе бизнес логика.

                если вы посмотрите doctrine best practice то вы увидите что этот пример не имеет смысла. так как у вас никогда не должно быть невалидного состояния в сущностях. Подобная валидация должна происходить еще на подходе к сущности (в крайнем случае в методе который меняет стэйт). Поменять стэйт а потом разбираться что пошло не так — не очень корректный вариант ведения дел.


            1. godzie
              09.10.2017 13:15

              Да, не ок. Ну и наверно в контексте заказа дата изменений тоже относится к бизнес логики…


      1. 5mbytes
        09.10.2017 11:19
        +2

        Сохранение OrderHistory это бизнес-логика. Да даже сохранение текущей даты, это бизнес-логика.


  1. fredrsf
    09.10.2017 11:38
    +1

    Лучше использовать доменные ивенты, без привязки к доктрине. Пример реализации от Fesor github.com/fesor/domain-events


    1. Fesor
      09.10.2017 12:09

      Отдельно распишу преимущества:


      • доменные ивенты проще в отладке
      • доменные ивенты по хорошему должны запускаться когда все хорошо, то есть мы можем скажем по postFlush их обработать и мы точно знаем что транзакция была успешна
      • доменные ивенты позволяют переходить на более сложные вещи вроде саг. Когда одна логическая транзакция должна либо инициировать другую либо, в случае если что-то пощло не так, компенсировать предыдущие транзакции. Пример. Первая транзакция создала заказ со статусом pending, это привело к доменному ивенту OrderSubmitted например. По этому ивенту кто-то запускает новую логическую транзакцию например для проведения оплаты. Если оплата не удалась, выкидываем событие PaymentFailed, по которому мы меняем статус заказа на failed.

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


      1. Fantyk
        09.10.2017 12:41

        Столкнулся с такой задачей:
        Происходят несколько событий, которые должны привести к единоразовому выполнению subscriber.
        Пример: вы создали пользователя, дальше меняете ему дату рождения, дальше вы меняете его прописку (привожу примеры атомарных операций, где логично на каждую операцию иметь отдельный метод. то есть не весь апдейт производить в одном методе).
        Дальше у вас задача: на все изменения данных клиента отсылать ему СМС

        $user = new User($email,$password); // создаем eventUserCreated
        $user->changeShippingAddress($address) // создаем eventAddressChanged
        $user->changeLegal($lega) //создаем eventLegalChanged.
        


        Любой из eventUserCreated, eventAddressChanged, eventLegalChanged должен вызывать отправку смс.
        Как вы предлагаете решать задачу, когда СМС должна уходить только одна (после последнего изменения)?

        UPD. Это для AR, где save() может вызываться не один раз.


        1. Fesor
          09.10.2017 13:18

          Для того что бы более-менее корректно вам что-то посоветовать мне нужно лучше понимать вашу бизнес логику. Потому несколько уточняющих вопросов:


          • Пользователь может существовать с пустыми значениями для даты рождения, адресов прописки и т.д.? Или это обязательная информация для регистрации?
          • Эти данные меняются по отдельности или вместе? Или иногда по отдельности и иногда вместе? Приведите примеры.
          • Имеет ли смысл событие AddressChanged если это по сути инициализация значения?


          1. Fantyk
            09.10.2017 13:41

            Пользователя я привел в качестве примера, чтобы не расписывать тут бизнес логику на лист А4.
            Но продолжим с пользователем:
            Пользователю для регистрации в магазине достаточно заполнить email.
            Дальше он может в ЛК указать ФИО, дату рождения. Если он захочет заказать посылку — он может указать адрес, если адрес не указан — с ним свяжется менеджер и заполнит его данные в админке.
            То есть существование отдельных методов на указание адреса, даты рождения отдельно от регистрации оправданно и существует.

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

            Допустим, такой вариант кода:

            <?php
            
            class User
            {
                public function changeInfo($userInfo)
                {
                    ....
                    $this->rememberThat(new UserChangeInfo($this));
                }
            
                public function changeAddress($adress)
                {
                    ....
                    $this->rememberThat(new UserChangeAdress($adress));
                }
            
                public function changeUserAll($userInfo, $adress)
                {
                    $this->changeInfo($userInfo)
                    $this->changeAddress($adress);
                }
            }
            


            Наш subscriber подписан на UserChangeInfo, UserChangeAdress.
            Пока в коде проблема — при вызове changeUserAll метод subscriber будет вызван 2 раза.


            1. oxidmod
              09.10.2017 14:15

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


              1. BoShurik
                09.10.2017 15:10

                kernel.terminate не вызывается в консоле. Конкретно в этом случае это не важно, но если, к примеру, данные будут обновляться по крону из какой-нибудь API, то как тогда?


                1. oxidmod
                  09.10.2017 15:30

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


                1. ghost404
                  12.10.2017 12:24

                  если что, кроме kernel.terminate есть еще console.terminate (пруф)


                  1. Fesor
                    12.10.2017 13:18

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


                    Вообще подвязывать подобную логику на такие вот события — наверное не ок. Хотя вариант, скажем, агрегации ивентов по времени (объеденять на уровне консюмера все ивенты от одного и того же агрегата, что-то типа throttle/debounce) тоже не шибко хорошая идея, хотя с хорошим брокером сообщений и хитрой маршрутизацией какой может и норм.


            1. TrogWarZ
              09.10.2017 16:38

              А как выглядит у вас событие, например, UserChangeInfo? И где вызывается changeUserAll?


              1. Fantyk
                09.10.2017 17:28

                Ну допустим new UserChangeInfo($user, array $infoOld, array $infoNew).
                changeUserAll — вызываю в контроллере настроек пользователя.
                changeAddress, changeInfo — могу вызвать и в админке менеджеров, и в контроллере настроек пользователя.


            1. ghost404
              09.10.2017 21:27
              +1

              Я в это случае создаю CQRS команду на отправку SMS уведомления на каждое событие и кладу ее в очередь уникальных команд и по крону разбираю очередь.
              Это даёт нам и сохранение уникальности уведомлений и позволяет не тормозить клиент на отправку SMS и контролировать нагрузку на сервер отправки SMS.


              1. Fantyk
                09.10.2017 23:50

                Понятно, смс не подходящий пример так как можно отправлять через очередь.
                Давайте на примере заказов:
                Пользователь создал заказ:
                1) Нужно проверить проверить была ли ссылка на оплату реферальной, если была — пользователю «рефералу» зачислить бонусные баллы.
                2) При первом создании заказа начислить пользователю бонусные баллы.
                3) После оплаты заказа начислить пользователю бонусные баллы, если он оплатил товар акционной категории.

                Пользователь может оплатить спустя время (после подтверждения наличия товара на складе), а может оплатить сразу же (например покупка электронной книги).

                Так у нас будет два события:
                — заказ создан
                — заказ оплачен
                После каждого из них мы должны пересчитать бонусные баллы.
                И есть три метода:
                — создать заказ
                — оплатить заказ
                — создать заказ и оплатить его (внутри последовательно вызываем «создать заказ» и «оплатить заказ»)

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

                Сам я как раз сделал «уникальные команды», но в рантайме, да еще и с приоритетами (сначала обрабатываем события непосредственно оплаты, потом события бонусной программы, потом можем и СМС послать)

                Хочется услышать об этом мнения, может быть ссылки на известные реализации и статьи.


                1. Fesor
                  10.10.2017 01:35

                  После каждого из них мы должны пересчитать бонусные баллы.

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


                  ну то есть, я может быть плохо понял задачу


                  1) Нужно проверить проверить была ли ссылка на оплату реферальной, если была — пользователю «рефералу» зачислить бонусные баллы.

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


                1. ghost404
                  10.10.2017 10:15

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


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


    1. ghost404
      09.10.2017 20:54

      Добавлю и свою либу
      https://github.com/gpslab/domain-event
      бандл для интеграции с Symfony
      https://github.com/gpslab/domain-event-bundle


      Кроме стандартного агрегирования событий ещё позволяет обрабатывать события в самой сущности, имеет функционал для реализации слушателей, подписчиков, очередей и middleware


      1. Fesor
        09.10.2017 23:23

        позволяет обрабатывать события в самой сущности

        Это для воспроизведения событий? ну мол event sourcing?


        1. ghost404
          10.10.2017 09:59

          Да. И не только. Например для соблюдения SRP, часть кода можно вынести в обработчик событий, но чтоб не размазывать бизнес логику по проекту, её можно сохранить в сущности.
          Например, тот же пример от Fantyk можно решить через обработку событий в сущности заказа.


          1. Fesor
            10.10.2017 13:19

            Пересчет бонусов в сущности заказа?


            1. ghost404
              10.10.2017 20:13

              Скорей: начисление бонусов рефералу за создание заказа.
              Пересчет делается в сущности реферала, а вот инициировать процедуру начисления бонусов можно через заказ.


              1. Fesor
                11.10.2017 00:11
                +1

                udidahan.com/2009/06/29/dont-create-aggregate-roots/ — вот тут описан схожий пример, к слову.


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

                У меня подобное подвешено на события от заказа, в частности когда заказ становится оплаченным.


                1. ghost404
                  11.10.2017 10:17
                  +1

                  У меня подобное подвешено на события от заказа, в частности когда заказ становится оплаченным.

                  Если событие обрабатывается в сущности заказа, как в моей библиотеке, то все гуд. Я это и предлагал.
                  А вот если вы начисляете бонусы за оплаченный заказ в отдельном сервисе слушателе, то это не есть хорошо. Это пример размазывания бизнес логики по проекту, что часто встречается при использовании событийно-ориентированного подхода.


                  1. Fesor
                    11.10.2017 14:37

                    фактически ивент тригирит следующую "команду", начислить бонусы, которая относится уже к другому контексту. Так что вроде бы все хорошо.


                    1. ghost404
                      12.10.2017 09:55

                      Соглашусь. Если какое-то действие в сущности одного контекста должно стригерить действие в сущности другого контекста, то конечно логично делать это через доменные события.


  1. Anrki
    09.10.2017 12:26
    +2

    Symfony — это же event-based framework, кастомные ивенты для бизнес логики приложения вам в помощь: symfony.com/doc/current/event_dispatcher.html
    Ну максимум в доктриновский листенер можно положить время изменения записи, но отправлку письма — это уже переборп.


    1. DVLev Автор
      09.10.2017 12:36
      -1

      Вы также внутри EntityListener можете использовать обычные Event через EventDispatcher. Т.е. не реализовывать логику отсылки письма внутри Listener, а вынести ее во вне.
      В статье подход упрощенный и акцент сделан не на конкретную реализацию, а на общий принцип.


  1. dizzy7
    09.10.2017 12:44
    +1

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


    1. Fesor
      09.10.2017 13:19

      и это будет весьма неплохой источник тупых багов.


    1. to0n1
      09.10.2017 15:29

      Сейчас же есть entity_listener'ы они привязываются к конкретной сущности