@Fantyk
web developer

Как быть с событиями из агрегата, который используется в агрегате уровнем выше?

Как откладывать выполнение доменных событий агрегата в агрегате уровнем выше?
Ниже приведен код домена "Покупка фильма" в сервисе продажи фильмов онлайн.
Опишу изначальный код.
У меня есть сервис FilmPurchaseService. Который списывает деньги со счета и дает доступ к фильму. При этом клиенту шлется смс.

<?php

trait HasEventsTrait
{
    private $domainEvents = [];

    public function registerEvent($event)
    {
        $this->domainEvents[] = $event;
    }

    public function releaseEvents()
    {
        $events = $this->domainEvents;
        $this->domainEvents = [];

        return $events;
    }
}


class FilmUserService
{
    public function __construct($entityManager)
    {
        $this->entityManager = $entityManager;
    }

    public function giveUserAccessToFilm(Film $film, User $user)
    {
        $filmUserLink = new FilmUserLink($film, $user);
        $this->entityManager->persist($filmUserLink);
    }
}


class FilmPurchaseService
{
    use HasEventsTrait;

    public function __construct(EventDispatcher $dispatcher, FilmUserAccessService $filmUserService, FilmCostCalculator $filmCostCalculator, UserBalanceCharger $userBalanceCharger)
    {
        $this->dispatcher = $dispatcher;
        $this->filmUserAccessService = $filmUserService;
        $this->filmCostCalculator = $filmCostCalculator;
        $this->userBalanceCharger = $userBalanceCharger;
    }

    public function purchaseFilm(Film $film, User $user)
    {
        $cost = $this->filmCostCalculator->getCostFilm($film, $user);

        DB::transaction(function () use($film, $user, $cost) {
            $this->userBalanceCharger->chargeUser($cost);
            $this->filmUserAccessService->giveUserAccessToFilm($film, $user);
            $this->registerEvent(UserPurchaseFilmEvent::class);
        });

        foreach ($this->releaseEvents() as $event) {
            $this->dispather->dispath($event);
        }
    }
}

class PurchaseFilmController
{
    public function __construct(UserAuthService $userAuthService, FilmPurchaseService $filmPurchaseService)
    {
        $this->userAuthService = $userAuthService;
        $this->filmPurchaseService = $filmPurchaseService;
    }

    public function __invoke(Film $film)
    {
        $user = $this->userAuthService->getUser();
        $this->filmPurchaseService->purchaseFilm($film, $user);
        return accept();
    }
}
?>


На этом этапе все отлично. Пользователь покупает фильм. Ему уходит смс по событию UserPurchaseFilm.
Но бизнес логика расширяется фичей: пользователь может подписаться на услугу "Дайте мне доступ к лучшему фильму недели.". Каждый 5 фильм бесплатно. Т.е. обязательно вести историю автоматических покупок.

Я предполагаю, что это отдельный бизнес процесс. Таким образом у меня появится домен "Лучшие фильмы" со всей логикой поиска. И еще появится домен "Покупка лучшего фильма" как надстройка над двумя другими "Лучшие фильмы" и "Покупка фильма". Так как домен "Покупка фильма" работает хорошо и совсем не хочется добавлять знания о домене "Лучшие фильмы".

<?
class AutomaticFilmPurchaseService
{
    use HasEventsTrait;

    public function __construct(FilmPurchaseService $filmPurchaseService, BestFilmFinder $bestFilmFinder, StatisticsAutomaticFilmPurchaseService $statisticsService)
    {
        $this->bestFilmFinder = $bestFilmFinder;
        $this->filmPurchaseService = $filmPurchaseService;
        $this->statisticsAutomaticFilmPurchaseService = $statisticsService;
    }

    public function purchaseBestFillm(User $user)
    {
        $film = $this->bestFilmFinder->find();
        DB::transaction(function () use($film, $user) {
            $this->filmPurchaseService->purchaseFilm($film, $user);

            //вот здесь все может свалиться, а СМС уже отправлена!
            $this->statisticsAutomaticFilmPurchaseService->saveAutomaticFilmPurchaseEvent($user, $film);
        });
    }
}

?>


Вот как здесь быть? "$this->statisticsAutomaticFilmPurchaseService->saveAutomaticFilmPurchaseEvent($user, $film);" может сфейлится.
Тогда деньги возвращаем. Но смс уже ушла! т.к. UserPurchaseFilmEvent уже передан на выполнение!
Как быть с событиями из агрегата, который используется в агрегате уровнем выше?
Я понимаю что этот пример несколько надуман, и я могу обойтись одним сервисом.
Но в реальной жизни домены сложнее.
  • Вопрос задан
  • 328 просмотров
Решения вопроса 1
ghost404
@ghost404
PHP Developer
Для начала рекомендую глянуть библиотеку для реализации доменных событий
https://github.com/gpslab/domain-event

Еще рекомендую определится с терминологией. "Покупка лучшего фильма", "Лучшие фильмы" и "Покупка фильма" это не домены, а ограниченные контексты (Bounded Context).

Не может быть агрегата уровнем выше. У вас неправильное понимание термина Агрегат. И с доменными событиями тоже самое. Доменные события должны бросаться в сущностях, а не сервисах прикладного уровня.

Далее. Вы списываете баланс со счета пользователя, потом выдаёте ему доступ к фильму и потом бросаете событие и все это делаете последовательно, да ещё и заварачиваете в БД транзакцию, хотя это бизнес транзакция.

Бизнес логика должна быть все таки на уровне предметной области.

И все же вернёмся к вашей проблеме.
> пользователь может подписаться на услугу "Дайте мне доступ к лучшему фильму недели"

Вы явно описали понятие Услуга. И у вас есть две, скажем так, услуги:
- Лучшие фильмы недели
- Покупка одного фильма

Отсюда у вас появляется:
interface Service
{
    public function price(): Money;

    // ...
}

class OneFilmService implements Service
{
    public function __construct(Film $film)
    {
        // ...
    }

   // ...
}

class BestFilmsService implements Service
{
    // ...
}

class User
{
   // ...

    public function buyService(
        AccountRepository $repository,
        Service $service
    ) {
        // получаем текущий счёт пользователя
        // и выполняем покупку
        $repository
            ->get($this->id)
            ->buy($service->price())
        ;

        // добавляем пользователю
        // преобретенную услугу
        $this->services[] = $service;

        // бросаем доменное событие
        $this->raise(new ServicePurchased(
            $this->id,
            $service->id()
        );
    }
}

Простой и наглядный способ покупки услуги.

Получение денег от клиента по средствам СМС инкапсулированно в UserAccount.

Если между покупкой и получением доступа к услуге должно пройти несколько минут, можно ввести понятие -
Активация заказанной услуги.

То есть, пользователь покупает/заказывает услугу и возможно даже видит ее в своём личном кабинете, но активация услуги и соответственно доступ к ней выполняется только после поступления средств от клиента через СМС или ещё как.
Ответ написан
Пригласить эксперта
Ответы на вопрос 2
@Fortop
Tech/Team lead
Передавать каждому домену свой инстанс очереди событий

В этом случае агрегат верхнего уровня инстансу filmPurchaseService передаст ту очередь на которую никто не подписан. И уже он будет решать - пробрасывать ли события, собранные им в эту очередь, выше или нет.

P.S. Ну и абсолютно непонятно почему надо отменять покупку фильма только из-за того что у вас отвалился statisticsService?
Ответ написан
Комментировать
@Fantyk Автор вопроса
web developer
Пока решение такое (с учетом тонн легаси) : Во время транзакции в БД начинаю накапливать сообщения, в очередь не посылаю. При коммите в БД все накопленные сообщения посылаю в очередь (при фейле чищу очередь).
Ответ написан
Комментировать
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Похожие вопросы