sobota, 22 października 2022

Komunikacja między Modułami Monolitu: zlecanie prac


Realizacja nowej funkcjonalności biznesowej często wykracza poza ramy jednego Modułu Monolitu. Jako że muszą się ze sobą komunikować m.in. w celu oddelegowania zadań dalej - warto przeanalizować sobie rodzaje komunikacji jakie możemy zaimplementować.

 

🔌 Wariant #1: Fasada

Moduł B jest całkowicie zależny od modułu A

W tym przypadku moduł bieżący (B) w którym piszemy zleconą nam funkcjonalność musi oddelegować część prac do innego zewnętrznego modułu (A). Delegowanie pracy w praktyce polega na wywołaniu void'owskiej metody (Command) Fasady leżącej w zewnętrznym module

W opisywanym przypadku moduł bieżący jest tym znajdującym się w dole strumienia. Całkowicie musi się on poddać kontraktowi komunikacji ustanowionego przez upstream'owy moduł zewnętrzny. Moduł A wie jakich składników będzie potrzebował do wykonania swojego zadania, dlatego stanowią one parametry metody Fasady. W gestii modułu downstream'owego leży to by takowe parametry dostarczyć - warto się zastanowić czy posiadanie przez niego takich informacji jest w ogóle poprawne. 

Warto podkreślić, że moduł A nie wie nic o swoich klientach - udostepnia API (Interfejs Fasady) do komunikacji z innymi modułami i nie dba o to czy jest ich pięciu czy nie ma ani jednego. W tym przypadku to moduł zewnętrzny A jest niezależny od innych modułów. Idąc tym tropem: odpowiedzmy sobie najpierw na pytanie dlaczego moduł bieżący B do wykonania w pełni swojej funkcjonalności musi oddelegować część pracy do zewnętrznego modułu - czy granice zostały poprawnie wytyczone?

Moduł B z kolei wie dokładnie o istnieniu innego modułu A i jak się z nim komunikować (API Fasady). Jest to więc jawna deklaracja komunikacji jednostronnej: 

  • wiem co chcę zrobić,
  • wiem kto to zrobi,
  • wiem dokładnie jak zmusić tego kogoś do wykonania tej czynności.

 

📨 Wariant #2: Event'y

W tym wypadku moduł bieżący (A) w którym dodajemy funkcjonalność, informuje inne bliżej nieokreślone moduły o zaistnieniu pewnego zdarzenia wewnątrz swoich granic. Robi to emitując Event Aplikacyjny (Sync/Async) - nie troszcząc się o to kiedy i przez kogo zostanie on obsłużony.  

Moduły B i C są całkowicie zależne od Modułu A

Zaimplementowanie Event'u w Warstwie Aplikacji wychodzącego poza granicę bieżącego modułu jest swego rodzaju zdefiniowaniem kontraktu komunikacji

Moduł bieżący (A) nie dba o to czy Event'y zostaną obsłużone, istotne jest to tylko dla zewnętrznych modułów (downstream'owych) które decydują się na nasłuchiwanie na tego typu zdarzenia.

Downstream'owe moduły B i C muszą dostosować się do kontraktu ustalonego przez moduł A. Muszą one być w stanie wykonać swoją pracę na postawie danych znajdujących się w Event'cie. 

W tym wypadku, nie możemy powiedzieć że moduł A chce oddelegować część pracy do innych modułów. Jedynie daje im znać, że coś się u niego zdarzyło. 

Jest to więc całkowicie inna sytuacja niż gdyby miał on skorzystać z Fasad modułów B & C wywołując ich metody void'owskie. 

Możnaby ponownie wypisać stwierdzenia jak w przypadku poprzedniej sekcji - Stan bieżącego modułu uległ zmianie:

  • nie wiem kogo to interesuje,
  • nie wiem czy będzie miało to jakiekolwiek konsekwencje.

 

🔎 Porównanie

W przypadku korzystania z Fasady niejawnie zakładamy, że coś musi się zadziać i nie możemy obejść się bez tej funkcjonalności z zewnętrznego modułu. Wydaje się, że wybierając takie rozwiązanie funkcjonalność wykonywana za Fasadą (upstream) jest naprawdę ważna. 

W przypadku emitowania Event'ów przez moduł bieżący - nie dbamy o to jaki klient/klienty obsłużą to zdarzenie. Zastanawiam się czy z takim podejściem, obsługujące to zdarzenie moduły downstreamo'we wykonują funkcjonalności drugorzędne... tak - ale tylko z perspektywy bieżącego modułu.

Bieżący moduł komunikacja z Fasadą emitowanie Event’ów
Hierarchia downstream upstream
Czy tworzy kontrakt nie tak
Czy wie kto będzie wykonywał czynność tak nie
Czy mamy pewność wykonania zleconych prac tak nie
Czy konsekwencje prac są znane tak nie
Sposób komunikacji Sync Sync/Async

Moduły w Niebieskiej Książce Eric'a Evans'a


Large Scale Domain Concept

 
Eric sugeruje by rozpatrywać moduły jako bloki budulcowe taktycznego DDD takiej samej rangi jak Agregaty, Encje czy Value Object'y. Jest to koncept większej skali, grupujący inne mniejsze powiązane ze sobą koncepty domenowe. Od strony kodu źródłowego moduły będą reprezentowane przez konkretne namespace'y zawierający obiekty i interfejsy.
  
Moduł pochodzi z modelu domenowego, a jego nazwa z Ubiquitous Language - nie powinien więc on być tylko elementem kodu źródłowego służącego do zredukowania złożoności. Ułatwia to komunikację z ekspertami domenowymi ponieważ możemy szybko ustalić kontekst poruszanego problemu czy też posługiwać się bardziej ogólnym pojęciami rozumianymi przez każdą ze stron.

Moduł skupiający mniejsze domenowe pojęcia
Spoglądając na aplikację z lotu ptaka, wprowadzone moduły sprawiają, że zyskujemy nową perspektywę wglądu w tworzony system. Możemy spojrzeć na to w jaki sposób koncepty większej skali ze sobą współpracują, bez niepotrzebnego rozpraszania się złożonością (liczne klasy i interfejsy) którą heremtyzują. 

Możliwość spojrzenia na aplikację monolityczną z wysokości nie jest sprawą oczywistą i dostępną od tak. Najpierw należy wykonać pracę analizującą dokładną zawartość modułów oraz ustalić relację między nimi nazwiązane. Niewątpliwie jest to opłacalna inwestycja ponieważ daje nam możliwość lepszego zrozumienia domeny problemu. Na podstawie takiego przeglądu możemy dojść do wniosku, że niektóre z modułów mają zbyt dużo odpowiedzialności lub relacje między modułami są nielogiczne lub dwukierunkowe.    


Relacje z innymi Modułami 

Relacje między modułami

Dzieląc klasy i interfejsy na moduły wartoby było monitorować ich relacje z innymi modułami. Nie powinniśmy rozpatrywać zależności modułów między sobą jako coś złego, to normalne że komunikują się ze sobą nawzajem. Relację można traktować jako element modelu i wartość samą w sobie.
 
Uważnie śledząc kierunek relacji modułów możemy sprawdzić w jaki sposób moduły są od siebie zależne. Powinniśmy pilnować by liczba relacji w module lokalny do modułów zewnętrznych była jak najmniejsza i jednokierunkowa. Jeżeli tak nie jest to warto przeanalizować funkcjonalności w innych modułach od których moduł lokalny jest zależny. 
 
Jeżeli koncept domenowy z modułu zewnętrznego jest spójny z tymi znajdującymi się w module lokalnym wszystko wskazuje na to, że to w nim powinna być umiejscowiona funkcjonalność. 


Low Coupling & High Cohesion

 
Zasada jak najmniejszej ilości powiązań (Low Coupling) jest ściśle związana z zasadą wysokiej spójności (High Cohesion) konceptów/reguł/logiki. Jeżeli moduł ma wiele powiązań z innymi modułami (High Coupling) to znaczy, że funkcjonalności dotyczące jednej dziedziny problemu zostały rozbite na kilka modułów - stąd potrzeba wielu powiązań między nimi. 
 
Istotną korzyścią płynącą ze stosowania się do tych zasad jest fakt, że o wiele łatwiej pracować z wysoce spójnymi i luźno powiązanymi modułami. Programiści wprowadzający zmiany w takim obszarze będą mieli do czynienia z klasami reprezentującymi tylko konkretną część domeny. 
 
Liczba klas w module będzie więc ograniczona, dlatego nie powinniśmy być przeciążeni kognitywnie analizując je wszystkie razem w celu zrozumienia pełnego konceptu. Ewentualne powiązania do innych bytów będą nieliczne i jasno zdefiniowane.   
       
Warto zwrócić uwagę, że pilnowanie by implementowany byt był luźno powiązany i wysoce spójny powinniśmy stosować zarówno podczas pracy z modułami jak i obiektami - zasada jest uniwersalna.


Ciągły Refactor

 
Tak jak pozostałe building block'i taktycznego DDD wymagają przeprowadzania ciągłej refaktoryzacji - tak samo powinniśmy postępować z modułami. 
 
W przypadku modułów refaktoryzacji zazwyczaj powinno być poddawane umiejscowienie konceptów/reguł/logiki w nich zawartych. Jeżeli łamiemy zasadę wysokiej spójności powinniśmy przenieść funkcjonalność do innego modułu. 
 

Moduł A posiada niespójny koncept

 

Kiedy żaden z istniejących modułów nie wydaje się wystarczająco odpowiednim miejscem należy rozważyć utworzenie nowego modułu. Wskazówki co do jego potencjalnej nazwy może dać rozmowa przeprowadzona z przedstawicielem biznesu. Zazwyczaj jest to wcześniej nieodkryty bądź niejawny koncept domenowy.

Jak zauważa Evans, programiści nie są skorzy do przeprowadzania refaktoru modułów i zadowalają ich granice/nazwy ustalone na samym początku ich powstania, a jak często wspominał, pierwotny model jest zazwyczaj naiwny.
 
Można wyciągnąć wnioski, że są one kłopotliwe do refaktoru ponieważ:
  • wymagają znacznie większego zakresu prac niż refaktor obiektów,
  • wymagają spojrzenia na kod z innej perspektywy w celu dostrzeżenia ewentualnych miejsc do poprawy,  
  • trudno wpaść na lepszy pomysł podziału na moduły
    bądź programiści w ogóle nie biorą pod uwagę tego, że ten aspekt projektu mógłby zostać ulepszony.
Warto więc rozpatrywać następujące aspekty modułów i zastanowić się nad ich poprawnością:
  • granice, 
  • nazewnictwo, 
  • ukryte koncepty, 
  • nieaktualne byty.
 
Powinniśmy liczyć się z tym, że prace refaktoryzacyjne w tych obszarach nie będą tak często przeprowadzane i same moduły pod względem ich aktualności "będą stały w tyle" za resztą konceptów.  

 

Opracowanie na podstawie

📚 Eric Evans "Domain Driven Design: Tackling Complexity in the Heart of Software" str. 109-115.

sobota, 15 października 2022

Mockowanie Repozytoriów

Testowanie jednostkowe klas posiadających jako zależności Repozytoria np. Seriwsów/Command Handler'ów może być kłopotliwe. Jeżeli zdycydujemy się na sztuczne implementacje Repozytoriów tzw. *InMemoryRepository – powinniśmy być świadomi kwestii związanych z ich późniejszym utrzymaniem – jakie problemy rozwiązują, a jakie stwarzają. Kod metody poddawanej testom nie jest tym nad czym chaciałbym się skupić w tym wpisie, istotne jest jedynie wywołanie w niej metody Query (CQS) Repozytorium.

 

Podejścia do Mock’owania Repozytoriów

Na potrzeby testu jednostkowego musimy odtworzyć wierną kopię klasy produkcyjnej. Np. posiadamy interfejs repozytorium OrderRepository:

interface OrderRepository 
{
	/** @return Order[] */
	public function getUnpaidOrders(): iterable;
}

 

Abstrahując od tego jak wyglądałaby implementacja klasy produkcyjnej OrderMySQLRepository, skupmy się na jej odpowiedniku utworzonym na potrzeby testu:

final class OrderInMemoryRepository implements OrderRepository
{
	/** @param Order[] $orders */
	public function __construct(private array $orders) {}
	
	public function getUnpaidOrders(): iterable
	{
		return array_filter(
			$this->orders,
			static fn ($order) => $order->isUnpaid() 
		);
	}
}


Implementacja metody OrderInMemoryRepository::getUnpaidOrders została tak napisana by zawsze zwracała odpowiednią kolekcję zamówień - logicznie zgodną z nazwą metody. Dzięki takiej implementacji dysponujemy całkiem poręcznym narzędziem do pisania testów jednostkowych. W przypadku gdy interfejs posiadałby by inne metody – też w takim stopniu odtwarzające rzeczywistą implementację – moglibyśmy używać tego samego obiektu OrderInMemoryRepository w wielu przypadkach testowych. Jest to dość złożony TestDouble typu Fake, który niejako posiada więdzę na temat tego jak działa produkcyjna implementacja.

Moglibyśmy przyjąć inną taktykę w której OrderInMemoryRepository jest maksymalnie okrojony z implementacji:

final class OrderInMemoryRepository implements OrderRepository
{
	/** @param Order[] $orders */
	public function __construct(private array $orders) {}
	
	public function getUnpaidOrders(): iterable
	{
		return $this->orders;
	}
}

 

Jako, że OrderInMemoryRepository::getUnpaidOrders zawsze zwraca taką samą kolekcję jak ta dostarczona do konstruktora – można powiedzieć, że jest to pewna forma Stub’a.Ciężar doboru odpowiednich danych wejściowych spoczywa na metodzie w której sztuczne Repozytorium zostało utworzone – np. Fixtura, metoda testowa/setUp.

Korzystanie z wariantu Stub jest niemal identyczne jakbyśmy używali biblioteki mokującej np. MockObject czy Prophecy. W dwóch przypadkach musimy staranie dobrać zwrotkę metody do aktualnego przypadku testowego. Pomiędzy przygotowaną kolekcją obiektów wrzucaną w metodę obiektu z biblioteki mokującej/czy konstruktorem Stub *InMemoryRepository, a zwróceniem danych z wywołanej metody Repozytorium, nic więcej się nie dzieje - nie następuje żadne filtrowanie.

Fake Repository

❌ jest silnie sprzężony z Produkcyjnym Repozytorium (po każdej zmianie repozytorium MySQL, musimy sprawdzić czy *InMemoryRepository nie wymaga modernizacji, czy w dalszym ciągu wiernie odzwierciedla prawdziwą implemetację),

✅ koncept stojący za nazwą metod jest jawnie zapisany w kodzie,

✅ odpowiednio przygotowany może być wykorzystany w wielu przypadkach testowych.

 

Stub Repository

❌ musi być indywidualnie przygotowany pod każdy przypadek testowy. Dobór zwrotek jego metod stoi po stronie developera – jest więc nie do końca jawny,

❌ w przypadku zmiany metody Produkcyjnego Repozytorium, musimy zweryfikować wszystkie przypadki testowe kodu, który bezpośrednio polega na tej metodzie. Czy w dalszym ciągu przygotowane przez developera obiekty zwracane przez metodę sztucznego Repozytorium spełniają logikę która stoi za ich nazwą? 

✅ zaletą jest mniej kodu do utrzymania, ale to tylko dlatego, że koncepty te nie są zawarte w kodzie z czego wynikają opisane wyżej problemy. W przypadku gdy istnieje tylko jeden klient metody Repozytorium możemy zastosować Stub’a – wraz z rozrostem repozytorium o nowe metody, zwiększeniem się liczby klientów należy rozwarzyć refaktoryzację w stronę Fake’a.

Koniec końców, czy stosujemy jedną czy drugą metodę – cały czas jesteśmy w tej samej sytuacji – musimy inscenizować dostarczanie danych do testowanej metody. W przypadku Fake’ów z bardziej skomplikowaną implementacją, dysponujemy po prostu bardziej wszechstronnym narzędziem kosztem jego późniejszego utrzymania. 

 

Utrzymanie klasy Fake InMemory Repository


Nowe wymagania biznesowe w czasie kolejnych iteracji wymuszają zmianę repozytoriów - prawdziwych i sztucznych.

Przedstawione na diagramie zmiany interfejsu Repozytorium, są oczywiście napędzane decyzjami biznesu co do nowych feature’ów. Jak widać testując jednostkowo metodę wywołującą metody Repozytorium, musimy wprowadzić do systemu testowego nowy byt. Każda zmiana implementacji repozytoriów będzie wymuszała na nas prace związane z jego utrzymaniem. Można wywnioskować, że klasy *InMemoryRepository to dodatkowy koszt jaki musimy ponieść za cenę bezpieczeństwa czyli łatwiejszego wprowadzania zmian w projekcie.

Błędne będzie jednak założenie, że Mock’owanie Repozytoriów zawsze daje nam wspomniane bezpieczeństwo. W przypadku gdy pomiędzy Klasą Produkcyjną, a Test Double pojawią się rozbieżności w działaniu – wprowadzone nieświadomie/omyłkowo przez programistę – testy jednostkowe mogą przechodzić na zielono, podczas gdy tzw. Produkcja będzie rzucał błędami lub działała niezgodnie z oczekiwaniami. Jest to ryzyko z którego powinniśmy zdawać sobie sprawę podczas synchronizacji Fake InMemory Repository z Repozytorium Produkcyjnym.

Warto też zauważyć, że im bardziej uniwersalna jest metoda repozytorium tzn. posiada filtry, tym trudniej utworzyć jej imitację potrzebną do testów jednostkowych. Odtworzenie poprawnego zachowania wszystkich filtrów może być skomplikowane, a wprowadzona złożoność z tym zwiazana - podatna na błędy w przyszłości. Przy tego typu pracach należy zachować szczególną ostrożność, gdyż jak na ironię, nie posiadamy testu jednostkowego klasy InMemory.

Jeżeli zaś chodzi o sam design, na tak uniwersalnej metodzie Repozytorium polegać będzie zapewne dużo klientów, co będzie skutkowało pojawieniem się zależności między nimi czego wspólnym mianownikiem jest wspomniana „uniwersalna” metoda Repozytorium.

 

Podejście funkcyjne

Możemy przyjąć zupełnie inną taktykę. Zamiast zastanawiać się nad najlepszym sposobem testowania metod które pozyskują dane z repozytoriów - nie testować ich w ogóle. Wiąże się to z destylacją logiki biznesowej zawartej w testowanej metodzie poprzez wydzielenie wszystkich wyowłań metod Query (CQS) do warstwy wyżej.

Metoda Serwisu samy pozyskuje dane z Repozytorium. Taka implementacja wymaga ich mock'owania.

 

W wyniku takiej zmiany design’u: wydestylowany byt staje się Serwisem Domenowym, a warstwa która pozyskuje Encję/Agregaty/VO z repozytoriów – Serwisem Aplikacyjnym, którego domenowy odpowiednik otrzymuje wszystkie potrzebne do przeprowadzenia operacji obiekty, jako parametry metody.

 

Testowany Serwis Domenowy poddawany nie wie nic o klasach Repozytoriów.

Testowaniu jednostkowem podlegać będzie wtedy tylko Serwis Domenowy, dla którego nie będziemy musieli już szykować żadnego Test Double Repozytorium. Serwisy Aplikacyjne będą testowane tylko Integracyjne.

Niektóre przypadki mogą okazać się problematyczne do zaimplementowania – bo jak mamy rozwiązać problem jakiegoś bytu który jest pobierany z repozytorium na podstawie jakiejś decyzji biznesowe?

Podsumowanie

Jak widać mockowanie repozytoriów wiąże się z pewnego rodzaju problemami z których należy sobie zdawać sprawę – jak zwyklę w programowaniu nic nie jest czarno białe i rozwiązując pewien problem godzimy się na wprowadzenie mniejszego. Testy Integracyjne niejako rozwiązują sprawę tworzenia Test Double Repozytoriów w ogóle, niemniej jednak nie zawsze takowe z łatwością można wprowadzić w projekcie.


 

 

Metoda Prywatna vs. Value Object

🔒 Metoda Prywatna

Na samym początku musimy odpowiedzieć sobie na pytanie dlaczego tworzymy metody prywatne:

  • potrzebujemy współdzielić kod w conajmniej dwóch innych metodach danej klasy,
  • chcemy zredukować złożoność i ukryć pewny spójny logicznie kawałek kodu,

Niewątpliwie są  to dobre powody, ale stosowanie metody prywatnej nie jest najlepszym sposobem na uwspólnianie kodu czy redukowania jego złożoności.  

 

💡 Narodziny Konceptu Domenowego

Mamy klasę ShippingService z metodą publiczną getCost zwracającą cenę wysyłki. W samym ciele tej metody zaczyna rosnąć ilość linii kodu weryfikującego czy dostawa powinna być darmowa czy nie. Logika nie jest banalna i na podstawie nowych wytycznych biznesu koncept darmowej wysyłki jest dopiero implementowany w kodzie.

final class ShippingService 
{
	public function getCost(...): Money 
	{
		// call private method
	}

	private function isFree(...): bool 
	{
		// using class dependencies
	}
}

Nie wchodząc w szczegóły implementacji, skupmy się na samym fakcie powstania nowej metody prywatnej ShippingService::isFree - czyli zredukwaniu złożonoście metody głównej ShippingService::getCost. Metoda prywatna enkapsuluje warunki podejmujące decyzję czy wysyłka jest darmowa. Wyłonił się tutaj nowy koncept domenowy i niestety jest on zamodelowany za pomoca metody prywatnej.

Problem pojawia się gdy inny serwis również będzie musiał operować na koncepcie darmowej wysyłki. W tym wypadku potrzebny byłby refaktor - upublicznienie metody bądź wydzielenie jej do nowego serwisu nie wydaje się też dobrym rozwiązaniem. Więc jeżeli pozostawimy to w obecnej formie musimy liczyć się z następującymi konsekwencjami:     

❌ niemożliwe jest ponowne wykorzystanie metody w innym obszarze,

❌ istnieje ryzyko duplikacji logiki - świadomej (przez lenistwo programisty) / nieświadomej (trudniej odnaleźć logikę w metodzie prywatnej niż w osobnej klasie).

❌ modelowanie konceptów domenowych za pomocą metod serwisowych ogranicza nasze możliwości co do rozwoju konceptów które odzwierciedlają. 

Koncept domenowy jest ukryty

 

⚠️ Metoda jako wyrażanie Konceptu Domenowego

Przechowywanie konceptów domenowych w metodach jest bardzo ograniczające w ich potencjalnym dalszym rozwoju. Istnieje bardzo duża szansa, że sam koncept domenowy z czasem będzie się rozwijał nabierając pełniejszego kształtu. 

Jeżeli obecnie oczekujemy tylko odpowiedzi na to czy dla danej kwoty wysyłka jest darmowa to w przyszłości możemy potrzebować np. samego progu cenowego darmowej wysyłki. Wymagałoby to od nas utworzenia kolejnej wyspecjalizowanej metody. 

Co więcej przy nieznajomości projektu możemy mieć problemy z ustaleniem czy taki byt w ogóle istnieje w projekcie - z perspektywy katalogów jest on niewidoczny. 

Dodatkowo cementujemy logikę biznesową wraz ze sposobem pozyskiwania danych (pobieranie danych z repozytorium) co może prowadzić do ich parametryzacji bądź wydzielenia części logiki do kolejnych metod prywatnych.

 

🧩 Value Object

Nie jest to wpis o Value Object'cie samym w sobie dlatego tylko wypiszę jego przewagi względem stosowania metod serwisowych:

  • jest uniezależniony od kontekstu wywołania,
  • nazwa klasy może nadać mu bardziej abstrakcyjny charakter (metoda shippingDays na Value Object ShippingTime::days),  
  • bardzo łatwy w testowaniu,
  • jawna i niezależna jednostka przechowująca wiedzę domenową,
  • widoczny z poziomu katalogów,  
  • bardzo łatwy do ponownego wykorzystania w innym obszarze. 

 

♻️ Refaktoryzacja do Value Object

Pierwszym etapem refaktoryzacji do Value Object'u może być transformacja do prywatnej metody statycznej. W tym wypadku wszystkie parametry wejściowe stałyby się w dalszym toku przekształceń parametrami konstruktora Value Object'u.

Przenosząc odpowiedzialności do Value Object'u i powiązanej z nim Fabryki redukujemy złożonośc samego Serwisu w którym logika ta się wcześniej znajdowała. Zależności przechodzą z Serwisu do Fabryki i to właśnie ona od teraz jest wstrzykiwana do klasy Serwisowej. 

Przekształcenie metody prywatnej na Value Object