Aplikacje oparte o Modularny Monolit są swego rodzaju majstersztykiem – łączą w sobie zalety aplikacji monolitycznych (znane ludzkości od dawna) oraz wnioski z trudnej pracy na mikroserwisach. Jest to jedno z bardziej odpowiedzialnych podejść, na wyjaśnienie którego stanowczo warto poświęcić tutaj chwilę.

O aplikacjach monolitycznych słów kilka

Podstawową sprawą, którą powinniśmy zrobić na samym początku, jest zdefiniowanie monolitu w kontekście tworzenia aplikacji biznesowych. Zatem aplikacją o architekturze monolitycznej nazywamy samodzielną wdrożeniowo aplikację, która kompleksowo realizuje konkretne potrzeby biznesowe.

Mówiąc o aplikacji samodzielnej wdrożeniowo, mamy na myśli jednostkę aplikacyjną pracującą w architekturze serwerowej. Nie chodzi jednak tutaj o to, aby ta aplikacja była fizycznie wdrożona tylko na jednym serwerze. Aplikację monolityczną możemy w końcu skalować horyzontalnie – instalując ją na kilku jednostkach serwerowych jednocześnie. Słowo „samodzielna” oznacza, że do wdrożenia nie wymaga ona konieczności istnienia innych aplikacji; oznacza to, że nie jest ona zależna wdrożeniowo od innego systemu.

Łatka przypięta aplikacjom monolitycznym

W dzisiejszym świecie panuje pewna fałszywa wiedza na temat monolitów, która powoduje, że wiedza o nich ciągle jest niszowa. Mowa tutaj o skojarzeniu z architekturą typu Wielka Kula Błota (ang. Big Ball Of Mud). W zasadzie, jedyną definicją tej architektury jest brak jakiejkolwiek architektury. Każdy jej element może być powiązany z dowolnym innym elementem znajdującym się w środku. Bardziej popularnym określeniem tego rodzaju aplikacji jest spaghetti code, czyli jedno wielkie pomieszanie z poplątaniem. Niestety, tego typu aplikacje w większości przypadków mają postać monolityczną. Ponieważ bardzo duża ilość programistów na starcie swojej kariery ma styczność z monolitycznym spaghetti code, to architektura monolitu kojarzy im się zazwyczaj właśnie źle.

Charakterystyka Modularnego Monolitu

Modularny Monolit jest pojęciem samo deskryptywnym – mówimy tutaj o aplikacji monolitycznej podzielonej na moduły. Z jednej strony proste do wykonania, z drugiej strony – bez odpowiedniej wiedzy architektonicznej trudno jest osiągnąć zadowalający rezultat.

Kiedy mówimy o module, mamy tutaj na myśli część systemu, która mogłaby z powodzeniem zostać wydzielona do osobnej aplikacji, która następnie komunikowała by się w jakiś sposób z naszym monolitem. Aby uzyskać tego rodzaju efekt, należy zadbać o możliwie pełne odseparowanie każdego modułu od pozostałych. Niestety, zazwyczaj nie da się w pełni odseparować modułów od siebie – w końcu pełnią one ważną rolę w całej aplikacji. Muszą najczęściej integrować się z pozostałymi modułami, aby zachowanie aplikacji było spójne.

Integracja modułów

Bardzo dobrym rozwiązaniem dla Modularnego Monolitu jest skorzystanie z dobrodziejstwa architektury warstwowej znanej z Domain Driven Design. Aby zrozumieć, w jaki sposób ta architektura może tutaj pomóc, przejdźmy przez pewien proces myślowy:

  1. Moduły pełnią rolę w obrębie aplikacji.
  2. Warstwa aplikacji dotyczy logiki biznesowej (ma dostęp do warstwy domeny).
  3. Każdy moduł aplikacji przeprowadza procesy biznesowe zachodzące w systemie.
  4. Każdy zachodzący proces biznesowy modyfikuje stan aplikacji.
  5. Każda modyfikacja stanu aplikacji jest zdarzeniem z życia aplikacji.
  6. Zdarzenia należą do warstwy domeny.
  7. Warstwa aplikacji ma dostęp do warstwy domeny.

Dalszy wniosek nasuwa się sam: poprawnym architektonicznie zachowaniem będzie, jeżeli warstwa aplikacji jednego modułu będzie nasłuchiwała na zdarzenia, które pojawiają się w drugim module. Idąc za ciosem, reakcją na odebrane zdarzenie z obcego modułu powinno być wrzucenie na szynę komendy, która będzie domenowo rozumiana przez obecny moduł.

Dużym niebezpieczeństwem związanym z powiązaniami między modułami jest zbyt duża ich zależność od siebie. Pamiętajmy o tym, aby tworzone przez nas moduły były możliwie najbardziej samodzielne. Jednakże jest jedna rzecz, której kategorycznie należy unikać – cyklicznej zależności między modułami. Praca w takim środowisku grozi bardzo trudnymi do zdiagnozowania błędami.

Współdzielenie kodu między modułami

Niestety, nie wszystkie domeny pozwolą nam na integrację modułów wyłącznie poprzez nasłuchiwanie na zdarzenia pozostałych modułów. Najczęściej, kiedy do takiej sytuacji dochodzi, to potrzebujemy współdzielić warstwę domeny (choć nie jest to zasada). W sytuacji, kiedy potrzebujemy współdzielić kod pomiędzy modułami, mamy dwa wyjścia: skorzystać z zasady Wspólnego Jądra (ang. Shared Kernel) lub zbudować most między modułami.

Jądro Współdzielone

Niekiedy domena w obrębie wielu różnych kontekstów, na pewnym poziomie potrzebuje operować tymi samymi pojęciami (współdzielenie warstwy domeny). Czasem jest potrzeba współdzielić serwisy lub porty (warstwa aplikacji). Jeżeli potrzebujemy tego rodzaju klas na poziomie większości systemu, to możemy utworzyć dodatkowy moduł, od którego zależne mogą być pozostałe moduły. W moich projektach ten moduł nosi nazwę SharedKernel, co daje mi jasną informację co do zależności pozostałych modułów od tego jednego.

Zasady DDD jasno określają, że wspólne jądro powinno być z zasady jak najmniejsze. Im mniejsze jądro, tym łatwiej jest wydzielać serwisy z istniejących modułów oraz wspierać je w przyszłości. Jeżeli kod Jądra Współdzielonego zaczyna nam puchnąć, to znaczy, że należy przemyśleć, czy aby na pewno idziemy w dobrą stronę.

Tworzenie modułu-mostu

Czasami może zaistnieć potrzeba, aby dwa osobne moduły integrowały się nieco mocniej, niż pozostałe. Jeżeli zachodzi taka potrzeba, to wtedy należy utworzyć dodatkowy moduł nazywany mostem. Moduł ten będzie mógł bezpośrednio integrować obydwa moduły (jego kod może zależeć od tych dwóch modułów). Wszekie zależności zamieszczamy właśnie w tym dodatkowym module; niedopuszczalnym jednak jest zależność któregokolwiek modułu od modułu-mostu lub od drugiego modułu, z którym się integrujemy. Dodatkowo, moduł-most może pełnić rolę translacji pomiędzy dwoma domenami, które wewnątrz operują innym językiem.

Co z warstwami Interfejsu Użytkownika oraz Infrastruktury?

Zarówno warstwa Interfejsu Użytkownika oraz Infrastruktury pełnią rolę „wymienną” w całej aplikacji. Zmieniając sposób komunikowania się z naszą aplikacją, nie powinniśmy nic zmieniać wewnątrz logiki działania samej aplikacji. Mówiąc nieco technicznie: jeżeli przechodzimy z kontekstu HTTP (API) na kontekst CLI (systemy kolejkowe), to logika działania aplikacji nie powinna ulec zmianie. Zatem dobrze by było, abyśmy nie naruszali jak największej jej części – modułu. Dodatkowo, zmieniając sposób komunikowania się aplikacji ze światem zewnętrznym, również nie powinniśmy modyfikować logiki samej aplikacji. Czyli znowu, mówiąc technicznie: przechodząc z jednego systemu bazodanowego do drugiego, logika działania aplikacji dalej powinna być niezmienna. Zatem podobnie, jak to ma miejsce w rozumieniu warstwy Interfejsu Użytkownika, tutaj również staramy się nie modyfikować jak największej części aplikacji – modułu.

Kontrakt aplikacji z warstwą Infrastruktury

Jeżeli chcemy zmienić sposób powiadamiania użytkownika o nowej wiadomości, to nie powinno to wpłynąć na logikę aplikacji. Warstwa Infrastruktury powinna zatem przekazać do warstwy Aplikacji gwarancję, że wszystko między nimi będzie tak, jak wcześniej. Taką gwarancją będzie spełnienie przez infrastrukturę określonego przez aplikację interfejsu. Zatem, skoro interfejsy zostają w warstwie Aplikacji, a ona jest z kolei podzielona na moduły, to wychodzi nam, że wewnątrz modułu powinniśmy również zawierać interfejsy infrastrukturalne.

Wydzielanie modułu a warstwa Interfejsu Użytkownika

Podczas wydzielania modułu do postaci zewnętrznego serwisu, nie jest powiedziane, że komunikacja z tym serwisem będzie odbywała się tak, jak do tej pory. Przykładowo, to, że funkcjonalność ustalania promocji miała własne kontrolery nie oznacza, że po wyniesieniu modułu będzie on dalej komunikował się z aplikacją przez te właśnie kontrolery. Możemy chcieć tą komunikację oprzeć np. o systemy kolejkowe. To właśnie jest powodem, dlaczego warstwa Interfejsu Użytkownika nie powinna zostać zawarta wewnątrz modułu.

Nie twórzmy modułów na siłę

Według zasad Domain Driven Design, moduły powinniśmy tworzyć jedynie wtedy, kiedy wewnątrz aplikacji tworzy się odrębna, spójna ze sobą część aplikacji. Powinniśmy wtedy zauważyć, że język, którym posługujemy się zaczyna tworzyć dodatkowy kontekst. Przywołajmy w tej chwili jeden z klasycznych przykładów z dziedziny e-commerce. Encja Product może mieć przypisaną do siebie promocję. W podstawowej wersji jako promocję traktujemy dodatkowe pole, które określa promocyjną kwotę, która jest odejmowana od regularnej ceny produktu. W momencie, kiedy potrzeby biznesowe zaczynają dyskutować o tym, że będziemy mieli różne rodzaje promocji, kalkulator określający kolejność ich naliczania oraz promocje naliczane dla specjalnej grupy klientów – powinna zaświecić nam się lampka, że powinniśmy utworzyć nowy moduł, który zaspokoi potrzeby biznesowe.

Nie taki Modularny Monolit straszny…

Na pierwszy rzut oka może wydawać się, że architektura Modularnego Monolitu jest trudna i skomplikowana. Po zapoznaniu się m.in. z tym, co składa się na warstwę Domeny oraz jak zaimplementować system skoncentrowany na zdarzeniach może okazać się, że nie taki wilk straszny, jak go malują 🙂

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.