Eventy v PHP jinak: Jak vám doménové eventy zpřehlední kód?

Technologie Petr Gala

Eventy jsou běžnou součástí moderních PHP aplikací – umožňují reagovat na změny stavu a udržet kód přehlednější. Tento přístup ale často vede k tomu, že se byznys logika dostává do infrastrukturní vrstvy, což vede k horší čitelnosti a údržbě kódu.

V tomto článku se podíváme na doménové eventy, které přinášejí čistší architekturu a lepší oddělení byznys logiky od databázové vrstvy. Pokud právě stavíte aplikaci nebo se snažíte zpřehlednit svůj kód, doménové eventy vám mohou výrazně usnadnit život. Prozkoumáme rozdíl mezi klasickými ORM eventy a doménovými eventy a ukážeme si, jak je správně implementovat.

Co to jsou eventy?

Event (událost) je nějaká akce nebo změna stavu, na kterou chceme reagovat. V praxi to znamená, že když v aplikaci nastane určitá situace – např. když se uloží nová entita do databáze – chceme spustit specifickou logiku, aniž bychom ji museli vkládat přímo do kódu, kde se událost stala.

Jako příklad si zvolíme vytvoření nového uživatele. Po jeho registraci mu chceme zaslat e-mail s potvrzením a zároveň mu jako poděkování připsat body do věrnostního programu.

Proč používat eventy místo přímého volání funkcí?

V nejjednodušší variantě bychom kód mohli napsat takto:

$userRepository->save($user);
$emailService->sendConfirmationEmail($user);
$loyaltyService->addPoints($user);

Tento přístup má několik nevýhod:

  • Opakující se kód – Pokud budeme potřebovat posílat e-mail a připisovat body na více místech (třeba při registraci přes webový formulář i přes ERP systém), musíme stejnou logiku duplikovat.
  • Pevné propojení komponent – Kód se stává obtížně udržitelným, protože jednotlivé části aplikace jsou pevně svázané. Pokud tedy změníme způsob odesílání e-mailů (vyměníme EmailService za jinou implementaci), musíme upravit kód na více místech.
  • Závislost na konkrétní implementaci – Jakákoliv změna ve funkci pro odesílání e-mailů nebo připisování bodů vyžaduje úpravy ve všech místech, kde je tato logika volána. Navíc je velmi pravděpodobné, že v budoucnu přibudou další akce, které se mají provést při vytvoření uživatele, což může vést k ještě větší provázanosti kódu.

Jak eventy pomáhají?

Místo přímého volání funkcí můžeme použít eventy. Když se v aplikaci stane něco důležitého – v našem případě vytvoření uživatele – vyvoláme event. Tento event pak mohou zachytit listeneři (posluchači), kteří provedou konkrétní akce, jako je odeslání potvrzovacího e-mailu nebo připsání bodů do věrnostního programu.

Tímto způsobem získáme několik výhod:

  • Oddělení logiky – Kód, který ukládá uživatele, nemusí vědět nic o tom, co se má dít po jeho registraci (odeslání e-mailu, připsání bodů atd.).
  • Vyšší flexibilita – Pokud chceme změnit způsob odesílání e-mailů, místo upravování všech částí aplikace, kde se uživatelé ukládají, stačí upravit listener.
  • Lepší správa závislostí – Každá reakce na event má svého vlastního posluchače (listener), což znamená, že logika pro odeslání e-mailu je nezávislá na logice pro připsání bodů. To umožňuje snadnější rozšiřování a údržbu kódu.

ORM eventy vs. doménové eventy

Doménové eventy jsou nástrojem, který pomáhá udržet kód čistý a přehledný tím, že odděluje byznys logiku od infrastruktury. Místo toho, aby se aplikace rozhodovala na základě změn v entitách (což dělají např. ORM eventy v Doctrine), doménové eventy vychází přímo z logiky aplikace – tedy z toho, co je skutečně důležité pro fungování systému.

Doménové eventy často souvisí s konceptem Domain-Driven Design (DDD) a využívají se v hexagonální architektuře. To ovšem neznamená, že by se daly použít jen v těchto přístupech – jejich výhody oceníme i v běžných aplikacích, kde chceme, aby byl kód lépe strukturovaný, testovatelný a udržitelný.

Proč jsou doménové eventy game changer?

Doctrine (a jiné ORM) nám umožňuje reagovat na změny v entitách pomocí ORM eventů, např. prePersist, postUpdate nebo postFlush. Tyto eventy mohou být užitečné v situacích, kdy potřebujeme provést technickou změnu ještě před uložením entity do databáze. Dejme tomu, že pokud uživatel změní svoje jméno, můžeme v preUpdate přepočítat datum kdy má svátek a změnit ho přímo v entitě.

Co když ale potřebujeme reagovat na byznysovou změnu, třeba když se uživatel zaregistruje nebo objednávka přejde do stavu „Doručena“?

V Doctrine bychom na takovou situaci mohli reagovat v postFlush, protože až tam máme jistotu, že byla data opravdu zapsána do databáze. Takové řešení má své zásadní nevýhody:

  • Nepřehlednost a skrytá logika – Pokud se někdo snaží pochopit kód, nemá šanci na první pohled poznat, že existuje nějaký postFlush event, který se spouští v určitých situacích.
  • Debugging je noční můra – ORM eventy se spouští na úrovni Doctrine a pokud nevíme, že nějaký posluchač reagující na postFlush existuje, musíme ho složitě hledat přes Xdebug.
  • Silná závislost na ORM – Pokud bychom se někdy rozhodli Doctrine nahradit jiným řešením nebo používat více úložišť (např. ElasticSearch), ORM eventy nám přestanou fungovat.

Jak to řeší doménové eventy?

  • Přehlednost a srozumitelnost – Doménový event je součástí entity, takže už v kódu entity vidíme, že když dojde k určité akci (např. registrace uživatele), vznikne odpovídající event (UserRegistered). Nemusíme složitě hledat v ORM eventech, co se vlastně děje.
  • Snadnější debugging – Není potřeba zkoumat ORM hooky, stačí se podívat, které události může entita vyvolat.
  • Flexibilita a nezávislost na ORM – Doménové eventy fungují bez ohledu na ORM. Můžeme je vyvolávat v rámci aplikace, ať už data ukládáme do databáze, message brokera nebo API.
  • Lepší testovatelnost – ORM eventy se testují obtížněji, protože vyžadují interakci s Doctrine. Doménové eventy testujeme jednoduše jako samostatné třídy, které nejsou závislé na databázi.
  • Škálovatelnost – Protože doménový event popisuje to, co již proběhlo, je snadné na tuto událost reagovat asynchronně pomocí message brokerů (např. RabbitMQ, Kafka…). To umožňuje lepší škálovatelnost aplikace a oddělení jednotlivých procesů.

ORM Event – UserRegistered skrytý v Doctrine eventech

Nejprve máme entitu uživatele, kde nastavujeme pouze e-mail:

class User
{
    private string $id;
    private string $email;

    public function __construct(string $email)
    {
        $this->email = $email;
    }
}

Po zavolání $entityManager->persist($user); Doctrine automaticky vyvolá svůj event postPersist. Důležité je, že tento event se volá pouze po zavolání persist(), ale změny ještě nejsou v databázi! Samotné zapsání dat proběhne až při zavolání $entityManager->flush();.

Doctrine listener pro postPersist:

use Doctrine\ORM\Event\LifecycleEventArgs;

class UserListener
{
    public function postPersist(User $user, LifecycleEventArgs $args)
    {
        $this->emailService->sendConfirmationEmail($user);
        $this->loyaltyService->addPoints($user);
    }
}

Nevýhody tohoto přístupu:

  • Skrytá logika – Z kódu entity není vidět, že existuje nějaký UserListener, který provádí další akce po registraci. Programátor si toho všimne až při debuggingu nebo pokud aplikaci zná.
  • Nesprávné použití postPersist pro byznys logikupostPersist se volá ihned po persist(), ale ještě před flush(), což znamená, že změny stále nemusí být zapsány do databáze. Pokud následně flush() selže (třeba kvůli unique email omezení), může se stát, že uživateli odešleme e-mail, jenže ve skutečnosti se registrace neprovede.
  • Silná vazba na ORMpostPersist funguje jen v Doctrine. Pokud bychom se rozhodli uživatele ukládat jinak (přes API nebo jiný systém), tento přístup by přestal fungovat.

Doménový Event – UserRegistered jako součást entity

V předchozím přístupu s ORM eventy jsme narazili na několik problémů – logika byla skrytá v Doctrine listeneru, nebylo jisté, zda registrace skutečně proběhne, a vazba na ORM stěžovala rozšiřitelnost. Doménové eventy tento problém řeší tím, že jsou součástí domény a jasně říkají, co se v aplikaci stalo.

Nejprve máme entitu uživatele, kde nastavujeme pouze e-mail, ale zároveň ihned vytváříme doménový event:

class User
{
    private string $id;
    private string $email;
    
    private array $events = [];

    public function __construct(string $email)
    {
        $this->email = $email;
        $this->events[] = new UserRegistered($this);
    }

    public function releaseEvents(): array
    {
        $events = $this->events;
        $this->events = [];
        return $events;
    }
}

Na rozdíl od Doctrine listeneru, který je pevně svázaný s ORM, si zde musíme sami definovat, jak se eventy budou zpracovávat. Hezky už však jde vidět, že listener je závislý na eventu UserRegistered, takže celkem jednoduše pomocí IDE zjistíme, kde všude se event používá.

class UserRegisteredListener
{
    public function handle(UserRegistered $event)
    {
        $this->emailService->sendConfirmationEmail($event->email);
        $this->loyaltyService->addPoints($event->userId);
    }
}

Je důležité zmínit, že samotný příklad v tomto článku není kompletní produkční řešení. Slouží pouze k ilustraci toho, jak se doménové eventy dají používat.

Aby vše správně fungovalo, je nutné mít mechanismus pro zpracování doménových eventů. V praxi se to nejčastěji řeší pomocí jednoho centrálního posluchače ORM eventů, který zachytává a zpracovává doménové eventy po úspěšném dokončení transakce.

Tento mechanismus obvykle využívá postFlush v Doctrine, protože až v tomto momentě máme jistotu, že změny byly skutečně zapsány do databáze a není riziko, že by registrace selhala.

Proč nám v tomto případě nevadí ORM event postFlush?

  • Používáme pouze jeden posluchač, který slouží čistě pro zpracování doménových eventů – tím pádem v aplikaci nevzniká stejný problém, který jsme kritizovali u postFlush při přímém volání byznys logiky.
  • Neobsahuje žádnou doménovou logiku – jeho úkolem je pouze vyzvednout doménové eventy z entit a předat je event dispatcheru, který spustí jednotlivé listenery.
  • Dává nám jistotu, že byznysová logika nebude spuštěna předčasně – kdybychom eventy spouštěli už při persist(), mohlo by dojít k tomu, že některé akce (např. odeslání e-mailu) proběhnou ještě před dokončením transakce a databázový zápis může nakonec selhat.

Co dodat na konec? Doménové eventy představují čistší a udržitelnější přístup k práci s událostmi v aplikaci. Oproti ORM eventům nejsou skryté v infrastruktuře, ale jsou součástí domény, což výrazně zlepšuje čitelnost, testovatelnost a flexibilitu kódu.

Pokud chcete lépe oddělit byznys logiku od databázové vrstvy a zpřehlednit architekturu své aplikace, doménové eventy jsou rozhodně cesta, kterou stojí za to prozkoumat.

Lákají tě moderní přístupy v PHP, jako jsou doménové eventy, a chceš se podílet na tvorbě čistého a udržitelného kódu? Přidejte se k týmu Cognito a posouvej společně s námi hranice softwarového vývoje.

Co si dále přečíst