A
A
Artem Moseev2017-08-30 19:42:46
PHP
Artem Moseev, 2017-08-30 19:42:46

What about events from an aggregate that is used in a higher aggregate?

How to delay the execution of domain events of the aggregate in the aggregate level above?
Below is the domain code "Purchase movie" in the service of selling movies online.
I will describe the original code.
I have a FilmPurchaseService. Which writes off money from the account and gives access to the film. At the same time, an SMS is sent to the client.

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

Everything is great at this stage. The user buys a movie. He receives SMS on the UserPurchaseFilm event.
But the business logic is extended by a feature: the user can subscribe to the service "Give me access to the best movie of the week.". Every 5th film is free. Those. be sure to keep a history of automatic purchases.
I assume that this is a separate business process. This way I will have the "Best Movies" domain with all the search logic. And there will be a "Best Movie Buy" domain as a superstructure on top of the other two "Best Movies" and "Movie Buy". Since the "Buy a movie" domain works well and I don't want to add knowledge about the "Best Movies" domain at all.
<?
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);
        });
    }
}

?>

How is it to be here? "$this->statisticsAutomaticFilmPurchaseService->saveAutomaticFilmPurchaseEvent($user, $film);" may falter.
Then we return the money. But the SMS is already gone! because UserPurchaseFilmEvent has already been submitted!
What about events from an aggregate that is used in a higher aggregate?
I understand that this example is somewhat contrived, and I can get by with one service.
But in real life domains are more complicated.

Answer the question

In order to leave comments, you need to log in

3 answer(s)
P
Peter Gribanov, 2017-09-01
@Fantyk

To begin with, I recommend looking at the library for implementing domain events
https://github.com/gpslab/domain-event I
also recommend that you decide on the terminology. "Best Movie Buy", "Best Movies" and "Movie Buy" are not domains, but Bounded Contexts.
Cannot be an aggregate level higher. You have a misunderstanding of the term Aggregate. And with domain events too most. Domain events should be thrown in entities, not application layer services.
Further. You write off the balance from the user's account, then give him access to the movie and then throw the event and do all this sequentially, and even wrap the transaction in the database, although this is a business transaction.
Business logic should be all the same at the level of the subject area.
Anyway, back to your problem.
> the user can subscribe to the service "Give me access to the best movie of the week"
You have clearly described the concept of the Service. And you have two, let's say, services:
- Best films of the week
- Purchase of one film
From here you get:

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

A simple and visual way to purchase a service.
Receiving money from a client via SMS is encapsulated in UserAccount.
If several minutes must pass between the purchase and access to the service, you can introduce the concept -
Activation of the ordered service.
That is, the user buys / orders a service and perhaps even sees it in his personal account, but the activation of the service and, accordingly, access to it is performed only after the receipt of funds from the client via SMS or something else.

F
Fortop, 2017-08-30
@Fortop

Pass each domain its own event queue instance
In this case, the top-level aggregate will pass the queue to which no one subscribes to the filmPurchaseService instance. And it will already decide whether to forward the events collected by it in this queue, higher or not.
PS Well, it is absolutely incomprehensible why it is necessary to cancel the purchase of the film only because statisticsService has fallen off for you?

A
Artem Moseev, 2017-08-31
@Fantyk

While the solution is (taking into account tons of legacy): During the transaction in the database, I start to accumulate messages, I do not send them to the queue. When committing to the database, I send all the accumulated messages to the queue (if I fail, I clean the queue).

Didn't find what you were looking for?

Ask your question

Ask a Question

731 491 924 answers to any question