Zdarzenia domenowe w agregatach DDD

zdarzenia domenowe diagram

Zdarzenie domenowe jest jedną z podstawowych jednostek Domain Driven Design. Samo zdarzenie to fakt, który można umiejscowić w określonym czasie. W programowaniu istnieją różne typy zdarzeń, a jednym z możliwych jest właśnie domenowe. Deaktywowano dostęp, zarejestrowano płatność, czy utworzono spotkanie to kilka przykładów tego rodzaju bytów.

Zdarzenie domenowe emitowane jest przez agregat (powiązany zbiór obiektów połączonych logiką i regułami biznesowymi). Jest ono jawnie wyrażonym skutkiem ubocznym realizowanych operacji. Służy do komunikacji w ramach tej samej domeny. Ostatnie zdanie to bardzo ważna informacja. Sugeruje, że nie powinny one być publikowane na zewnątrz modułu. Używam terminologii modułu, żeby nie wprowadzać kolejnych pojęć, jak chociażby kontekst ograniczony, ale dla osób bardziej zaawansowanych w temacie DDD, taki termin jest bardziej adekwatny.

Nie jest tak, że zdarzeń domenowych nie można wykorzystać do komunikacji między modułami, czy mikroserwisami (w zależności od przyjętej architektury). Na pewno nie bezpośrednio, bo w praktyce do takiej komunikacji wykorzystuje się zdarzenia integracyjne. Nic jednak nie stoi na przeszkodzie, aby na bazie publikowanych zdarzeń domenowych, które mogą być istotne w innej domenie, wyemitować zdarzenia integracyjne.

Do samej obłsługi zdarzeń domenowych nie trzeba wykorzystywać warstwy infrastruktury. Wszystko może odbyć się synchronicznie w pamięci w ramach jednego procesu. Jeżeli istnieje potrzeba wprowadzenia asynchroniczności to również jest możliwe. W praktyce, prawdopodobnie, skończy się na hybrydowym rozwiązaniu.

Domain Event w PHP

Pora przejść do kodu. Zacznę od głównego bohatera, czyli zdarzenia. Co powinno charakteryzować każdy tego typu obiekt? Najlepiej pokazuje to odpowiednio przygotowana abstrakcja.

abstract class DomainEvent
{
    public readonly Id $aggregateId;

    public readonly int $version;

    public readonly DateTimeImmutable $occurredOn;

    public function __construct(Id $aggregateId, int $version)
    {
        $this->aggregateId = $aggregateId;
        $this->version = $version;
        $this->occurredOn = new DateTimeImmutable();
    }
}

W kwestii pól, na pewno ważne, aby każdy event miał identyfikator agregatu, którego dotyczy. Tego rodzaju kontekst jest potrzebny, aby ewentualnie reagować na dane zdarzenie. Wersja to kolejny istotny parametr. Być może, w przyszłości, zaistnieje potrzeba zmodyfikowania klasy. Należałoby wtedy podbić wersję. Same zdarzenia warto traktować jak konktrakt API, mając świadomość, że niektóre zmiany mogą nie być wstecznie kompatybilne. Ostatnim istotnym elementem jest data wystąpienia. Niektóre zdarzenia mogą być wyemitowane kilkukrotnie, a czas pozwoli chociażby na odpowiednie posegregowanie. Poza tym, to zawsze cenna informacja, kiedy zdarzenie miało miejsce. Oczywiście, w razie potrzeb, można tę abstrakcję rozszerzyć. W moim odczuciu, wyżej wymienione są absolutnym minimum.

Sam event powinien być niemutowalny. Jak możecie zauważyć, w moim przykładzie, pojawiają się publiczne własności oznaczone jako tylko do odczytu (readonly). Nic nie stoi na przeszkodzie, aby były to pola prywante z getterami. Niezmienność jest ważna, ponieważ zdarzenie reprezentuje coś, co już się wydarzyło. Przeszłości nie można zmieniać.

Zdarzenie w najprostszej postaci to takie, które nie posiada nic ponad abstrakcję. Nie oznacza to jednak, że nie można dodawać dodatkowych pól. Istotne jest to, aby nie przemycać za dużo informacji. Dużo trudniej jest to później utrzymać, dlatego powinno się ograniczać tylko do tych elementów, które jednoznacznie opisują, co się wydarzyło. Dodatkowo, każdy event powinien mieć nazwę opartą o czasownik w czasie przeszłym. Tak, by jasno zaznaczyć, że jest to coś, co już miało miejsce.

final class DocumentArchived extends DomainEvent
{
    private const EVENT_VERSION = 1;

    public function __construct(DocumentId $id)
    {
        parent::__construct($id, self::EVENT_VERSION);
    }
}

Mówiąc o całej obsłudze zdarzeń, trzeba wspomnieć o agregatach. To właśnie w ich kontekście występują. O samym koncepcie można przygotować kilka osobnych artykułów. Nie chcąc zaciemniać, prezentuję to w najprostszej formie, potrzebnej do realizacji tego materiału. Samą obsługę zdarzeń można wyciągnąć do klasy abstrakcyjnej. Logika ta będzie idetyczna dla wszystkich agregatów, dlatego nie ma sensu jej powielać.

Co się przyda? Po pierwsze, potrzebna jest tablica lub kolekcja pozwalająca przechować zdarzenia w pamięci. Po drugie, metoda która pozwoli pobrać wszystkie zdarzenia. Ważne, żeby pobieranie dodatkowo czyściło dotychczasowe zdarzenia, tak aby nie zostały one przetworzone ponownie. Ostatnia potrzebna metoda pozwala na wyemitowanie zdarzenia. Ona powinna być chroniona, bo zdarzenia będą tworzone wewnątrz agregatu. To agregat powinien mieć pełną kontrolą nad zdarzeniami. Nie ma możliwości, aby to klient mógł je generować.

abstract class AggregateRoot
{
    /** @var DomainEvent[] */
    private array $events = [];

    /** @return DomainEvent[] */
    final public function pullEvents(): array
    {
        $events = $this->events;
        $this->events = [];

        return $events;
    }

    final protected function raise(DomainEvent $event): void
    {
        $this->events[] = $event;
    }
}

Agregat dokumentu mógłby wyglądać jak ten poniżej. Naturalnie, posiada on tylko tyle logiki, ile jest potrzebne dla tego przykładu. Kilka pól ustawianych przez konstruktor, tak aby obiekt tuż po utworzeniu był w poprawnym stanie. Zauważcie, że ten konstruktor jest prywatny. Sam agregat posiada dwie metody wytwórcze, czyli create i restore. Zabieg ten pozwala na separację faktycznego tworzenia oraz odtwarzania obiektu. W innym wypadku, zdarzenie DocumentCreated byłoby emitowane również podczas odtworzenia.

final class Document extends AggregateRoot
{
    private Status $status;

    private function __construct(
        private DocumentId $id,
        private Content $content,
        private EditorId $editorId
    ) {
        $this->status = Status::ACTIVE;
    }

    public static function create(
        DocumentId $id,
        Content $content,
        EditorId $editorId
    ): self {
        $document = new self($id, $content, $editorId);
        $document->raise(new DocumentCreated($id));

        return $document;
    }

    public static function restore(
        DocumentId $id,
        Content $content,
        EditorId $editorId,
        Status $status
    ): self {
        $document = new self($id, $content, $editorId);
        $document->status = $status;

        return $document;
    }

    public function archive(): void
    {
        if ($this->isArchived()) {
            return;
        }

        $this->status = Status::ARCHIVED;
        $this->raise(new DocumentArchived($this->id));
    }

    private function isArchived(): bool
    {
        return Status::ARCHIVED === $this->status;
    }
}

Widzieliście już wcześniej zdarzenie DocumentArchived. Generowane jest ono w metodzie do archiwizacji. Ważne, że powinno wystąpić tylko wtedy, kiedy faktycznie archiwizacja ma miejsce. Trzeba więc sprawdzić, czy obiekt nie jest już zarchiwizowany. Nie można zarchiwizować zarchiwizowanego dokumentu (chyba, że domena określa inaczej).

Do tego momentu, wyjaśniona została kwestia agregatów i emitowania zdarzeń wewnątrz nich. To całkiem dużo. Nic jednak po eventach, kiedy nie wnoszą wartości, a wartości tej można się doszukać w komunikacji między agregatami. Jak to zrobić? Na pewno, przydałby się obiekt, który potrafi przyjąć zdarzenia do realizacji. Interfejs takiego zachowania może wyglądać następująco.

interface DomainEventDispatcherInterface
{
    public function dispatch(DomainEvent ...$domainEvents): void;
}

Mając już abstrakcję odpowiedzialną za obsługę zdarzeń, pojawia się inne pytanie. Mianowicie, kiedy należy pobrać zdarzenia z agregatu i dostarczyć je do dispatchera. Zazwyczaj, ma to miejsce po utrwaleniu stanu agregatu. Jest to o tyle dobry moment, że eliminuje ryzyko puszczenia zdarzeń w świat, w momencie kiedy faktycznych zmian w agregacie nie udało się persystować. A to prowadziłoby do błędów fatalnych w skutkach.

W moim przykładzie, obsługa zdarzeń zacznie się w klasie typu Handler. Jeżeli nie korzystacie z CQRS, prawdopodobnie będzie to klasa reprezentująca serwis aplikacyjny. Cokolwiek, co odpowiada za zmianę stanu agregatu.

Reagując na polecenie archiwizacji dokumentu o zadanym identyfikatorze, metoda wyciąga konkretny agregat dokumentu i wywołuje zachowanie archiwizacji. Następnie przekazuje agregat do repozytorium, które próbuje go utrwalić. Jeżeli taka operacja się uda, jest to idealny moment na pobranie i przekazanie zdarzeń, które pojawiły się w ramach tej operacji.

Warto wspomnieć o potencjalnym ryzyku, że w trakcie reagowania na zdarzenia, coś może pójść nie tak. Wówczas zmiany w agregacie zostały zapisane, a wszystko zależne się nie wykonało. Rozwiązaniem na to jest chociażby zapisywanie zdarzeń do bazy lub na kolejkę i procesowanie ich z różnymi mechanizmami ponawiania.

final class ArchiveDocumentHandler implements CommandHandlerInterface
{
    public function __construct(
        private DocumentRepositoryInterface $repository,
        private DomainEventDispatcherInterface $dispatcher
    ) {}

    public function __invoke(ArchiveDocumentCommand $command): void
    {
        $document = $this->repository->get(new DocumentId($command->id));

        $document->archive();

        $this->repository->save($document);
        $this->dispatcher->dispatch(...$document->pullEvents());
    }
}

Do tego momentu, implementacja jest dość uniwersalna. Obstawiam, że bardzo często tak właśnie wygląda. Ewentualnie jest pochodną powyższej. Zostaje jeszcze samo reagowanie na zdarzenia domenowe. Do tego tematu można podejść na wiele sposobów. Ja prezentuję jedną z możliwości.

interface DomainEventListenerInterface
{
    public function handle(DomainEvent $event): void;

    public function supports(DomainEvent $event): bool;
}

Powyższy interfejs reprezentuje nasłuchującego. Opisany jest za pomocą dwóch metod: handle pozwalająca na obsługę zdarzenia domenowego i supports dostarczająca odpowiedzi na pytanie, czy to zdarzenie powinno być obsługiwane przez ten Listener. Klasyczne wykorzystanie wzorca strategii.

Faktyczny DomainEventDispatcher posiada więc tablicę obiektów typu DomainEventListenerInterface. Kiedy zdarzenia są mu dostarczane, iteruje po wszystkich załadowanych nasłuchujących i sprawdza, czy na ten event powinny reagować. Jeśli nie to je pomija, a jeśli tak to deleguje je do wykonania.

final class DomainEventDispatcher implements DomainEventDispatcherInterface
{
    /** @var DomainEventListenerInterface[] */
    private array $listeners;

    public function __construct(DomainEventListenerInterface ...$listeners)
    {
        $this->listeners = $listeners;
    }

    public function dispatch(DomainEvent ...$domainEvents): void
    {
        foreach ($domainEvents as $event) {
            foreach ($this->listeners as $listener) {
                if (! $listener->supports($event)) {
                    continue;
                }

                $listener->handle($event);
            }
        }
    }
}

Jak mógłby wyglądać Listener reagujący na zdarzenie archiwizacji dokumentu? Sposobów jest wiele. Ważne, aby odpowiedzieć sobie, czy powinno się to zadziać w ramach tego samego procesu. Jeśli tak, to operacja w metodzie handle może bezpośrednio wywołać konretną operację. Jeśli nie, to należy w odpowiedni sposób zakolejkować polecenie.

Jedną z opcji jest stworzenie komendy i dostarczenie jej na szynę poleceń. Implementacja ta zadziała zarówno na potrzeby synchroniczne jak i asynchroniczne. Co więcej, być może agregat Editor w ramach operacji ukarania twórcy zarchiwizowanego dokumentu także wygeneruje inne zdarzenia. Nie widzę więc podstaw, żeby całą tę logikę zawierać w listenerach. Raczej powinny one delegować zadania do serwisów.

final class PunishEditorWhenDocumentArchived implements DomainEventListenerInterface
{
    public function __construct(
        private CommandBusInterface $commandBus
    ) {}

    public function handle(DomainEvent $event): void
    {
        if (! $this->supports($event)) {
            return;
        }

        $this->commandBus->dispatch(
            new PunishDocumentEditorCommand($event->aggregateId->toString())
        );
    }

    public function supports(DomainEvent $event): bool
    {
        return $event instanceof DocumentArchived::class;
    }
}

Tak przygotowany mechanizm, powinien wystarczyć do obsługi zdarzeń domenowych. Bynajmniej, u mnie w projektach się sprawdza. Tak jak wspomniałem, sam dispatcher i listenery mogą wyglądać nieco inaczej, w zależności od wymagań. Mam nadzieję, że cały powyższy kod jest zrozumiały. Jak zwykle, starałem się stworzyć realny przykład, ale jednocześnie ograniczyć go do minimum. Nie zawsze jest to łatwe.

Zdarzenia domenowe – podsumowanie

Definitywnie, zdarzenia domenowe są w stanie wzgobacić model dziedziny. Kolejny świetny koncept z Domain Driven Design, który można wprowadzić nawet w aplikacjach, które DDD nie widziały na oczy. Rzecz jasna, wszystkie te operacje dałoby się zrobić bez zdarzeń, ale ja uważam, że nie warto. Zdarzenia pozwolą rozluźnić powiązania między agregatami, a przez transformację zdarzeń na integracyjne również między modułami.

Samo zdarzenie domenowe, to fakt z przeszłości reprezentujący istotną zmianę w domenie. Jeden z podstawowych bloków taktycznego DDD. Tworzony przez agregaty jako niemutowalny obiekt.

Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan dobrego humoru i podcastów.