Agregaty to perła pośród całego Domain Driven Design. Święty Graal wielu, którzy migrują na DDD. Niby każdy wie o ich istnieniu, jednakże znaleźć projekt z ich wykorzystaniem jest niełatwo. Przejdźmy przez ten bardzo ciekawy element taktycznego DDD.

Encje i Value Objecty to nie wszystko…

Gdybyśmy podsumowali wszystko, co do tej pory pisałem o DDD – architekturę warstwową, heksagonalną, potrzebę istnienia command handlerów itp. to wyszło by nam coś bardzo ciekawego. Najlepiej wyjaśniać na przykładzie, więc przyjmijmy, że rozmawiamy o domenie bloga.

Mamy endpoint, który służy do publikacji posta. Robimy kontroler, do tego dorzucamy command handler, następnie zaczytujemy encję bloga z repozytorium i zmieniamy widoczność posta. Ponieważ trzeba również pokombinować z liczbą wpisów per tag i kategoria, to wyciągamy również odpowiednio tagi i kategorie z repozytoriów (wait, mamy przecież odniesienia w encji) – wyciągamy je z encji. Następnie robimy przekalkulowania, inkrementujemy tam, gdzie trzeba zainkrementować i zrobione. Aby było ładnie, to cała procedura odbywa się w serwisie domenowym, który jest uruchamiany w command handlerze należącym do warstwy aplikacji.

Kontynuując przykład, mamy drugi endpoint, który służy do ukrywania posta. Domenowo jest to inny proces, więc zgodnie z DDD – albo robimy nowy kontroler – albo dopinamy akcję do już istniejącego. Następnie tworzymy command handler i dodajemy nową metodę do serwisu, w którym grzebaliśmy poprzednio – bo wygląda na to, że jest to podobna odpowiedzialność. Tym bardziej, że mamy tam logikę wyliczania liczby postów w tagach i kategoriach. Zgodnie z zasadą DRY – korzystamy z niej. Wszystko wygląda na zgodne z zasadami Domain Driven Development.

Powstaje jedno, zasadnicze pytanie: a gdzie jest logika encji? Czyżbyśmy właśnie wyprodukowali anemiczny model? Coś chyba poszło nie w tą stronę.

Agregaty są wśród nas

Domain Driven Design, jak nazwa wskazuje, jest nauką związaną z modelowaniem domeny aplikacji. To domena, czyli logika naszego biznesu, staje na pierwszym miejscu. Encje nie służą żadnemu mapowaniu wartości bazodanowych. Nie są też przekaźnikami danych. Są to obiekty, których klasy są połączone ze sobą relacjami oraz współpracują ze sobą. A współpraca ta powinna odbywać się właśnie w obrębie agregatu.

Agregatem w kontekście Domain Driven Design możemy nazwać zespół encji, które współpracują ze sobą, realizując niezmienniki, czyli procesy biznesowe, które są nierozerwalne. Każdy agregat posiada swój rdzeń (ang. aggregate root), który powinien zostać zwrócony z repozytorium i który jest niejako fasadą dla całego drzewa (bądź nawet grafu) encji, które znajdują się gdzieś tam w środku.

Przykładowe miejsca, gdzie potencjalnie moglibyśmy wyróżnić agregat:

  • Klasyka eCommerce – Produkt. Każdy produkt posiada zazwyczaj kilka wariantów, atrybutów oraz translacji. Do tego, każdy wariant posiada zestaw opcji, którymi różni się od pozostałych wariantów w obrębie tego agregatu.
  • Domena eLearningu – Dział kursu. Każdy dział posiada kilka epizodów, czyli pojedynczych nagrań video (choć nie tylko). Do tego dział może posiadać kilka krótkich testów sprawdzających wiedzę między epizodami oraz egzamin kończący dział. Każdy test oraz egzamin posiada dowiązanie do kilku pytań (z odpowiedziami) oraz encji wyników.
  • Zarządzanie projektami: Sprint. Każdy sprint ma kilka tasków. Każdy task posiada swoją historię – logi pracy, historię (komentarze), osoby biorące udział w sprincie. Mamy tu duży potencjał na wyliczenia scope, statystyk, szacowanych szans na dowiezienie funkcjonalności.

To, że wymieniłem powyższe, nie oznacza jeszcze, że tam zawsze agregaty są. Pamiętajmy, że to my projektujemy i to my dzielimy domenę na takie kawałki (agregaty), jakie wyszczególnimy w obrębie aplikacji (biznesu).

Warunki konieczne pracy na agregatach

Domain Driven Design jasno określa warunki, które musimy spełnić, jeżeli chcemy poprawnie korzystać z agregatów w naszej aplikacji. Część z nich jest oczywista, ale poniżej znajdą się również zasady, które mogą zaskoczyć 🙂

Samodzielność agregatów

Agregat powinien być samodzielny. Oznacza to, że wszystkie procesy, które wykonamy w obrębie agregatu, nie wymagają ingerencji ze strony innych agregatów. Jeżeli zmieniamy coś w obrębie produktu (który jest agregatem), to musimy zagwarantować, że podczas wykonania operacji na tym agregacie, żaden inny produkt nie zostanie zmodyfikowany.

Możemy mieć sytuację, że dwa agregaty są połączone ze sobą relacją. Na przykład agregat produktu może mieć połączenie z kategorią, która w naszym systemie również jest agregatem. W obrębie produktu możemy pracować na identyfikatorze kategorii. DDD pozwala na dostęp do identyfikatora innych agregatów. Mowa tutaj również o sytuacji, że agregat produktu będzie posiadał identyfikator innego agregatu (innej instancji tego agregatu). I będzie na nim pracował. Istotne jednak jest to, że instancja jednego agregatu nie może zmienić stanu instancji innego agregatu.

Ograniczony dostęp na zewnątrz

Wyciekanie logiki biznesowej poza agregaty ma wiele wspólnego z modelem anemicznym. Jeżeli istnieje element świata zewnętrznego (w kontekście agregatu), to powinniśmy dwu-, a nawet trzykrotnie zastanowić się, czy powinniśmy udostępnić mu jakąkolwiek składową agregatu. Mowa tutaj szczególnie o encjach. Pytania, które powinniśmy sobie zadać, powinny brzmieć: dlaczego nie może tego zrobić agregat? Czy jest potrzeba, aby zwracać całą encję? Może warto zwrócić wyłącznie obiekt wartości? Pamiętajmy, że jeżeli zwrócimy encję, to potencjalnie dajemy dostęp innym częściom aplikacji do środka agregatu; bo ta encja prawdopodobnie posiada relacje do innych encji w obrębie tego agregatu.

Dostęp do składowych agregatu wiąże się również z potencjalnym łamaniem prawa Demeter. Psuje nam to modelowanie domeny, potencjalnie zostawiając mnóstwo tzw. pociągów getterowych w serwisach domenowych i (co gorsza) aplikacyjnych.

W obrębie tego akapitu należy również poruszyć kwestię encji, które zostają włączone do agregatu. Przykładowo, do agregatu sprintu zostaje dodane nowe zadanie. Jeżeli rozszerzamy agregat o nową encję (podpinamy encję relacją do innej składowej agregatu, wrzucamy encję do kolekcji wewnątrz agregatu itp), to nie powinniśmy już „pracować” na tej encji spoza poziomu agregatu. Wtedy to agregat przejmuje własność nad tą encją i jest za nią odpowiedzialny.

Oznacza to, że poza agregatem:

  • Nie powinniśmy już nigdzie w kodzie zaczytywać tej encji z repozytorium,
  • Nie powinniśmy uruchamiać żadnych niezmienników w obrębie tej encji,
  • Nie powinniśmy włączać tej encji w żaden inny agregat.

Ostatni punkt jest na pozór kontrowersyjny. Powinniśmy móc rozróżnić tutaj proces włączania encji w agregat od procesu połączenia tej encji relacją z inną encją. To pierwsze pozwala nam na uruchomienie procesów tej encji poza jej macierzystym agregatem. Drugie natomiast oznacza wyłącznie pracę z identyfikatorem agregatu, czyli coś, co jest dozwolone.

Agregat musi być kompletny

Jak wcześniej zaznaczyłem, agregat pochodzi z repozytorium. I to repozytorium jest odpowiedzialne za dostarczenie wszystkich składowych encji agregatu. Po zwróceniu agregatu musimy mieć pewność, że nie potrzeba już nic więcej doczytywać, aby móc na nim pracować. Czyli wszystkie encje i obiekty wartości, które wchodzą w jego skład, muszą znajdować się w pamięci aplikacji.

Jeżeli nawiązujemy już do pamięci aplikacji, to koniecznie należy wspomnieć o performance aplikacji. Im większy agregat, tym więcej aplikacja zajmie miejsca w pamięci, oraz (potencjalnie) tym więcej zapytań bazodanowych będzie musiało zostać wysłanych, aby utrwalić zmianę jego stanu. A to wpływa bezpośrednio na to, jak szybko będzie działała nasza aplikacja.

Powyższa zasada nakłada na nas odpowiedzialność projektowania agregatów tak, aby relacje jeden do wielu oraz wiele do jednego nie skutkowały tym, że będziemy musieli wczytywać np. tysiąc wierszy bazodanowych, aby zagwarantować kompletność agregatu. Aby móc poprawnie zamodelować agregaty, koniecznie musimy poznać rozkład danych, na których biznes się opiera.

Przykładem źle zaprojektowanego agregatu może być:

  • Kategoria produktów, która posiada odwołanie do dopiętych do niej produktów. Bo tych produktów mogą być setki, a nawet tysiące.
  • Artykuł posiadający dowiązanie do jego wszystkich poprzednich wersji. Tych wersji może być dużo (niektóre artykuły tworzy się tygodniami), a każda z tych wersji może potencjalnie posiadać dowiązania do dużej ilości value objectów, które będą reprezentowały pojedynczą zmianę w treści.
  • Projekt (w rozumieniu narzędzia do zarządzania pracą nad projektami), który będzie posiadał dowiązanie do wszystkich zadań. Bo tych zadań może być dużo.

Pamiętajmy również, że do pamięci muszą być wczytane wyłącznie te encje, które włączamy w agregat, czyli te, które są potrzebne, aby proces biznesowy został zrealizowany. Nie wczytujemy w pełni tych relacji, które służą jedynie jako dowiązania – niezależnie od tego, czy są to dowiązania do innych agregatów, czy luźnych encji (1).

Kompletność agregatu a Doctrine Lazy Loading

Jak nam wiadomo, Doctrine posiada mechanizm nazywany Lazy Loading, który służy do wczytywania encji w momencie, kiedy chcemy skorzystać z ich wartości. Mechanizm ten powoduje, że w momencie, kiedy np. z poziomu encji posta chcemy odwołać się do imienia autora (autor jako osobna encja jest połączony relacją z postem), to zostaje wysłane dodatkowe zapytanie bazodanowe. Z perspektywy czysto purystycznej, możemy stwierdzić, że jest to zachowanie niezgodne z zasadami DDD. Sprawa ta ma jednak drugie dno.

DDD jest zbiorem zasad, który żyje poza Doctrine. Z perspektywy DDD encje zupełnie nie są połączone z bazą danych jakimkolwiek mechanizmem. Tak na prawdę, może nie być żadnej bazy danych – możemy wyobrazić sobie scenariusz, kiedy cała aplikacja działa w pamięci serwera i jest zgodna z DDD. Możemy wyobrazić sobie scenariusz, że nie korzystamy z Doctrine, tylko prostymi zapytaniami SQL na poziomie repozytorium zaczytujemy wszystkie potrzebne dane (tak, repozytorium to warstwa infrastruktury 😉 ), a następnie manualnie przejść proces hydracji, tworząc encje jedna po drugiej. I w sumie – to rozwiązanie jest chyba najbliższe temu, co DDD chce nam powiedzieć w kontekście agregatów. Jak budujemy agregat, to raz, a porządnie. Każda składowa encja w obrębie agregatu powinna mieć dostęp do innych jego składowych, do których dostęp powinna mieć. Efekt ma być taki, że każda metoda odpalana z poziomu rdzenia agregatu powinna pracować na kompletnym modelu, który nie zmusza nas do rozbicia jednego procesu (niezmiennika) na kilka różnych operacji, bo trzeba doczytać dane.

Jeżeli pójdziemy w tą stronę, i odseparujemy się nieco od tego, co robi Doctrine „pod spodem”, to wyjdzie nam, że de facto wszystko jest /teoretycznie/ w porządku. A to, że implementacja Doctrine gdzieś tam pod spodem załącza do encji mechanizmy infrastrukturalne, z perspektywy logiki naszej aplikacji jest na tyle dla nas transparentne (i często dzieje się bez naszej wiedzy), że nie powinniśmy się skupiać nad tym. Skupić się powinniśmy na performance, czyli m.in. na potencjalnym problemie N+1, który szczególnie w obrębie agregatu może występować.

Pracujemy na jednym agregacie jednocześnie

Z perspektywy Domain Driven Design sytuacja jest bardzo prosta – jedna transakcja bazodanowa równa się jeden agregat. Zarówno w stronę, że praca na jednym agregacie nie może wiązać się z dwoma transakcjami bazodanowymi, jak i w stronę, że podczas jednej transakcji bazodanowej nie możemy modyfikować więcej, niż jeden agregat. Jest to dosyć duże ograniczenie, jednakże wynika ono z bardzo dużego nacisku na spójność stanu systemu. Jeżeli zmieniliśmy coś w obrębie jednego agregatu, to chcielibyśmy, aby inne części systemu (inne żądania HTTP, procesy serwerowe) mogły dorwać się do zmodyfikowanych danych.

A co z procesem importu…?

Jako, że pracuję w domenie eCommerce, to bardzo częstym zadaniem jest napisanie skryptu importującego dane z jednego sklepu na drugi (migracja). Dobrym rozwiązaniem tego mechanizmu jest batching, czyli dodanie/aktualizacja pewnej paczki danych (np. 100 produktów), a następnie wysyłka tego do bazy danych. Ktoś powie – przecież łamiemy tutaj DDD. Bo jeden agregat = jedna transakcja.

Z drugiej strony – zadajmy sobie pytanie: czy proces importu produktów jest procesem biznesowym? Czy jest to proces, który musi być na siłę ograniczony wszystkim tym, co nakazuje DDD? Z mojej perspektywy, jest to bardziej proces bazodanowy (techniczny) niż domenowy (biznesowy). Pamiętajmy: to nigdy nie powinno być tak, że powinniśmy dążyć do 100% implementacji aplikacji w ramach DDD. Powinniśmy korzystać z DDD jak z młotka – tam, gdzie to się przyda 🙂

Mamy agregaty i… co dalej?

Agregat, jako obiekt złożony głównie z encji oraz obiektów wartości, zostaje zwrócony przez repozytorium. Zgodnie z architekturą warstwową, możemy z niego skorzystać w warstwie domeny oraz aplikacji. Jeżeli nasza logika domenowa jest rozległa, to rozważyć należy pracę na agregacie na poziomie serwisu domenowego. Jeżeli natomiast logika biznesowa jest na tyle prosta, że przekłada się na uruchomienie jednej metody agregatu, to możemy uprościć nieco i skorzystać z niego np. bezpośrednio w command handlerze.

Z agregatu nie korzystamy w kontrolerach ani innych elementach warstwy User Interface. Nie ma jednak przeciwwskazań, aby z agregatu korzystał aplikacyjny serwis, z którego bezpośrednio będzie korzystać kontroler. Wtedy ten serwis jako serwis aplikacyjny (pośrednio lub bezpośrednio) będzie miał za zadanie spełnić wszystkie wymagania związane ze stosowaniem agregatów, jak na przykład jednotranzakcyjność.

(1) – chociaż jakby to głębiej przeanalizować, to zgodnie z DDD nie powinniśmy mieć w systemie „luźnych encji”, czyli takich, które nie byłyby składową jakiegokolwiek agregatu 😉

Profesjonalista, zajmujący się na co dzień aplikacjami biznesowymi w ekosystemie PHP. Jego pasją jest odkrywanie nowych konceptów programistycznych oraz wzorce architektoniczne. Uwielbia również pisać testy, gdyż jak sam uważa, dobry kod to przetestowany kod.

Comments are closed.