Event Modeling & Modularny Monolit | Od kolorowych karteczek do kodu poprzez TDD

Żuk gnojarz: https://www.rmfmaxxx.pl/scratch/staticImages/b2/349/b23497161e7eb449.jpg

Co ma wspólnego żuczek gnojarz z Twoim kodem? Dlaczego developer powinien jeść ravioli zamiast spaghetti? A bardziej po programistycznemu: dlaczego architektura jak poniżej nie sprawdza się dla większych aplikacji i jak to robić inaczej? Każdy Twój projekt powtarza tę samą mantrę w myśl Controller-Service-Repository? Albo zaczynasz projektowanie systemu od modelu bazy danych? Jeśli tak, to ten post może zmienić Twoje życie na kodach już na zawsze! A może nie wiesz jak przekładać EventModeling na kod? To też wpis też jest dla Ciebie! Zobaczysz to na domenie realnego projektu.

Architektura warstwowa w myśl Controller-Service-Repository.

Architektura warstwowa w myśl Controller-Service-Repository.

Ponadto dowiesz się

  • Jaką architekturę zastosować, aby TDD naprawdę miało sens?
  • Jak dzielić kod na moduły, które są niezależne od reszty projektu i pozwalają na izolowanie zmian?
  • Czym jest znana i lubiana z programowania obiektowego enkapsulacja na poziomie architektury aplikacyjnej?
  • Co dalej, kiedy rozpoznanie domeny problemu poprzez Big Picture EventStorming masz już za sobą?
  • Jak modelować rozwiązanie i automatyzację poprzez Event Modeling?
  • Jak przełożyć Event Modeling 1 do 1 na działający kod?
  • Dlaczego nie lubię ORMów takich jak Hibernate?

Wszystko to na przykładach kodu w TypeScript (NestJS) i w Kotlin (Hibernate)!

Z życia na kodach

Zmianę wyceniliśmy na jeden story point. To miał być przecież tylko mały refaktoring w tzw. module “users”. Ten moduł znałem też i ja, bo przecież każdy już dołożył do niego kiedyś swoją cegiełkę. W projekcie, gdzie od razu zobaczyłem katalogi takie jak: controllers, services, repositories, models — bez problemu odnalazłem model naszego “usera” rozpisany na ponad 300 linijek kodu (jak nie dopuścić do czegoś takiego opisuję w wiadomości o Bounded Contextach w ramach mailingu Domain-Driven Design). Bułka z masłem! Tylko mała zmiana i… BOOM! Bułka okazała się niezwykle czerstwa i trudna do przełknięcia, kiedy zobaczyłem, jak większość plików w projekcie została podkreślona czerwonym szlaczkiem.

Potem obejrzałem prezentację o „Hexagonal Architecture” i zacząłem ewangelizować zespół. Udało się pójść w tym kierunku. Pakiety podzielone per „feature”? Świetnie! Projekt greenfield? Wspaniale? Ale niestety w środku… dalej to samo spaghetti i zależności między modułami. Projekt legacy? Już po miesiącu! Gdzie te moduły skoro wszystko zależy od wszystkiego? Wcześniej serwis wywoływał serwis, teraz fasada wywołuje fasadę. No właśnie… Czas odstawić na bok spaghetti czy lazanię i spróbować dobrego przepisu na Ravioli!

Programowanie jest jak makaron. Najpierw spaghetti, potem lazania, a teraz ravioli.

Programowanie jest jak makaron. Najpierw spaghetti, potem lazania, a teraz ravioli.

Burza zdarzeń z ekspertami domenowymi

„Dobieraj rozwiązania do problemów, a nie problemy do rozwiązań” - to najbardziej podstawowa zasada w projektach informatycznych. Niestety często tak jest, że jeśli ma się w ręce tylko młotek, to wszędzie będziemy widzieć gwoździe. Developer znający tylko CRUD nie pomyśli np. o Event Sourcingu, a ktoś programujący tylko w Javie, zastosuje ją do wszystkich problemów, gdzie lepszy byłby np. Python. Dlatego, aby wybrać właściwe rozwiązania technologiczne, należy najpierw odpowiednio zrozumieć problem i zobaczyć czy tutaj lepszy może będzie śrubokręt od młotka. To podejście promuje też Domain-Driven Design, dzieląc wzorce z tej metodologii na przestrzeń problemu (ang. problem space) i przestrzeń rozwiązań (ang. solution space). Do tej drugiej przechodzimy najpierw po właściwej analizie i odpowiedzeniu sobie na pytanie, co i w jakim celu należy zrobić.

Masz jakiś problem?

Podczas organizacji ostatniego kursu CodersCamp byłem odpowiedzialny za przygotowanie części merytorycznej (materiały edukacyjne, sprawdzanie wiedzy poprzez zadania praktyczne, wymagania do projektów zespołowych). Byłem także mentorem jednego zespołu. I to właśnie domena największego otwartego kursu programowania w Polsce będzie służyć w tym wpisie za przykład. Zorganizowanie kursu, na który zgłasza się około 2 tys. osób, a potem tajniki programowania zgłębia ponad 200 uczestników z pomocą około 25 mentorów to wielkie wyzwanie. Po 6 edycjach przyszła pora na zautomatyzowanie procesów, które wykształciły się na przestrzeni lat. Wymaga tego skala wydarzenia. No właśnie, ale co takiego powinno być zautomatyzowane? Gdzie są te problemy do rozwiązania? I co potraktować priorytetowo?

EventStorming to the rescue!

EventStorming w pierwszej fazie, która nazywa się BigPicture, pozwala spojrzeć nam na działanie procesów zachodzących w naszej organizacji czy biznesie z lotu ptaka. Procesy te modeluje się za pomocą zdarzeń. Zdarzenie traktujmy, jako istotny moment (zazwyczaj zmiana stanu) dla modelowanego procesu, opisany w czasie przeszłym. Podczas sesji EventStormingu rozpoznaliśmy wszystkie procesy kursu, nie wchodzą w zbędne szczegóły. Patrząc na jej efekt poniżej, z łatwością dostrzeżesz niektóre ze zdarzeń. Zostały oznaczone jako karteczki w kolorze pomarańczowym.

Wycinek procesu rekrutacji uczestników kursu zamodelowany za pomocą EventStormingu.

Wycinek procesu rekrutacji uczestników kursu zamodelowany za pomocą EventStormingu.
(Kliknij na obrazek, aby powiększyć)

Jeśli chcesz przeprowadzić taką sesję u siebie, to dokładną instrukcję krok-po-kroku znajdziesz w jednym z moich maili, który możesz podejrzeć tutaj: Mailing Domain-Driven Design | Google to tylko jeden guzik i dwa ekrany? + Zadanie z EventStormingu. Dla subskrybentów są dostępne także dodatkowe wytłumaczenia i cały MIRO Board, którego wycinki znajdziesz w tym wpisie. Na mailingu co 2 tygodnie otrzymasz teorię związaną z Domain-Driven Design, rozbudowaną o moje własne doświadczenia i zadanie praktyczne wprowadzające w ten świat.

Wycinek

EventStorming poprzez przerzucenie myśli na ścianę (czy MIRO Board) umożliwia tzw. shared understanding. Zrozumienie poprzez zamodelowanie procesu zdarzeniami i zwizualizowanie ich jest o wiele lepsze niż długie tekstowe opisy.
https://www.jpattonassociates.com/read-this-first/

Aby nie krążyć gdzieś po zazwyczaj bezkresnych pustyniach procesów i przypadków brzegowych warto określić cel, jaki chcemy osiągnąć podczas takiej sesji. Na prowadzonych przeze mnie warsztatach celami było:

  • Zamodelować aktualne procesy kursu CodersCamp. Nazwać problemy, jakie się przy nich pojawiają i z czym są związane.
  • Określić jakie szanse / wartości możemy uzyskać, wprowadzając zmiany.
  • Znaleźć miejsca, gdzie może pomóc automatyzacja.

W tym wpisie skupię się na tym ostatnim wymienionym. W czasie BigPicture modelujemy procesy, tak jak aktualnie się zachowują, a nie tak jak być powinno. Tym zajmiemy się podczas szukania rozwiązania. Kluczem do osiągnięcia celu jest zlokalizowanie problemów właśnie w obecnej formie procesów.

Z odpowiednimi ekspertami domenowymi na pokładzie jako pierwszy do automatyzacji uwypuklił się proces związany z udostępnianiem materiałów do nauki podczas kursu CodersCamp. Najłatwiej spostrzec to na BigPicture EventStormingu, po prostu spoglądając z oddali, i znajdując miejsca skupienia czerwonych karteczek oznaczający HotSpoty. Tak jak wspomniałem, CodersCamp, jest to największy darmowy kurs programowania w Polsce, na który zgłasza się około 2 tysiące osób. Na początek każda zgłoszona osoba, po przejściu wstępnej weryfikacji, otrzymuje materiały do samodzielnej nauki. Najbardziej zdeterminowani, po rozwiązaniu testu rekrutacyjnego dostają się do dalszego etapu, gdzie wciąż przerabiają materiały samodzielnie. W międzyczasie pracują też w zespołach wraz z mentorem nad projektami, które dają im praktyczne umiejętności potrzebne do pierwszej pracy w IT. Korzystamy przy tym z zewnętrznej platformy, gdzie każdy ma dostęp do materiałów i może śledzić swój postęp. Aby dostać się do listy i poprawnie utworzyć kopię dla siebie każdy z kandydatów dostawał maila, gdzie znajdowała się instrukcja jak poniżej:

  1. Otwórz w przeglądarce link: <link do materiałów edukacyjnych>. Posłuży Ci on jedynie do utworzenia nowej checklisty. Nie będzie Ci już więcej przydatny.
  2. Po pojawieniu się checklisty nazwij ją swoim imieniem i nazwiskiem. Zrobisz to, klikając na wytłuszczony napis po lewej stronie, zaraz nad spisem treści. Format napisu podobny jest do: „CodersCrew’s 11:26PM checklist”.
  3. Kiedy to zrobisz, pojawi się komunikat „Checklist name updated”, a link w przeglądarce się zmieni.
  4. Zapisz link widoczny teraz w przeglądarce! To właśnie jego będziesz używać, aby dostać się do swojej checklisty i przerabiać materiały kursu.
Wycinek

Wycinek z BigPicture EventStormingu pokazujący kawałek procesu udostępniania materiałów edukacyjnych.
Kliknij na obrazek, żeby powiększyć.

Niestety taki sposób był dość zawodny, co uwypuklają zaznaczone na powyższym EventStormingu HotSpoty w postaci czerwonych karteczek. Maile często nie dochodziły, instrukcja też nie była dla wszystkich zrozumiała, a zapisany link czasem gdzieś umykał uczestnikom. W skrócie, wykonanie instrukcji było podatne na ludzkie błędy, które właśnie ma eliminować wykonywany przez nas software poprzez automatyzację. A więc do dzieła. Czas przejść do przestrzeni rozwiązania! Zautomatyzujemy generowanie listy dla uczestnika i zapisywanie linku do niej.

Rozwiązanie widzę w gwiazdach! A pod nogami wielka kula gnoju…

Często zdarza się, że rozwiązanie jest trochę dziełem przypadku, a trochę fantazji developera. Na początku przygody z programowaniem intuicja niestety często może zawodzić i nie warto na niej polegać. Podział kodu na moduły wg. zasady “bo tak pasuje nazwa” albo “tak mi się wydaje” albo nawet wg. encji bazodanowych, z pewnością nie jest dobrym pomysłem. Wtedy zachowujemy się bardziej właśnie jak wspomniany żuczek gnojarz niż developer. Takie właśnie małe stworzenie orientuje się w świecie dzięki światłu jakie dają gwiazdy Drogi Mlecznej. Niestety bazowanie na przypuszczeniach, czy astralnych wizjach nie doprowadzi nas w projekcie do niczego innego niż żuka — czyli do sławnej Wielkiej Kuli Błota (ang. Big Ball of Mud). W praktyce Big Ball of Mud, to właśnie ten projekt, którego w Twojej firmie nikt nie chce tykać, a gdy już ktoś się za niego zabiera, to tylko jeszcze bardziej grzęźnie w tym bagnie. Często mówi się na taki projekt “monolit”. Ale to nie architektura monolityczna jest tutaj problemem. W mikroserwisach problem może być jeszcze gorszy!

Architektura warstwowa w myśl Controller-Service-Repository.

Żuk gnojarz. Kosmiczny Nawigator.
https://dinoanimals.pl/zwierzeta/zuk-gnojowy-kosmiczny-nawigator/

W Big Ball of Mud, każda nowa funkcjonalność czy zmiana modyfikuje kształt całej kuli i nie wiadomo, jaki skutek będzie miała zmiana. To tak jakby naprawiać u siebie zmywarkę, a u sąsiada wypada ściana. Z zależnościami rozsianymi po całym kodzie nigdy nie wiadomo co się wydarzy. Żeby sobie z tym poradzić często trzeba stawać na głowie, jak właśnie taki żuk gnojarz, który tocząc kulę kroczy głową do dołu. Pokażę Ci jeden często powtarzany design, który prowadzi właśnie do takiej kuli błota. Z pewnością przypomni Ci on projekty na studiach. Spotkasz go też w systemach legacy i robią tak dalej developerzy, którzy 1 rok swojej kariery powtarzają już po raz 10. Albo tacy, którzy jeszcze nie czytali tego bloga :) Więc czas przestać toczyć tą Big Ball of Mud i głowa do góry!

Raz, dwa, trzy — modułem będziesz ty!

Patrząc na branże IT, można by powiedzieć, że spotkało ją coś podobnego, co bohaterów biblijnego opowiadania o wieży Babel. Ten programuje w Kotlinie i za nic nie chce rozmawiać z programistą .NET. Tamten zaś w F# i uznaje tylko języki funkcyjne. Jednak, jeśli jesteś “programistycznym poliglotą”, to na pewno zauważyłeś już pewne podobieństwa. W niemal każdym języku programowania, czy chociaż na poziomie frameworków występuje pojęcie modułu — niektórzy mogą to nazwać też pakietem czy namespacem. Te wierzenia różnych plemion programistycznych z pewnością mają w sobie coś z prawdy. Jednak nie wszystko, co nazwiemy modułem, rzeczywiście doprowadzi nas do modularnego designu. Co przez to rozumiem? Jak taki podział na moduły powinien wyglądać? Co chcemy przez niego osiągnąć? Zacznijmy od tego, jak NIE dzielić na moduły. To pozwoli nam podobnie jak w przypadku EventStormingu najpierw przeanalizować problem, a później dopiero skupimy się na rozwiązaniu. Czyli czym moduł nie powinien być. No to jedziemy! Powtórzymy diagram z samego początku.

Architektura warstwowa w myśl Controller-Service-Repository.

Architektura warstwowa w myśl Controller-Service-Repository.

Podział aplikacji na najwyższym poziomie wg. pakietów odpowiadających technicznym warstwom jak np. controllers / services / repositories już dawno odszedł do lamusa. Projekty automatyzujące procesy biznesowe potrzebują czegoś więcej niż prosty CRUD w stylu “encja na twarz i pchasz”. Dlatego ten sposób zostawmy od razu poza rozważaniami. Jeśli masz wątpliwości, kiedy jakie podejście stosować, to najlepszym miejscem będzie zatrzymać się w tym miejscu i przeczytać artykuł DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together, a potem spędzić chwilę z tą prezentacją Modularity and hexagonal architecture in real life: Jakub Nabrdalik. Jak widzisz na górze, od razu zastosowaliśmy tzw. podział pionowy (ang. vertical slice) wg. funkcjonalności biznesowych, w przeciwieństwie do horyzontalnego (ang. horizontal slice), który skupiałby się na warstwach technicznych. Ale co tutaj dalej jest nie tak?

Enkapsulacja w modułach. Jak ją złamać?

Wróć do swoich początków z programowaniem. Z pewnością już wtedy w Twoim kodzie można było spotkać modyfikatory dostępu. W Javie mamy np. modyfikator package-private (który powinien być najczęściej używany, a niestety często z przyzwyczajenia pojawia się tam od razu public), private i protected, oraz owiany złą sławą public. Na rozmowie rekrutacyjnej oczywiście bez problemu umiałem wymienić enkapsulację jako jeden z filarów programowania obiektowego, ale z zastosowaniem i zrozumieniem było już gorzej. Nie myślałem o niej w ogóle na poziomie pakietów, liczyły się tylko pojedyncze klasy.

Jeszcze raz mówię, że jeśli nie widziałeś prezentacji Kuby Nabrdalika o Hexagonal Architecture, to zobacz koniecznie! LINK JESZCZE RAZ TUTAJ. Przykłady są oparte na Javie, ale bez problemu zastosujesz to podejście w innych językach. Swego czasu to bardzo zmieniło mojego podejście do programowania, które potem oczywiście dalej ewoluowało, szczególnie poprzez wprowadzenie do kodu zdarzeń (ang. event).

Enkapsulacja pomaga schować szczegóły implementacyjne na poziomie klas i umożliwia ich refaktoring. Wiemy też, że testujemy metody widoczne na zewnątrz klasy, a prywatne nie. Dlaczego tak? Odpowiada Vladimir Khorikov na swoim blogu. Niestety zapominamy już o tej zasadzie na poziomie całej architektury aplikacji i poszczególnych modułów. Znasz to uczucie, że projekt greenfield staje się po czasie Legacy? Brak enkapsulacji na poziomie modułów to z pewnością jest jeden z powodów.

Przez pierwsze lata popełniałem ten sam błąd. Zobacz jak łatwo, się to dzieje. Przykładowo, LearningResourcesService potrzebuje pobrać nazwę użytkownika po id i już odwołuje się do wnętrzności modułu users. Na diagramie powyżej widać strzałkę prowadzącą do serwisu UserService.

Chcesz wysłać maila po wygenerowaniu listy materiałów? Musisz się odnieść do modułu wysyłania maili i wprowadzić zależność. Jakiś zapis w audyt logu? Zmiana. Uruchomienie innego procesu biznesowego? Konieczność wysłania powiadomienia na telefon? Kolejna zależność i kolejna zmiana. Oczywiście można tak jeszcze wymieniać i wymieniać. Klasy puchną, testy potrzebują zmian, a cały kod staje się coraz trudniej utrzymywalny.

package pl.zycienakodach.coderscamp.modules.learningresources.application

import pl.zycienakodach.coderscamp.modules.email.EmailService //importy z innych modułów = wprowadzone zależności
import pl.zycienakodach.coderscamp.modules.users.UsersRepository
import pl.zycienakodach.coderscamp.modules.users.User // import modelu z modułu user = znajomość szczegółów implementacyjnych

@Service
class LearningResourcesService (
  private val usersRepository: UsersRepository,
  private val emailService: EmailService,
  private val learningResourcesGenerator: LearningResourcesGenerator,
  private val learningResourcesRepository: LearningResourcesRepository
) {

  @Transactional
  fun generateLearningResources(userId: String) {
    val user: User = usersRepository.findById(userId)
    val generatedLearningResources = learningResourcesGenerator.generateFor(user.fullName)

    val email = Email(title = "Resources Generated", /*pominięte*/)
    emailService.sendEmail(email) // co jeśli tutaj będzie BOOM!? odpisz w komentarzu

    learningResourcesRepository.save(generatedLearningResources)
  }

}

Jakie zmiany w innych modułach wymuszą na nas też grzebanie w metodzie, generateLearningResources odpowiedzialnej za generowanie listy? A właśnie, słowo “odpowiedzialnej” ma tutaj znaczenie. Ta metoda łamie zasadę S z SOLID, czyli nie ma pojedynczej odpowiedzialności, albo inaczej: ma więcej niż jeden powód do zmiany. Między innymi są to:

  • Zmiana modelu użytkownika — np. rozbicie fullName na dwa pola.
  • Zmiana sposobu wysyłania wiadomości email, np. tego, jak wygląda obiekt Email = zmiana w części odpowiedzialnej za generowanie listy.

A przecież żadna logika biznesowa związana z materiałami edukacyjnymi nie została zmieniona! Nie wprowadzono reguły wpływającej na ten przypadek użycia, takiej jak np.: każdy użytkownik może wygenerować listę materiałów tylko jeden raz. To dlaczego diff w prawdopodobnym pull requeście, w ogóle zawiera te pliki i wystawia nas na potencjalne błędy? Warto tutaj wrócić do zasady, którą mam wypisaną na jednej z koszulek: “Jak działa, to nie ruszaj”. Zyskuje to tutaj głębszego znaczenia.

Taki kod nie ma możliwości zmian wraz z biznesem, a zmienia się wtedy, kiedy zmieniają się technikalia i propaguje te zmiany na niemal całą aplikację. Daleko jesteśmy tutaj od przestrzegania zasady Open-Closed z SOLID. Rozbijając taką aplikację na mikroserwisy, doszlibyśmy do bardzo groźnego antywzorca rozproszonego monolitu. Byłoby to koszmarem dla osób, które przyjdą po nas, kiedy nie wytrzymamy już chaosu wprowadzonego w obecnym projekcie.

Event Modeling po BigPicture EventStorming

Następnie Alberto Brandolini, autor EventStormingu proponuje przejście do kolejnej fazy, tzw. Process Level. Czy to dobry pomysł? Jeden rabin powie tak, drugi powie nie. Bazując na moich własnych doświadczeniach, widzę, że kiedy wchodzą dodatkowe elementy notacji, takie jak Polityki i Agregaty to zaczynają się problemy. Szczególnie jest to mniej zrozumiałe dla osób nietechnicznych czy nie znających DDD. A to jednak udział biznesu jest tutaj niezastąpiony.

Dlatego warto postawić na prostotę i wykorzystać EventModeling zamiast Process Level i Design Level EventStormingu. Zrozumienie tej techniki jest trywialne, ponieważ idzie za tym, jak funkcjonuje ludzki mózg. Design systemu jest projektowany jak opowieść składająca się z kolejnych klatek filmu. Nie wymaga to myślenia abstrakcjami. To my jako programiści funkcjonujemy w takiej bańce na co dzień — interfejsy, byty, które nie istnieją — dla nas w kodzie to chleb powszedni. Trzeba zaakceptować to, że nie każdy myśli w takim sam sposób i jest w stanie sprawnie żąglować nieistniejącymi abstrakcjami. I to bardzo dobrze! EventModeling sprawia, że eksperci domenowi, jak i inni członkowie zespołu, np. UX Designerzy, mogą być w pełni zaangażowani w projektowanie.

Podstawowe zrozumienie Event Modelingu z pewnością da Ci przeczytanie dwóch artykułów na stronie autora metody — Adama Dmytriuka. Jeśli coś jest nie jasne, to możesz zostawić komentarz tutaj na blogu — chętnie odpowiem :)

Jeśli znasz już kolejne fazy EventStormingu, to na pytanie “Czym różni się Event Modeling od Event Stormingu?” świetnie odpowiada Rafał Maciąg jako gość podcastu DevSession. A może jeszcze Ci mało? Możesz obejrzeć Adama Dmytriuka w akcji, gdzie modeluje system do obsługi piekarni: Event Driven Meetup - Event Modeling a Bakery!.

Planowanie małych kroczków i Test-Driven Development

Wykonaliśmy EventStorming, teraz przyszedł czas na kod. Oczywiście najlepszy to taki kod, którego nie ma. Jeśli w ramach jakiegoś procesu nie odkryliśmy HotSpotów, to może wystarczy, zostawić go tak jak jest? Przecież płacą nam właśnie za rozwiązanie problemów biznesowych, a nie ich tworzenie.

Kiedy nie mamy żadnego planu na implementację, zazwyczaj powstaje taki potworek spaghetti jak opisany wcześniej. Event Modeling nie jest powrotem do Waterfall, ale promuje postawę “small design upfront”. Zobacz, jakie to proste w praktyce i jak współgra z Test-Driven Development!

Wykonałem bardzo prosty EventModeling, który pozwoli nam przełożyć wykonany model 1 do 1 na działający kod. Co ważne, taki Event Modeling skupia się na zachowaniach, a nie na strukturze danych. Event Modeling nie jest też powiązany z żadnym stylem implementacji, dlatego możesz go zastosować nie używając nic z tego, co znajdziesz w dalszej części artykułu. Często, kiedy jako programiści zaczynamy kminić o nowej funkcjonalności, w myślach powstają nam tabelki bazy danych — co gdzie dołożyć. Nowoczesne metody projektowania oprogramowania promują podejście skupione na zachowaniach, chociaż to nic odkrywczego. Już programowanie obiektowe kładzie nacisk przecież na metody i interakcje między obiektami, a nie pola klas. Jeśli chcesz ruszyć naprzód i przestać być niewolnikiem tabelek bazodanowych, warto już teraz wyrabiać u siebie nawyk myślenia zdarzeniami (ang. Event-Driven Thinking).

Co Ci to da w praktyce? Często mówi się, że TDD jest trudne, że nie działa. To prawda, jeśli próbujemy testować nie to, co trzeba, albo nie tak jak trzeba. Test-Driven Development wymaga właściwej architektury aplikacyjnej, która pozwoli nam wyspecyfikować zachowanie systemu poprzez testy. Takie testy, żeby były przydatne, nie mogą powodować zabetonowania designu i psuć się przy każdej zmianie kodu. To oznaka, że skupiamy się na szczegółach implementacyjnych. Testy przede wszystkim mają uchronić nas automatycznie przed regresją przy ewentualnych zmianach i refaktoringu.

Początkowa faza Event Modelingu.

Początkowa faza Event Modelingu.
(Kliknij, aby powiększyć)

Prosty EventModeling łączy ze sobą design komponentu UI odpowiedzialnego za generowanie personalnego linku do materiałów edukacyjnych z tym, co dzieje się pod spodem na backendzie. Jeden model zawiera zaplanowaną pracę UX Designerów, frontendowców, backendowców i ekspertów domenowych. A ponadto ukrywa specyficzne wybory technologiczne, które leżą w gestii developerów i biznes nie musi się o to martwić. Łatwo odczytać, że kliknięcie przycisku powoduje żądanie HTTP na POST /learning-resources, co skutkuje komendą GenerateLearningResources (niebieska karteczka). Efektem komendy jest zdarzenie LearningResourcesWereGenerated (pomarańczowa karteczka). W tym tekście pominiemy część frontendową i zaczniemy od implementacji komendy.

Moduł w izolacji

Chociaż na co dzień piszę w Javie lub Kotlinie, tutaj posłużymy się kodem z TypeScripta. Mam już do tego trochę gotowców, ponieważ ostatnio poprowadziłem właśnie szkolenie na ten temat dla stowarzyszenia CodersCrew i wykonałem kawałek implementacji, wykorzystując framework NestJS. Komenda (ang. command) i zdarzenie (ang. event) przedstawione w kodzie to nic innego jak proste klasy. Zdarzenia i komendy są opisane przez swoje nazwy, jak i atrybuty, które dostarczają szczegóły o danym żądaniu czy wydarzeniu. Wiesz już co to zdarzenie, jest ono zazwyczaj skutkiem komendy. Komenda to jakaś akcja, której wykonania w systemie żąda użytkownik / system zewnętrzny lub inny moduł. O ile zdarzenie informuje już o czymś post factum, co się wydarzyło, to komenda mówi, co powinno się wydarzyć. Powinno, ale nie ma takiej pewności. Żądana operacja może się nie powieść i zostać odrzucona. Nazewnictwo komend i zdarzeń zostało świetnie przedstawione tutaj: Mathias Verraes | Messaging Patterns: Natural Language Message Names

export class GenerateLearningResources {
  constructor(readonly userId: UserId) {}
}
export class LearningResourcesWasGenerated {
  constructor(
    readonly occurredAt: Date,
    readonly learningResourcesId: LearningResourcesId,
    readonly userId: UserId,
    readonly resourcesUrl: ResourcesUrl
  ) {}
}

Komenda i zdarzenie wyglądają w kodzie właśnie tak prosto! Nie pozostaje nic innego jak napisać teraz test, który spina oba te building blocki. Całość takiego testu, którą wykonałem podczas prowadzonego szkolenia znajdziesz TUTAJ. CommandBus / EventBus / QueryBus to w skrócie implementacja wzorców obserwator i mediator. Node’owy pakiet @nest/cqrs (stosowanie pakietu nie wymaga zastosowania wzorca architektonicznego Command-Query Responsibility Segregation - my też nie będziemy tego teraz robić) dostarcza nam od razu gotowe implementacje, które musielibyśmy inaczej wykonać od początku. Choć nie jest to nic ciężkiego i taką implementację możesz znaleźć w jednym z projektów moich mentorowanych, to jest ona poza zakresem tego wpisu. Wykonując komendę poprzez metodę CommandBus.execute() nie wiemy kto ją obsłuży, ale wymagane jest jedno miejsce, w którym zostanie wykonana. Podobnie ze zdarzeniem — publikujemy je za pomocą EventBus.publish() i też nie wiemy, czy ktoś jest nim zainteresowany. Zdarzenie może mieć 0 lub wiele obserwatorów. Publikacja zdarzenia na event bus to powiedzenie innym modułom aplikacji: “hej, ja moduł zrobiłem coś takiego, jeśli jesteście tym zainteresowani, to zróbcie coś u siebie”. EventBus jest abstrakcją, więc może być jak w przypadku NestJS realizowany w tym samym procesie, albo korzystać z infrastruktury takiej jak Kafka czy RabbitMQ.

it('when generate learning resources, then learning resources should be generated', async () => {
  // when
  await commandBus.execute(new GenerateLearningResources(existingUserId));

  // then
  const lastPublishedEvent = getLastPublishedEvent();
  expect(lastPublishedEvent).toStrictEqual(
    new LearningResourcesWereGenerated(
      currentTime,
      'generatedId1',
      existingUserId,
      'https://app.process.st/runs/Jan%20Kowalski-sbAPITNMsl2wW6j2cg1H2A'
    )
  );
});

Test wykonuje komendę i sprawdza, czy zostało opublikowane właściwe zdarzenie. Wszystkie szczegóły implementacji są transparentne dla testu. Sam test nie ma świadomości:

  • W jaki sposób z id użytkownika odczytywane jest jego imię i nazwisko (Jan Kowalski w URL).
  • Skąd brany jest URL listy materiałów edukacyjnych.
  • W jaki sposób wygenerowany url zapisuje się w bazie danych.
  • Jaki fragment kodu obsługuje komendę i genruje zdarzenie.

Wszystko to jest szczegółem implementacyjnym i może się dowolnie zmieniać, nie psując testu. Biznes określił, co ma się dziać, poprzez EventModeling i to właśnie realizuje test. Cała reszta to kwestie techniczne, które mogą się dowolnie zmieniać. Dzięki temu otrzymujemy prawdziwą enkapsulację. A testy, tak jak i inne moduły są tylko zainteresowane side-effectami (takimi jak opublikowane zdarzenie) widocznymi na zewnątrz modułu.

Zobacz poniżej, jak wygląda implementacja spełniająca taki test, wykorzystując NestJS i pakiet @nest/cqrs.

export class GenerateLearningResourcesCommandHandler
  implements ICommandHandler<GenerateLearningResources> {
  constructor(
    private readonly eventBus: EventBus,
    private readonly learningResourcesGenerator: LearningResourcesGenerator,
    private readonly usersFullNames: UsersFullNames,
    private readonly timeProvider: TimeProvider
  ) {}

  async execute(command: GenerateLearningResources): Promise<void> {
    const currentTime = this.timeProvider.currentTime();
    const userFullName = await this.usersFullNames.findById(command.userId);
    const learningResources = await this.learningResourcesGenerator.generateFor(
      userFullName
    );

    const event = new LearningResourcesWereGenerated(
      this.timeProvider.currentTime(),
      learningResources.id,
      command.userId,
      learningResources.resourcesUrl
    );
    this.eventBus.publish(event);
  }
}

Jak dotąd, aby dojść do fazy GREEN w TDD, nie potrzebujemy żadnego mechanizmu utrwalania danych. Ta implementacja będzie musiała się tylko zmienić, kiedy dojdzie kolejne zachowanie, czyli zmienią się reguły biznesowe. Następuje to np. w przypadku jak poniżej.

it('given learning resources were generated, when generate learning resources once again, then learning resources should NOT be generated', async () => {
  // given
  await commandBus.execute(new GenerateLearningResources(existingUserId));

  // when - then
  await expect(() =>
    commandBus.execute(new GenerateLearningResources(existingUserId))
  ).rejects.toStrictEqual(
    new Error('Learning resources were already generated!')
  );
});

Aby być zgodna ze specyfikacją, implementacja zmieni się następująco:

export class GenerateLearningResourcesCommandHandler
  implements ICommandHandler<GenerateLearningResources> {
  constructor(
    private readonly eventBus: EventBus,
    private readonly learningResourcesGenerator: LearningResourcesGenerator,
    private readonly usersFullNames: UsersFullNames,
    private readonly repository: LearningResourcesRepository,
    private readonly timeProvider: TimeProvider
  ) {}

  async execute(command: GenerateLearningResources): Promise<void> {
    const currentTime = this.timeProvider.currentTime();
    const existingLearningResources = await this.repository.findByUserId(
      command.userId
    );
    if (existingLearningResources) {
      throw new Error('Learning resources were already generated!');
    }

    const userFullName = await this.usersFullNames.findById(command.userId);
    const learningResources = await this.learningResourcesGenerator.generateFor(
      userFullName
    );
    this.repository.save(learningResources);

    const event = new LearningResourcesWereGenerated(
      this.timeProvider.currentTime(),
      learningResources.id,
      command.userId,
      learningResources.resourcesUrl
    );
    this.eventBus.publish(event);
  }
}

Dopiero tutaj cała na biało wchodzi też baza danych (za interfejsem LearningResourcesRepository), nie wcześniej. To opisane przez testy zachowania, w myśl TDD kierują potrzebą wprowadzenia persystencji. Heurystyka jest dość prosta:

  • Jeśli ta sama komenda, skutkuje zawsze takim samym eventem, nie jest potrzebna persystencja. I bez zapisu w bazie danych, możemy zareagować na zdarzenie, np. poprzez wysłanie emaila o zdarzeniu.
  • Jeśli na tę samą komendę, system może zareagować w różny sposób (zależnie od poprzednich zdarzeń, np. link do materiałów został już wygenerowany poprzednio), to wtedy musimy jakoś zapisać, co stało się wcześniej. Dzięki temu możliwe będzie podjęcie właściwej decyzji — zaakceptowanie bądź odrzucenie kolejnej komendy.

Powtórzę jeszcze raz dogmat, który zapewne już wkuli na pamięć subskrybenci mailingu: NIE ZACZYNASZ PROJEKTOWANIA SYSTEMU OD MODELU DANYCH Wyjątkiem może być, kiedy robisz naprawdę CRUDa, który nie ma procesów biznesowych i ma być tylko przeglądarką do bazy danych. Ale to właśnie w takich projektach niedługo nas zastąpi AI. Już są narzędzia takie jak JHipster, z których można wygenerować cały projekt w tym stylu.

Architektura Portów i Adapterów (Hexagonalna)

Czym w powyższym kodzie jest UsersFullNames? Jak widzisz w implementacji CommandHandlera zmienna userFullName jest używana do wygenerowania listy materiałów. Nazwa listy będzie po prostu tworzona na podstawie imienia i nazwiska użytkownika. W takim razie programując tę funkcjonalność, wprowadzam interfejs. Jest to abstrakcja, określająca czego potrzebuje implementowany moduł, nie wiem jeszcze, gdzie to się znajduje.

//plik: src/learning-resources/core/users-full-names.port.ts

import { UserId } from '../../shared/core/user-id';

export const USERS_FULL_NAMES = Symbol('USERS_FULL_NAMES');
export interface UsersFullNames {
  findUserById(userId: UserId): Promise<{ fullName: string } | undefined>;
}

Mówimy: “Ja potrzebuję dostać pełne imię i nazwisko użytkownika. Będę go szukał za pomocą user id”. Nie określam, w jaki sposób to dostanę. Nie jest to moją odpowiedzialnością i nie mam tych danych w ramach modułu. Poprzez interfejs komunikujemy CO jest potrzebne, ale NIE w jaki sposób zostanie dostarczone / zaimplementowane. Świetnie opisuje to Robert C. Martin w książce Czysty Kod. Implementacja tego interfejsu to miejsca na swoisty klej, który zepnie nas np. z bazą danych albo innym modułem. Umożliwia to też zrównoleglenie pracy. Nie muszę czekać, aż coś, z czego będę pobierał te dane, zostanie wykonane. Określam jedynie kontrakt, który będzie musiał być docelowo spełniony, a tymczasowo mogę nawet zahardkodować implementację lub podmienić na stuba w testach.

Załóżmy, że aby pozyskać potrzebne dane będę musiał się skomunikować z modułem users. Implementacja naiwna, bez wykorzystania interfejsu spowodowałaby bezpośrednią zależność od CommandHandlera do UsersRepository.

Zależności między klasami bez zastosowania Portów i Adapterów.

Zależności między klasami bez zastosowania Portów i Adapterów.

Niestety, wtedy do granic modułu LearningResources przenikają wszystkie szczegóły implementacji modułu Users. Wiemy, w jaki sposób wygląda też encja User, wszystkie pola, jakie ma — nawet te, które nie są nam potrzebne. W teście CommandHandlera nie będziemy musieli teraz zamockować jedynie fullName dla danych ID, ale wszystko, co taki user z modułu users zawiera. Taka zależność zanieczyszcza testy szczegółami innego modułu i sprawia, że są cięższe w utrzymaniu (zmiana w innym module wpływa na test) i mniej czytelne.

Odwrócenie zależności między klasa z wykorzystaniem Portów i Adapterów. Architektura Hexagonalna.

Odwrócenie zależności między klasa z wykorzystaniem Portów i Adapterów. Architektura Hexagonalna.
(Kliknij, aby powiększyć)

Dlatego stosujemy odwrócenie zależności (ang. dependency inversion). Kolejna literka z SOLIDA, tym razem D. Zwróć uwagę na kierunki strzałek. Tylko nasz adapter wie o tym, czego potrzebuje command handler i o tym, jak to dostać z innego modułu. Dlatego w myśl architektury portów i adapterów został wprowadzony właśnie interfejs — UsersFullNames. Nie odnosimy się bezpośrednio do UsersRepository, tylko do określonej abstrakcji.

//plik: src/learning-resources/infrastructure/user-module-to-users-full-names.adapter.ts

import { UserId } from '../../shared/core/user-id';
import { UsersRepository } from '../../users/users.repository';
import { UsersFullNames } from '../core/users-full-names.port';

/** *
 * Anti-corruption layer (adapter from Ports & Adapter architecture) which invert dependency,
 * whole LearningResources module is not dependent on Users module internals like user representation in database.
 */
export class UserModuleToUsersFullNamesAdapter implements UsersFullNames {
  constructor(private readonly usersRepository: UsersRepository) {}
  // lepszym sposobem byłaby tutaj komuniacja przez QueryBus, ale moduł users był zaimplementowany wykorzystując Controller-Service-Repository

  async findUserById(
    userId: UserId
  ): Promise<{ fullName: string } | undefined> {
    const user = await this.usersRepository.getById(parseInt(userId, 10));

    return user ? { fullName: user.fullName } : undefined; //jeśli zajdzie zmiana w module users taka jak rozbicie fullName na firstName i lastName, to dla modułu LearningResources będzie dotyczyć tylko tej linijki
  }
}

Portów i Adapterów można używać niezależnie od całej reszty. Służą do komunikacji nie tylko między modułami, ale np. też zewnętrznymi zależnościami takimi jak właśnie REST API, baza danych czy biblioteki 3rd party. Nieważne, jaką masz architekturę. Nie wymaga to stosowania CommandBusa itp. Port i Adapter pełnią też funkcję anti-corruption layer. Nie pozwalamy, aby język modułu użytkowników przenikał do naszego LearningMaterials. Interfejs kontroluje tę relację, a implementacja wybiera tylko to, co nam potrzebne. Co to daje w praktyce? Załóżmy, że pole fullName w user, zostanie zmienione i rozbite na dwa, to tylko adapter będzie wymagał zmiany, a cała reszta pozostaje nietknięta. Takie rozwiązanie, kiedy porty i adaptery służą do komunikacji, między modułami jednej aplikacji monolitycznej jest wystarczające.

Jednakże w przypadku chęci wyciągnięcia modułu LearningMaterials jako osobnej aplikacji rozwiązaniem ze zmianami jedynie w adapterze byłoby:

  • Wystawienie REST API z modułu users, który umożliwia odczytanie użytkownika po ID
  • Zaimplementowanie adaptera, który zamiast użycia usersRepository wykonywałby request HTTP.

Niestety taka komunikacja synchroniczna między mikroserwisami nie jest pożądana i może doprowadzić do kaskadowej awarii. Istnieje rozwiązanie na ten problem, bazujące na zdarzeniach. W skrócie:

  1. Moduł users mógłby publikować zdarzenie UserWasRegistered, zawierające fullName użytkownika.
  2. Moduł LearningMaterials subskrybowałby się na to zdarzenie i budował lokalną kopię potrzebnych danych. Wyeliminowałoby to konieczność komunikacji między modułami przy każdym odczycie fullName.

Widzisz już tutaj jakiś dodatkowy problem do rozwiązania? Wszystko w architekturze jest swego rodzaju trade-offem. Zyskamy większą niezależność i możliwość bezproblemowego rozbicia na mikroserwisy, ale co w sytuacji, kiedy… Dzisiaj wchodzi nowy moduł, a zdarzenia, które były publikowane przez ostatnie 5 lat, przecież nagle do niego nie trafią. Wiesz już może, jak sprostać takiemu wyzwaniu? Tym z pewnością zajmiemy się jeszcze na blogu.

Hello, Modular Monolith!

“Hello, World!” Zapewne coś takiego wypluwał na konsolę pierwszy program, jaki udało Ci się napisać. O ile lepiej byłoby to zamienić jednak na “Hello, Modular Monolith!“. Z pewnością uniknęlibyśmy wtedy wielu zdziwień, w stylu: “jak ktoś mógł tak to zaprogramować!?“.

Zobaczyliśmy już jak przeprowadzić komunikację między modułami za pomocą portów i adapterów. A co w przypadku zdarzeń? Jak to wygląda w praktyce? Dodajmy najpierw do naszego Event Modelingu roboty.

Automatyzacja ukryta za robotami na Event Modelingu.

Automatyzacja ukryta za robotami na Event Modelingu.
(Kliknij, aby powiększyć)

Te wyglądające jak R2-D2 z Gwiezdnych Wojen droidy, są w Event Modelingu nazywane automatyzacją. Pod postacią takiego robocika kryje się cała logika odpowiadająca za podjęcie pewnej decyzji. Zastanówmy się, jak to mogłoby działać w przypadku braku aplikacji. Po wygenerowaniu materiałów edukacyjnych dla konkretnego uczestnika kursu, pani Krysia kopiuje link do klienta poczty i wysyła maila. Robot to właśnie nasza część software, które ma zastąpić taką panią Krysię. Brutalne, ale prawdziwe. W jaki sposób zostanie to wykonane? W jaki sposób podejmiemy decyzję co zrobić po danym zdarzeniu? Czy zastosujemy tutaj AI? To wszystko leży już w gestii nas, developerów, którzy projektujemy rozwiązanie. Eksperci domenowi nie powinni narzucać nam implementacji. Ich interesuje tylko, czy oprogramowanie zareaguje prawidłowo. To nasz program ma automatyzować podejmowanie decyzji biznesowej. Wysłanie maila jest bardzo proste i niezależne od innych czynników. Ale równie dobrze możesz tutaj sobie wyobrazić zdarzenie NaruszonoPrzestrzeńPowietrzną i komendę PoderwijSamolotyF16, o której decyduje implementacja wykorzystująca sztuczną inteligencję.

Najprostszą implementacją takiej automatyzacji jest EventHandler. Automatyzacja nie narusza w żaden sposób, logiki, którą wcześniej wykonaliśmy. CommandHandler jedynie publikował zdarzenie, LearningResourcesWereGenerated zostawiając nam je jako API modułu, z którego korzystając, możemy dodać nowe zachowania, bez zmian w już gotowym kodzie. To podobnie jak porty i adaptery, taki klej między procesami biznesowymi. Event to output procesu, który może powodować uruchomienie kolejnego poprzez komendę itd. W poniższym przykładzie zakończyliśmy proces generowania materiałów edukacyjnych i rozpoczynamy proces wysyłki emaila.

@EventsHandler(LearningResourcesWereGenerated)
export class WhenLearningResourcesWereGeneratedThenSendEmail
  implements IEventHandler<LearningResourcesWereGenerated> {
  constructor(private readonly emailService: EmailService) {}

  handle(event: LearningResourcesWereGenerated) {
    const email = Email(/*pominięte*/);
    this.emailService.sendEmail(email); // jeśli tutaj będzie boom, to materiały edukacyjne i tak już zostały wygenerowane
  }
}

Zdarzenia są obsługiwane w sposób synchroniczny lub asynchroniczny. Drugi z nich wprowadza większą niezależność i jest konieczny, gdyby subskrybent znajdował się w innym mikroserwisie. Opóźnienie w przesłaniu danych i eventual consistency powinno być brane też pod uwagę przez frontendowców.

Całą powstałą architekturę z rozbiciem na moduły i komunikacją poprzez zdarzenia oraz porty i adaptery sam nazywam często “Multi-Hexagonal Architecture”. Każdy z modułów jest dla siebie black-boxem w myśl Hexagonal Architecture. W najlepszym przypadku, jeden moduł o drugim nie wie praktycznie nic poza API, jaki tworzą Command i Event. Nie mieszają się ze sobą we wszystkich miejscach niczym spaghetti, ale stykają się jasno określonymi granicami niczym ravioli.

Alt text

Multi-Hexagonal Architecture.
Kliknij na obrazek, żeby powiększyć.

Q jak Query

Na naszych diagramach pojawiła się jeszcze zielona karteczka. Co ona oznacza? Reprezentuje ona dane, jakie zostaną odczytane. W tym przypadku to, co zwróci nasze zapytanie GET. Modułowi możemy też zadać jakieś pytanie, np. o materiały edukacyjne dla konkretnego użytkownika. I zwróci to właśnie ten link, który będziemy chcieli otwierać po kliknięciu, w przycisk, gdy materiały zostaną już wygenerowane.

Zobacz teraz test i implementację dla tego przypadku.

it('given learning resources were generated, then learning resources should be find by user id', async () => {
  // given
  await commandBus.execute(new GenerateLearningResources(existingUserId));

  // then
  const result = await queryBus.execute(
    new WhatAreLearningResourcesForUser(existingUserId)
  );

  expect(result).toStrictEqual({
    resourcesId: generatedId,
    resourcesUrl:
      'https://app.process.st/runs/Jan%20Kowalski-sbAPITNMsl2wW6j2cg1H2A',
  });
});
@QueryHandler(WhatAreLearningResourcesForUser)
export class WhatAreLearningResourcesForUserQueryHandler
  implements IQueryHandler<WhatAreLearningResourcesForUser> {
  constructor(
    @Inject(LEARNING_RESOURCES_REPOSITORY)
    private readonly repository: LearningResourcesRepository
  ) {}

  async execute(
    query: WhatAreLearningResourcesForUser
  ): Promise<WhatAreLearningResourcesForUserQueryResult> {
    const found = await this.repository.findByUserId(query.userId);

    if (found?.resourcesUrl === undefined) {
      throw new Error("Learning resources for user weren't generated!");
    }

    return { resourcesId: found.id, resourcesUrl: found.resourcesUrl };
  }
}

Diabeł tkwi w relacjach

Kiedy zdaje się, że wszystko już jest ładnie podzielone, to czeka na nas jeszcze kolejna pułapka. Przy tak wydestylowanych modułach istnieje jeszcze jedno zagrożenie, które może przejść niezauważone. Jeśli przewiniesz do początku artykułu, to zobaczysz, że w naszej architekturze spaghetti możemy wprowadzić powiązania na poziomie samego modelu bazodanowego. Coś takiego uniemożliwi nam np. wyciągnięcie modułu do osobnego serwisu i może prowadzić do niekontrolowanych zachowań, zmniejszenia wydajności i dostępności naszych usług, a nawet do łamania reguł biznesowych!

package pl.zycienakodach.coderscamp.modules.users.domain

import pl.zycienakodach.coderscamp.modules.learningresources.domain.LearningResources //import z innego modułu

@Entity
@Table(name = "users")
class User(
  @Column
  var address: String,

  @OneToOne(cascade = CascadeType.ALL)
  @JoinColumn(name = "learning_resources", referencedColumnName = "id") //tak nie robimy!
  var learningResources: LearningResources? = null,

  // reszta kodu pominięta
)

Prostym rozwiązaniem, aby nie wpaść w tę pułapkę, jest postawienie sobie mentalnej granicy: wszystko w innym module traktuję, jakby było osobną aplikacją. Przekładając to na język bazodanowy: encje z jednego modułu wykonuję w taki sposób, jakby encje z drugiego były w zupełnie innej bazie danych. Wtedy klucze obce i więzy integralności między nimi, po prostu nie istnieją.

Rozwiązaniem tutaj jest odnoszenie się między encjami z różnych modułów jedynie po ID i to tylko w jednym kierunku (np. z LearningMaterials do Usera). Mamy też jedno miejsce, w którym możemy wprowadzać zmiany do LearningResources. Nie ma zagrożenia, że gdzieś bokiem, czego na pierwszy rzut oka nie widać, ktoś, modyfikując Usera, zrobi jakąś zmianę w zupełnie innej części systemu. W większości przypadków zapomnij też o kluczach obcych w tabelkach na poziomie bazy danych. To nie jest Ci potrzebne. W Hibernate tylko prowadzi do tego, co wyżej. ORMy takie jak właśnie Hibernate czy TypeORM, Entity Framework umożliwiają nam wykonanie takiego zabiegu zbyt łatwo. Wtedy dostajemy się backdoorem do zupełnie innego modułu. Możemy nawet coś w nim zmienić i zapisać pomiajając reguły biznesowe! Relacji za pomocą referencji do obiektu używaj jedynie w granicach jednego modułu, tam, gdzie istnieją reguły biznesowe między tym, co zawsze musi być wczytywane i zapisywane razem.

Jak poniżej. Relacja w jedną stronę, a z User kompletnie usuwamy odniesienia do LearningResources.

package pl.zycienakodach.coderscamp.modules.learningresources.domain

@Entity
@Table(name = "learning_resources")
class LearningResources(
    @Column
    val userId: String
)

Zostaw aplikacji dbanie o więzy integralności. To jak utrwalasz dane, nie powinno powodować przenikania się modułów. Używasz relacyjnej bazy danych, czy systemu plików? To wtedy nie ma znaczenia.

Moduły też mogą być Open-Closed

Na sam koniec podsumujmy jeszcze, co udało nam się osiągnąć. Na schemacie poniżej widzisz, w jaki sposób odbywa się komunikacja wewnątrz modułu i też między częściami aplikacji. Zdarzenie może przechwycić ten sam moduł lub inny i zareagować na nie, wykonując odpowiedni dla siebie command, rozpoczynając np. kolejny proces biznesowy. Dobrym przykładem tutaj może być złożenie zamówienia w sklepie internetowym, które będzie zdarzeniem OrderWasSubmitted, skutkujące zapewne komendami rozpoczynającymi proces płatności czy pakowania zamówienia do wysyłki.

Komunikacja między modułami powinna odbywać się tylko poprzez komendy i zdarzenia.

Komunikacja między modułami powinna odbywać się tylko poprzez komendy i zdarzenia.
(Kliknij, żeby powiększyć)

Po lewej stronie widzimy, w jaki sposób Command może być wywołany, co jest wejściem do aplikacji. To właśnie CommandBus jest portem wejściowym do aplikacji. Tak to nazywamy w architekturze hexagonalnej (tutaj już nie ma interfejsu). I umożliwia “wpięcie się do tego portu” różnym adapterom np. messaging / http / gRPC. Ważną zasadą jest, żeby w warstwie aplikacji, która znajduje się w naszym core, nie odnosić się do niczego co jest np. związane z REST API (np. nie rzucać wyjątków specyficznych dla HTTP). Takim adapterem jest najczęściej Controller, który przechwytuje zapytania RESTowe. Wtedy jego odpowiedzialnością jest tylko mapowanie zapytania na Command albo Query. Command może też np. być wywoływany przez message z RabbitMQ, czy calle poprzez gRPC, albo być wywoływany przez inny moduł. Ostatni przypadek zazwyczaj ma zastosowane w automatyzacji w modularnym monolicie — event handler wywołuje command z innego modułu.

Przykładową implementację można zobaczyć poniżej. Testując controller mockujemy CommandBus i QueryBus.

@Controller('learning-resources')
export class LearningResourcesController {
  constructor(
    private readonly commandBus: CommandBus,
    private readonly queryBus: QueryBus
  ) {}

  @Get('/')
  async getUserLearningResources(
    @UserId() userId: UserId
  ): Promise<GetUserLearningResourcesResponse> {
    const result = this.queryBus.execute(
      WhatAreLearningResources.forUser(userId)
    );
    if (!result) {
      //NotFoundException rzucany dopiero tutaj, ponieważ odnosi się do HTTP 404
      throw new NotFoundException(
        'Cannot found learning resources for current user!'
      );
    }
    return result;
  }

  @Post('/')
  @HttpCode(204)
  async postGenerateUserLearningResources(
    @UserId() userId: UserId
  ): Promise<void> {
    await this.commandBus.execute(GenerateLearningResources.forUser(userId));
  }
}

Podsumowanie

Implementację, jaką wykonałem na prowadzonym szkoleniu znajdziesz TUTAJ. Fragmenty kodu, które były cytowane w artykule, czasem zostały trochę zmienione i uproszczone w celu zachowania czytelności.

Zastosowana architektura, znacznie przyśpiesza development poprzez nie nawarstwianie się zmian i ograniczenie ich do granic modułu. A na koniec zmianę na mikroserwisy bardzo małym kosztem. W jaki sposób? CommandBus i EventBus to tez porty. Wystarczy tylko zmienić np. implementację EventBusa na korzystającą z Kafki i przepis na mikroserwisy gotowy!

Moduły powinny być skupione wokół procesów biznesowych. Uważaj, żeby nie pójść w niewłaściwą stronę jak Entity-Based Microservices, które są osobnymi aplikacjami, a i tak łamią enkapsulację poprzez wystawianie całych encji bazodanowych w zdarzeniach takich, jak EntityWasCreated / EntityWasUpdated / EntityWasDeleted etc. W nazwach używaj języka biznesowego.

Na sam koniec określmy, jakie cechy powinien mieć odpowiedni moduł:

  • Jasno określone API, przez które będą się z nim komunikować inne części aplikacji i świat zewnętrzny.
  • Może działać w izolacji. Zmiany w innym module nie powinny na niego wpływać.
  • Testy modułu nie powinny wymagać znajomości innych części aplikacji.
  • Zenkapsulowany — nie wystawiać na zewnątrz szczegółów implementacyjnych. Np. zmiana sposobu zapisu danych w module powinna w ogóle nie oddziaływać na inne moduły.
  • Łatwy do usunięcia lub przepisania (wymagane tylko zachowanie kontraktów Command/Event/Query). Usunięcie modułu nie powinno sprawiać destrukcji w innych. Zobacz: Greg Young - The art of destroying software

Czy stosujesz już taką architekturę? Pracujesz w modularnym monolicie czy mikroserwisach? Jak u Ciebie wyglądają zdarzenia? Czy są gdzieś zapisywane (np. Kafka / EventStore)?

W kolejnych wpisach z pewnością zobaczysz jak wykorzystać takie rzeczy jak CQRS, Event Sourcing, causationId i correlationId, a także jak modelować bardziej złożone procesy biznesowe. Dowiesz się też, jaka jest różnica między warstwą Application, a Domain.

Co dalej? Jak żyć?

Profesjonalizm wymaga praktyki. Więc nie czekaj dłużej i spróbuj zastosować to, co przeczytałeś. Czerpanie korzyści z Portów i Adapterów nie wymaga nawet wykorzystania zdarzeń, możesz z tego, co tutaj zobaczyłeś wybrać praktyki, jakie będą łatwe do wprowadzenia w Twoim projekcie. EventModeling też nie wymusza konkretnej implementacji, jednak najlepiej sprawdza się z EventSourcingiem, o którym opowiemy sobie za jakiś czas.

Jeśli wcześniej Twoja architektura była typowo CRUDowa w myśl “encja na twarz i pchasz”, to już to podejście znacznie powinno zwiększyć pewność i stabilność Twoich testów, a także możliwość rozbudowy aplikacji. Największym wyzwaniem jest zmiana myślenia: komunikowanie czego potrzebujemy poprzez interfejsy i informowanie o fakcie wykorzystując zdarzenia, zamiast żądanie wykonania konkretnych akcji.

Jak to wszystko opanować? Poza mailingiem to już na tym blogu znajdziesz mapę, która poprowadzi Cię w dalszej wędrówce. Sprawdź post Jak opanować Domain-Driven Design i Event Sourcing? Mapa rozwoju dla Ciebie

Inni też tym żyją…

Podziel się wpisem:

Mailing Domain-Driven Design

Wciąż za mało życiowych cheatów?

Zostaw swój adres e-mail i zobacz moje spojrzenie na codzienność programisty.

Na sam początek opowiem Ci o zetknięciu z Domain-Driven Design, zmianie myślenia i nowej erze mojego programistycznego ja.

Możesz liczyć na materiały o Event Sourcingu, Event Modelingu, DDD, programowaniu obiektowym i funkcyjnym oraz innych powiązanych tematach.

Na pewno poświęcę trochę maili umiejętnością miękkim. Będziesz też informowany o nowościach Życia na kodach prosto na Twoją skrzynkę!

Bądźmy ze sobą szczerzy. Od razu powiem, że nie zamierzam Ci niczego sprzedawać. Oczywiście nie mogę obiecać, że zawsze tak będzie 🙂

Jedyną stałą rzeczą w świecie IT (tak samo jak w życiu) jest właśnie zmiana.