DDD pozwala na zaprojektowanie i rozwój warstwy domeny oraz kontrolę nad jej złożonością szczególnie w przypadku dużych aplikacji. Jednak, jak wykazała praktyka, techniki stosowane w DDD przynoszą wymierne korzyści również w przypadku mikrousług. W tym artykule opiszę architekturę kodu mikrousługi przygotowaną w oparciu o Domain Driven Design.


Adam Janota. Software Engineer w Allegro. Od dziewięciu lat w jednej firmie. Pracuje w technologiach: Java (Spring Framework), JavaScript (React.js, Redux, RxJS, Node.js, Express, Koa). Poznał problematykę dużych aplikacji zaprojektowanych jako monolit oraz brał udział w ich refactoringu. Obecnie odpowiedzialny m.in. za rozwój mikrousług backendowych i aplikacji frontendowych. Zwolennik projektowania architektury kodu zgodnego z Domain Driven Design dla dużych aplikacji i mikrousług.


Przechodzimy na mikrousługi

Załóżmy, że w naszej firmie zapadła decyzja o transformacji architektury aplikacji z monolitu na podejście oparte na mikrousługach. Po czasie powstaje kilkadziesiąt/kilkaset mikrousług: większość z własnymi bazami danych, a same usługi często przesyłają między sobą wiele informacji. W tej sytuacji mogą pojawić się problemy z zarządzaniem całym rozwiązaniem, a rozwiązaniem może okazać się Domain Driven Design.

Problem: zarządzanie domeną

Architektura monolitu charakteryzuje się zazwyczaj dużą złożonością domeny, która może być dodatkowo rozproszona po kontrolerach czy kodzie procesów backgroundowych. Często oczekuje się, że przejście z monolitu na mikrousługi rozwiąże problem złej architektury kodu: zwiększy jego uporządkowanie i czytelność. Jednak bez zmiany podejścia do architektury kodu spotkamy się z tymi samymi problemami co w przypadku monolitu.

Pamiętajmy, że sama nazwa “mikrousługa” oczywiście nie chroni przed stopniowym powiększaniem się jej kodu, dlatego potrzebujemy takiej architektury, która pozwoli nam zarządzać warstwą domeny usługi każdej wielkości.

Problem: co robi usługa?

W przypadku gdy musimy przeanalizować usługę, której nie znamy, najpierw chcemy szybko sprawdzić co dana usługa robi. W dalszej kolejności możemy chcieć sprawdzić, jak to robi. Potrzebujemy takiej architektury kodu mikrousługi, która w odpowiedni sposób zarządza poziomami abstrakcji kodu.

Problem: z kim komunikuje się usługa?

W przypadku problemów z działaniem usługi często okazuje się, że przyczyną są problemy z komunikacją ze “światem zewnętrznym” np. problemy wydajnościowe bazy danych, niedostępność lub dłuższe czasy odpowiedzi innej usługi, od której usługa chce pobrać dane, problemy z systemem plików (np. brak miejsca na dysku). Analiza problemu wymaga szybkiego pozyskania wiedzy na temat tego, z którymi usługami czy bazami danych komunikuje się dana usługa. W przypadku gdy miejsca, z których komunikujemy się ze “światem zewnętrznym” są rozsiane po całym kodzie, szybkie pozyskanie takiej wiedzy może nie być proste. Architektura usługi powinna więc umożliwiać szybkie i łatwe dotarcie do tych informacji, bez potrzeby przeszukiwania całego kodu pod kątem, gdzie wykorzystujemy np. restowego klienta.

Problem: testowanie kodu

Kod domeny rozsiany po całej usłudze jest trudny do zlokalizowania, a umieszczony bezpośrednio w kontrolerach jest dodatkowo trudny do testowania. Dlatego często dochodzi do testowania kodu domeny za pomocą testów integracyjnych. Wydłuża to czas trwania testów, ponieważ testy domeny zazwyczaj wymagają sprawdzania wielu przypadków, a testy integracyjne trwają dłużej niż jednostkowe. Gdyby istniała architektura, która dzieli kod według sposobów jego testowania, znacznie ułatwiłoby to pisanie testów.

Rozwiązanie: Domain Driven Design

Powyższe problemy rozwiązuje architektura kodu zaproponowana przez Erica Evansa: Domain Driven Design (DDD). W powszechnej architekturze warstwowej wyróżniamy warstwę prezentacji, modelu i dostępu do danych, w której model jest zazwyczaj wyrażany tylko przez encje. DDD dzieli warstwę modelu na dwie dodatkowe warstwy: warstwę logiki aplikacji oraz warstwę logiki domeny. W ramach warstwy logiki domeny DDD wprowadza oprócz encji dodatkowe elementy tzw. Building Blocks. Taka struktura daje możliwość modelowania domeny tak, aby realizowała dokładnie procesy biznesowe.

Poniższy rysunek przedstawia schemat architektury kodu mikrousługi zaprojektowanej za pomocą DDD.

Zobaczmy jak ta architektura może pomóc w rozwiązaniu powyższych problemów.

Domain Driven Design: zarządzanie domeną

Wszyscy rozumiemy czym jest domena w projekcie. Ale czym jest domena na poziomie kodu? Jaki kod powinna zawierać, a jakiego nie powinna? W prezentowanym powyżej schemacie domena jest wyrażona jako warstwa logiki domenowej.

W architekturze MVC często jest ona oparta na encjach. Przykładowo encja Client posiada kod umożliwiający jej zapis np. do bazy danych oraz cały pozostały kod, który związany jest z operacjami na kliencie. W rezultacie w projekcie obecne były pliki, które zawierały parę tysięcy linii kodu (antywzorzec, tzw. God Class).

Problemy z utrzymaniem i testowaniem kodu dużych plików są jednym z głównych powodów dzielenia kodu na mniejsze części. Jednak brak precyzyjnego określenia czym jest domena na poziomie kodu i brak planu w jaki sposób dzielić kod, generował podobne problemy z utrzymaniem, co w przypadku dużych plików.

Czym jest więc kod domeny?

Kod domeny jest bezpośrednim odwzorowaniem opisu procesów biznesowych​, za które jest odpowiedzialna usługa. Najczęściej rozmawiamy na temat procesów biznesowych na spotkaniach z ekspertem domenowym. Jest to osoba, która bardzo dobrze zna tajniki prowadzonego biznesu, rynek, konkurencję, posługuje się wskaźnikami, analizami itd. Jednak najczęściej nie posiada wiedzy technicznej.

Ekspert domenowy raczej powie: “Klient składa zamówienie” niż “Klient zapisuje dane zamówienia jako dokument w bazie Mongo”. Na czym polega różnica? Biznesu czyli domeny nie interesuje w jakiej bazie mają być zapisane dane. Więc kod domeny nie komunikuje się ze “światem zewnętrznym”​. To nie jest jego odpowiedzialność.

Jak to jest zrealizowane na poziomie kodu? Jeśli jednostka kodu powinna komunikować się ze “światem zewnętrznym” to w domenie umieszczamy jedynie interfejs tej jednostki. Na przykład jeśli jest potrzeba dodania nowego zamówienia, to domena zawiera jedynie interfejs repozytorium OrderRepository z metodą addOrder. Domeny nie interesuje gdzie to zamówienie będzie zapisane. Dlatego implementacja interfejsu już nie należy do domeny i leży poza nią. Gdzie? O tym dowiesz się czytając dalej.

Ekspert domenowy nie powie również: “Klient zapisuje dane zamówienia za pomocą Spring Data MongoDB”. Dlaczego? Bo nie interesuje go jakiego frameworka programista użyje do zapisu danych. Dlatego domena nie zawiera kodu, który jest właściwy dla jakiegokolwiek frameworka​. Tylko czysta Java lub czysty JS itp. Bez frameworków. Domena nic nie wie, jakiego frameworka używamy.

W takim razie co powinna zawierać domena?

Powtórzmy: kod domeny jest bezpośrednim odwzorowaniem opisu procesów biznesowych.

Najlepiej na przykładzie np. usługi odpowiedzialnej za obsługę zamówień.

Dodajemy do katalogu “src” projektu katalog “domain”. Jest to miejsce, gdzie będzie cały​ kod domeny.

Ekspert domenowy mówi: “Klient składa zamówienie”.

Mamy więc dwa rzeczowniki, które są elementami domeny. Tworzymy katalogi “client” i “order” w katalogu “domain”.

Klient — encja Client w katalogu “client”, prosta klasa, która opakowuje dane.

Zamówienie — encja Order w katalogu “order”, prosta klasa, która opakowuje dane.

Składa (technicznym językiem: zapisuje) — repozytorium jako interfejs OrderRepository z metodą addOrder, która przyjmuje dane zamówienia i zwraca encję Order.

Gdyby ekspert domenowy powiedział: “Aktywny klient składa zamówienie” to domena zawierałaby jeszcze walidator klienta ClientValidator: prosta klasa, która sprawdza status klienta w katalogu “client”.

A teraz “Aktywny klient składa zamówienie, na podstawie którego może zbudować obiekt faktury”? Do domeny dochodzi builder, który na podstawie zamówienia zwraca dane faktury InvoiceBuilder i interfejs repozytorium dla faktur InvoiceRepository z metodą addInvoice, która zwraca encję Invoice (powyższe w nowym katalogu “invoice”). Żadnych frameworków, żadnego kodu, który komunikuje się ze “światem zewnętrznym”.

Building Blocks

Cechą charakterystyczną podejścia Domain Driven Design jest to, że odwzorowujemy każdy element opisu procesu biznesowego jako wyraźny, osobny element np. dodawanie nowego zamówienia – repozytorium, sprawdzanie klienta – walidator, tworzenie faktury: builder. W przeciwieństwie do encji typu “God Class”, która zapewne miałaby wszystkie funkcjonalności ukryte w jednej klasie z metodami save, validateClient czy buildInvoice teraz wyraźnie pokazujemy jakie elementy tworzą domenę. DDD oferuje dużo większą liczbę elementów, za pomocą których możemy wyrazić procesy biznesowe. Są to tzw. Building Blocks i mogą nimi być m.in.:

  • encje,
  • agregaty,
  • repozytoria,
  • walidatory,
  • strategie,
  • buildery,
  • fabryki,
  • serwisy domenowe,
  • zdarzenia domenowe.

Building Blocks tworzą warstwę logiki domenowej modelu.

Poprawność

Domain Driven Design zakłada, że elementy warstwy logiki domenowej zawsze muszą być w poprawnym stanie. Jeśli zamówienie musi zawierać przynajmniej jedną pozycję zamówienia, nie można umożliwiać utworzenia zamówienia bez takiej pozycji. Na poziomie kodu zazwyczaj umieszcza się w konstruktorze zamówienia walidacje, które sprawdzają czy tworzony obiekt zamówienia jest poprawny.

Encja a agregat

Czasami encja jest zbyt “drobna”, by odwzorować dane pojęcie z domeny. Na przykład zamówienie, które posiada pozycje zamówienia jest zbyt dużym pojęciem jak na jedną encję. W tym przypadku stosuje się agregat, który zawiera encję główną i pozostałe — zależne.

Jeśli encja przyjmuje dane w konstruktorze i udostępnia wyłącznie metody zwracające dane (nie posiada metod ustawiających), to agregat dodatkowo udostępnia metody biznesowe do zarządzania encjami zależnymi.

Język

W kodzie, a w szczególności w kodzie domeny, używamy dokładnie tych samych określeń, których używa ekspert domenowy. Jeśli ekspert mówi “Klient składa zamówienie”, to w kodzie powinna być encja Client (nie User, Person czy Actor). Dokładnie te same pojęcia jakim posługuje się ekspert powinny znaleźć się w kodzie. Złamanie tej zasady utrudnia rozumienie kodu i wymaga dodatkowego wysiłku na mapowanie pojęć używanych przez eksperta na te użyte w kodzie. Ekspert domenowy i programista czytając kod, powinni używać tych samych pojęć. Jest to tzw. Wszędobylski język (Ubiquitous language).

Domain Driven Design: co robi usługa?

Do tej pory omówiliśmy domenę. Poznaliśmy pojedyncze elementy domeny. Jednak jak do tej pory potrzeba eksperta domenowego “Aktywny klient składa zamówienie” nie jest spełniona. Potrzeba jeszcze kodu, który użyje zdefiniowanych w warstwie logiki domenowej elementów. Tym kodem jest warstwa, która znajduje się powyżej domeny: warstwa logiki aplikacji​. W strukturze katalogów mamy nowy katalog “application”.

Tu można by zapytać o warstwę logiki jakiej aplikacji chodzi? Przez aplikację rozumiemy kod, który realizuje zbiór procesów biznesowych (takich jak powyższy “Aktywny klient składa

zamówienie”), gdzie każdy z procesów jest zdefiniowany w metodzie tzw. serwisu aplikacyjnego (np. OrderApplicationService z katalogu “application”). Serwisy definiują API aplikacji. Z tego API korzystają klienci aplikacji, którymi może być restowy kontroler, listener lub kod uruchamiany w cronie (i tylko z tego API: kod warstwy logiki domenowej nie powinien “wyciekać” do kodu klientów warstwy aplikacji).

Serwisy używają elementów warstwy logiki domeny (Building Blocks) i tworzą dany proces biznesowy. Serwisy aplikacyjne nie zawierają logiki biznesowej. Jedyne co mogą zrobić to użyć elementów z domeny.

Jeśli istnieje potrzeba szybkiego sprawdzenia co robi usługa warstwa logiki aplikacji jest jedynym miejscem, które wystarczy sprawdzić, aby mieć pełen obraz odpowiedzialności usługi. Klienci warstwy aplikacji zazwyczaj uruchamiają jedną metodę serwisu aplikacyjnego, czyli realizują jeden proces biznesowy. Jeśli istnieje potrzeba by dany proces biznesowy był uruchamiany np. w kontrolerze i skrypcie uruchamianym w cronie to jest gwarancja, że będzie realizowany dokładnie w ten sam sposób w obu miejscach.

Warstwa logiki aplikacji, podobnie jak warstwa logiki domenowej, powinna być wolna od kodu frameworków.

Serwis aplikacyjny zazwyczaj zwraca dane biznesowe w przypadku, gdy proces biznesowy w ramach metody serwisu zakończył się sukcesem lub kolekcję błędów w przeciwnym przypadku. Z metod serwisów aplikacyjnych korzystają klienci aplikacji. W przypadku mikroserwisów jest to najczęściej restowy endpoint (na schemacie katalog “api” — endpointy definiują API mikrousługi), rzadziej proces uruchamiany w cronie lub listener (w przypadku użycia zdarzeń domenowych). Zadaniem klientów jest (zgodnie z zadaniem warstwy prezentacji, do której należą) zaprezentowanie danych (lub błędów) w formacie właściwym dla danego klienta. Jest to również miejsce gdzie możemy użyć frameworka. Na przykład dla endpointu restowego możemy użyć Spring MVC (Java) lub Express / Koa (Node.js) i zwrócić otrzymane dane z odpowiednim kodem HTTP.

Domain Driven Design: z kim komunikuje się usługa

Potrzeba szybkiego zdobycia wiedzy na temat tego z kim komunikuje się usługa występuje bardzo często. Szybkiego, ponieważ zazwyczaj wiedza ta jest potrzebna w sytuacji problemów z prawidłowym działaniem usługi. Z pomocą przychodzi warstwa infrastruktury, która leży pod warstwą domeny.

O ile w warstwach domeny i aplikacji organizujemy pliki kodu po katalogach według funkcji biznesowych (np. client, order, invoice) tak w warstwie infrastruktury w pierwszej kolejności dzielimy kod według tego z czym komunikuje się usługa. Przykładowy podział może wyglądać następująco:

  • service: komunikacja z innymi usługami,
  • mysql: komunikacja z bazą danych MySQL,
  • filesystem: komunikacja z systemem plików,
  • elastic: komunikacja z silnikiem wyszukiwania ElasticSearch.

Są to katalogi umieszczone bezpośrednio w katalogu src/infrastructure, więc łatwo jest pozyskać wiedzę, z kim komunikuje się usługa. W tej warstwie znajdują się implementacje interfejsów repozytoriów z warstwy domeny oraz konfiguracje źródeł danych.

Domain Driven Design: testowanie kodu

Temat testowania kodu jest niejako podsumowaniem całej architektury. Mamy obecnie w katalogu src co najmniej trzy katalogi:

  • application: warstwa aplikacji,
  • domain: warstwa domeny,
  • infrastructure: warstwa infrastruktury.

Do tego dochodzą katalogi klientów warstwy aplikacji. Dzięki takiemu układowi testowanie poszczególnych warstw jest obecnie prostą sprawą. Warstwę logiki domeny testujemy jednostkowo z pokryciem bliskim 100%. Domena jest to serce biznesu i kod domeny musi działać prawidłowo, dlatego dobre przetestowanie domeny jest szczególnie ważne. I również względnie łatwe, ponieważ warstwa domeny nie zawiera kodu, który byłby powiązany z jakimkolwiek frameworkiem.

Warstwy logiki aplikacji nie testujemy jednostkowo, gdyż nie zawiera logiki biznesowej. Najczęściej testuje się ją pośrednio — testując klienta tej warstwy. Przykładowo jeśli usługa wystawia endpoint restowy, który jest klientem warstwy logiki aplikacji, to testujemy integracyjnie ten endpoint. Testy te mają na celu potwierdzenie, że dany proces biznesowy działa poprawnie.

Warstwę infrastruktury, podobnie jak warstwę logiki aplikacji, najczęściej testujemy pośrednio testami integracyjnymi klientów.

Podsumowanie

Każda mikrousługa odpowiada za grupę procesów biznesowych, które są blisko ze sobą powiązane np. procesy związane z składaniem zamówień. Architektura zaprojektowana na podstawie Domain Driven Design kładzie nacisk na modelowanie domeny w największym stopniu zgodnym z danym procesem biznesowym. Tak zaprojektowany model nie ukrywa detali procesów biznesowych, ale jasno wyraża je za pomocą całej gamy elementów domeny. Polecam przetestowanie podejścia opartego na DDD: bardzo możliwe, że stanie się ono oczywistym wyborem przy budowaniu kolejnych projektów.


Zdjęcie główne artykułu pochodzi z stocksnap.io.

Zapraszamy do dyskusji
Nie ma więcej wpisów

Send this to a friend