⚔️ Moc i magia Domain-Driven Design w świecie Heroes III: Event Modeling, stawianie granic i wysoka jakość bez code review

zycienakodach.pl 6 miesięcy temu

Wykorzystane grafiki: Heroes of Might and Magic III, do której prawa ma firma Ubisoft Entertainment SA oraz Heroes III Board Game od Archon Studio.

⚔️ Heroes of Domain-Driven Design

Ten wpis jest częścią serii, w której opowiadam Ci o stoczonych bitwach w realnych projektach z wykorzystaniem metodyki Domain-Driven Design. Tłumaczę wykorzystane podejścia poprzez analogie występujące w świecie Heroes III. Najwięcej skorzystasz, zaczynając od POPRZEDNIEGO POSTA.

Dzięki odpowiedniemu planowaniu podczas Event Modelingu zaoszczędzisz setki godzin kodowania i zapewnisz jakość swoich projektów.

🔸 Event Modeling

Sposobów na zaprojektowanie systemu i modelowanie jest wiele. Jednak oczywiście metoda, metodzie nie równa. W przeciwnym wypadku mielibyśmy jedną, stosowaną przez wszystkich. Z punktu widzenia architekta systemu ważne jest użyć metody, dzięki której:

  • wyrazisz model w taki sposób, aby mógł być przełożony bezpośrednio do kodu;
  • spojrzysz na system z różnych perspektyw takich jak: backend, frontend, ux/ui, analityka, estymacje;
  • zbudujesz model zrozumiały dla programistów, jak i dla osób nietechnicznych;
  • zweryfikujesz kompletność wymagań biznesowych i projektu (unikniesz rozmów na ostatnią chwilę w stylu: “na to nie ma jeszcze designu” / “dodaj mi jeszcze to pole do tego API”)
  • zobrazujesz zależności między częściami systemu i zaplanujesz jakie prace można prowadzić równolegle;
  • możesz eksperymentować z architekturą i walidować jej założenia na whiteboardzie, co jest nieporównywalnie tańsze niż zmiany w kodzie;

Dlaczego? Zaoszczędzi Ci to mnóstwo dodatkowej roboty, a w efekcie czasu. A czas, to pieniądz. Bazując na moim doświadczeniu, chciałbym polecić Ci Event Modeling. Spełnia wszystkie powyższe kryteria i był używany przeze mnie przy wielu projektach. Z założeniami tej metody zapoznasz się na stronie EventModeling.org, a my od razu przejdźmy do przykładu.

👾 Przykład: proces rekrutacji jednostki

Nie pracujemy w waterfallu. Nie musisz przed startem implementacji wiedzieć już wszystkiego. W myśl zasady “small design up-front” zaczynamy modelować pierwszy proces, a potem kolejne. Wszystko składamy ze znanych także z EventStormingu building blocków:

  • Komenda (ang. Command) - (niebieska karteczka) - akcja w systemie, intencja zmiany, decyzja podjęta przez użytkownika albo inną część systemu;
  • Zdarzenie (ang. Event) - (pomarańczowa karteczka) - istotny biznesowy fakt, zmieniający stan systemu, powstaje w reakcji na Komendę;
  • Widok (ang. View / Read Model) - (zielona karteczka) - informuje użytkownika o stanie systemu, zmienia się w reakcji na Zdarzenie.

Dzięki temu, modelując proces rekrutacji jednostki, powstał poniższy diagram. W realnym projekcie umieścisz tam mockupy od UX/UI Designera, tak jak ja zrobiłem ze screenami z gry.

Reprezentacja procesu rekrutacji jednostki. Event Modeling pozwala nam opowiedzieć historię jak film i połączyć wszystkie warstwy systemu: akcje użytkownika, projekty interfejsów, REST API, zapis danych itp. na jednym diagramie. Dzięki temu wiemy, iż nie ma luk w wymaganiach. Wynikiem jest projekt, który przekładamy 1 do 1 na kod.
(Kliknij obrazek, aby powiększyć)

Aby przejść z BigPicture EventStormingu, do Event Modelingu musimy wykonać kroki przedstawione poniżej.

  1. Dla zidentyfikowanego procesu biznesowego ułóż zdarzenia chronologicznie, aby opowiadały historię. Możesz wziąć jedno zdarzenie i zastanowić się, co musi się wydarzyć przed nim i co po nim.
  2. Przyporządkuj do zdarzeń ich przyczyny, czyli komendy, które były ich przyczyną.
  3. Użytkownik wywołuje komendę na UI, dlatego nanieś elementy interfejsu użytkownika i w jaki sposób wywołują komendę;
  4. Zdefiniuj Widoki (bazujące na zdarzeniach), aby wiedzieć jakich danych potrzebujemy do przekazania użytkownikowi (lub innej części systemu), na podstawie których będą podejmowane decyzje o wywoływaniu kolejnych komend.
  5. Do widoków też dodaj interfejs użytkownika. zwykle jakieś projekty już są przygotowane, więc dobrze się nimi posiłkować budując wspólne rozumienie wraz z biznesem. Upewnisz się, iż system ma wszystkie potrzebne do prezentacji dane.
  6. Do wszystkich elementów dodaj potrzebne do ich opisania atrybuty, aby zweryfikować czy informacje przepływające przez system są kompletne.

Dzięki temu EventModeling będzie opowiadał historię Twojego systemu jak kolejne klatki w filmie. W szczegóły tej metody będziemy wchodzić w kolejnych wpisach na bardziej rozbudowanych przykładach.

🛂 Granice modelu i autonomia

  • Co musi się wydarzyć przed rekrutacją jednostki (CreatureRecruited)? Musimy wiedzieć ile jest jednostek do zarekrutowania (AvailableCreaturesChanged).
  • A co jeszcze wcześniej? Musi zostać wybudowane siedlisko jednostki w mieście.
  • Ale czy zawsze? Nie. Możemy też rekrutować jednostkę w siedlisku na mapie.

W ten sposób zidentyfikowaliśmy alternatywne wejścia do procesu rekrutacji. Jest to kolejna heurystyka wyznaczania granic, komplementarna do omawianych perspektyw z poprzedniego wpisu. W konsekwencji, aby nasz model był autonomiczny i niepowiązany ściśle z np. procesem rozbudowy miasta, powinniśmy oddzielić go grubą kreską na zdarzeniu AvailableCreaturesChanged od reszty systemu.

W praktyce oznacza to, iż inne modele czy moduły, które mają wpływ na dostępność jednostek (jak wybudowane budowle czy “astrologowie ogłaszają”) będą się komunikować z modułem rekrutacji, wywołując komendę (np. IncreaseAvailableCreatures) skutkującą wspomnianym zdarzeniem. Dzięki czemu, dodawanie kolejnych powodów zmiany dostępnych jednostek (jak np. artefakty bohatera), nie będzie wpływać na tą część systemu.

Co więcej, jest to ściśle powiązane z organizacją pracy — implementacja autonomicznego modułu będzie niezależna od innych toczących się prac programistycznych. Trzeba ustalić jedynie jego API, czyli w tym przypadku komendy — zadania, jakie będą mu zlecane przez inne moduły. Np. moduł świadomy odwiedzającego miasto bohatera i posiadanych przez niego artefaktów będzie mógł zlecać zwiększenie lub zmniejszenie liczby dostępnych jednostek, bez znajomości szczegółów całego procesu rekrutacji.

Możliwy podział na moduły i relacje między nimi. Rozbudujemy i zweryfikujemy wraz z postępem planowania i analizy.

Wszystko brzmi ładnie, ale co jeżeli w innym przypadku, na ostatnie pytanie z góry tego akapitu otrzymasz odpowiedź: “Tak, zawsze”, a ona zmieni się już za miesiąc? Tym zajmiemy się w kolejnych wpisach, zapisz się na newsletter na końcu tego posta, aby ich nie przegapić!

👨‍💻 Przekładanie modelu 1 do 1 na kod

Niech przemówi kod! Na razie zajmiemy się backendem w Kotlinie, ale wykorzystane wzorce są tak generyczne, iż z mojego doświadczenia możesz ich użyć w C#, JavaScript, TypeScript, Ruby i prawdopodobnie też innych językach, z którymi jeszcze nie miałem przyjemność pracować. Dzięki temu w Twój kod będą mogli z łatwością wchodzić programiści innych technologii, stosujący te same wzorce.

Do dzieła! Zobacz jak karteczki symbolizujące komendę i event przekładają się na klasy, a linia między nimi to czysta funkcja (ang. pure function) bez side effectów.

Na powyższym przykładzie rozpatrujemy stronę zapisu (write slice).
(Kliknij obrazek, aby powiększyć)

Na poniższym przykładzie widzisz dwa rodzaje sliceów (po polsku to chyba “wycinków”?) z Event Modlelingu:

  • Write Slice: Command -> Event
  • Read Slice: Event -> View

W tym poście skupimy się jedynie na tym pierwszym.

Użyta funkcja decide jest składową wzorca Decider, którego interfejs w Kotlinie wygląda jak poniżej. Stan (klasa Dwelling) jest wyliczany na potrzeby procesowania komendy z przeszłych zdarzeń, dzięki funkcji evolve. Jeśli jeszcze nie miałeś okazji użyć tego wzorca, to najlepsze znane mi wprowadzenie znajdziesz tutaj Fraktalio (fmodel).

interface Decider<in Command, State, Event> { val decide: (Command, State) -> List<Event> val evolve: (State, Eevent) -> State val initialState: State // funkcje pomocznie, nie wsytępujące we wzorcu: fun decide(events: Collection<E>, command: C) = decide(command, evolve(events)) private fun evolve(givenEvents: Collection<E>): S = givenEvents.fold(initialState) { state, event -> evolve(state, event) } }

Decider w rzeczywistości służy nam do zapewnienia natychmiastowej spójności reguł i niezmienników systemu — czyli implementuje koncept Agregatu. Jest to bardziej funkcyjna forma, która umożliwia też bezproblemowe przełączanie się między sposobami utrwalania danych: Event Sourcingiem lub tradycyjnym snapshotem aktualnego stanu. Preferuję choćby tę nazwę niż wprowadzony przez Erica Evansa Aggregate, który mylnie sugeruje, iż jedynie “agreguje” jakieś dane czy obiekty.

🎖️Zdarzenie obywatelem pierwszej kategorii

Tutaj zdarzenia są prawdziwymi first-class citizen. Skupiamy się na zachowaniach systemu, a stan jest szczegółem implementacyjnym — koniecznym, aby po adekwatnej sekwencji zdarzeń i wykonaniu komendy, nasz system zareagował odpowiednio kolejnym zdarzeniem. W Kotlinie zdarzenia i komendy modelujemy jak poniżej.

sealed interface DwellingCommand { val dwellingId: DwellingId data class RecruitCreature( override val dwellingId: DwellingId, val creatureId: CreatureId, val recruit: Amount ) : DwellingCommand data class IncreaseAvailableCreatures( override val dwellingId: DwellingId, val creatureId: CreatureId, val increaseBy: Amount ) : DwellingCommand } sealed interface DwellingEvent { val dwellingId: DwellingId data class CreatureRecruited( override val dwellingId: DwellingId, val creatureId: CreatureId, val recruited: Amount, val totalCost: Cost ) : DwellingEvent data class AvailableCreaturesChanged( override val dwellingId: DwellingId, val creatureId: CreatureId, val changedTo: Amount ) : DwellingEvent }

Dzięki zastosowaniu zdarzeń zachowanie systemu specyfikujemy dzięki testów, które mają powtarzalną, sformalizowaną i czytelną formę: given (przeszłe zdarzenia) -> when (komenda) -> then (oczekiwane zdarzenia). Jak widzisz, one także bazują na zdarzeniach. Nie zakładamy w nich stanu początkowego ani końcowego, ale jedynie zdarzenia poprzedzające komendę i zdarzenia oczekiwane po jej wykonaniu.

Dokładnie wyrażony w teście omawiany scenariusz z rekrutacją Anioła:

private val portalOfGlory = dwelling(angelId, costPerTroop = resources(GOLD to 3000, CRYSTAL to 1)) @Test fun `given Dwelling with 2 creatures, when recruit 2 creature, then recruited 2 and totalCost = costPerTroop * 2`() { // given val givenEvents = listOf(AvailableCreaturesChanged(dwellingId, angelId, changedTo = Amount.of(2))) // when val whenCommand = RecruitCreature(dwellingId, angelId, recruit = Amount.of(2)) // then val thenEvents = portalOfGlory.decide(givenEvents, whenCommand) val expectedRecruited = CreatureRecruited( dwellingId, angelId, recruited = Amount.of(2), totalCost = Cost.resources(GOLD to 6000, CRYSTAL to 2) ) assertThat(thenEvents).containsExactly(expectedRecruited) }

Implementacja tego zachowania w kodzie produkcyjnym wygląda następująco:

fun dwelling(creatureId: CreatureId, costPerTroop: Cost): IDecider<DwellingCommand, Dwelling, DwellingEvent> = Decider( decide = ::decide, evolve = { state, event -> when (event) { is CreatureRecruited -> state.copy(availableCreatures = state.availableCreatures - event.recruited) is AvailableCreaturesChanged -> state.copy(availableCreatures = event.changedTo) } }, initialState = Dwelling(creatureId, costPerTroop, Amount.zero()) ) private fun decide(command: DwellingCommand, state: Dwelling): List<DwellingEvent> = when (command) { is RecruitCreature -> { if (state.creatureId != command.creatureId || command.recruit > state.availableCreatures) emptyList() else listOf( CreatureRecruited( command.dwellingId, command.creatureId, command.recruit, state.costPerTroop * command.recruit ) ) } is IncreaseAvailableCreatures -> listOf( AvailableCreaturesChanged( command.dwellingId, command.creatureId, command.available ) ) }

Skoro atrybuty czy stan reprezentowany przez klasę Dwelling nie są istotne, to możemy ją dowolnie refaktorować i to w ogóle nie wpływa na testy! Dzięki temu podział atrybutów i znanych ze świata realnego konceptów na reprezentacje w różnych klasach wyniknie naturalnie. Jeśli dane sekwencje zdarzeń nie będą miały na siebie wpływu, to nie ma żadnego argumentu za tym, żeby łączyć je jeden strumień albo zapisywania danych, które nie wpływają na zachowania. Łączenie odczytów i zapisów w jeden model często niebywale komplikuje rozwiązanie. Dlatego wyświetlaniem zajmiemy się później, aby nie zaciemniać projektowanego modelu.

Dzięki zastosowaniu sealed interface do implementacji, to kompilator pilnuje, abyśmy obsłużyli wszystkie komendy (w funkcji decide) i zdarzenia (w funkcji evolve) implementujące dany interfejs. Całość kodu z tego przykładu znajdziesz TUTAJ. // TODO: Opracowac lepiej README i commit

jeżeli zapomnimy obsłużyć jakieś zdarzenie, to naszą pamięć odświeży kompilator.
(Kliknij obrazek, aby powiększyć)

⚖️ Reguły biznesowe i przykłady

Zapewne zastanawiasz się skąd w kodzie metody decide wzięły się ify. Albo inaczej: jak w Event Modelingu wyrazić reguły biznesowe? EventStorming miał na to specjalną żółtą karteczkę. Tutaj idziemy w stronę przykładów. Dzięki temu choćby biznes może Ci potwierdzić czy tak to ma działać przed napisaniem choćby 1 linijki kodu. Jest to w myśl podejścia Behavior-Driven Development, a specyfikacje przyjmują właśnie formę Given-When-Then. Poniżej przykład dla command RecruitCreature. Dla ułatwienia przyjąłem, iż kiedy operacja nie jest dopuszczalna, nic się nie dzieje. Nie są produkowane żadne zdarzenia ani rzucane wyjątki.

Szczegółowe przykłady zachowań opisujące reguły biznesowe dla diagramu Event Modelingu.
(Kliknij obrazek, aby powiększyć)

Event Modeling pozwala nam zweryfikować kompletność informacji w naszym systemie. Kiedy spojrzysz na zdarzenia powyżej, to czy widzisz pewną lukę? Zastanów się chwilę…

Powstaje pytanie: skąd wiadomo, jaki jest koszt rekrutacji jednostki? Kiedy jest to określane? Nie ma tej informacji w żadnym zdarzeniu. Rozpatrzeniem tego przypadku zajmiemy się w kolejnym wpisie. Na potrzeby tego przykładu załóżmy, iż koszt jednostki pochodzi z jakiejś “konfiguracji” albo jest “hardcodowany”.

🤖 ChatGPT? Zanieś, przynieś… wygeneruj testy!

Dodatkowo, dla modeli LLM banalnie proste jest wygenerowanie testów jednostkowych z tak przygotowanych scenariuszy. Możesz niesamowicie przyśpieszyć sobie pracę, stosując prompty jak ja w poniższym przykładzie. Na pewno da się to zrobić choćby lepiej :)

Generowanie testów jednostkowych z Event Modelingu dzięki ChatGPT.
(Kliknij obrazek, aby powiększyć)

👼 Jakość bez Code Review? To możliwe!? Jeszcze jak!

Kiedy mowa o zapewnieniu jakości wytwarzanego oprogramowania, często pojawia się wątek, ile osób musi zaakceptować każdego Pull Requesta. Jednak w momencie Code Review jest już za późno na zapewnienie jakości, a przynajmniej na zrobienie tego relatywnie tanio. Najważniejszą zmianą, jaką możesz zrobić, stosując Event Modeling i wiedząc, jak karteczki przekładają się na kod… jest przesunięcie nacisku zapewnienia jakości z końca procesu, na jego początek. Możnaby to nazwać “design review” lub “architecture review”.

Kiedy mleko się już wylało, byłoby trzeba posprzątać i nalać nowe. Lepiej w ogóle nie doprowadzać do takiej sytuacji. Jaka to korzyść z code review, kiedy po 3 dniach pisania developer dowiaduje się, iż trzeba zmieniać całą koncepcję? Nawet jeżeli racja jest po stronie “recenzenta” to i tak już nikt nie zapłaci 2 razy za tą samą robotę. I mamy sytuację przegrana-przegrana.

Wiedza o modelowaniu pozwala na szybkie eksperymenty i sprawdzanie jakości modelu na whiteboardzie. Dzięki temu unikniesz rzucania się sobie do gardeł podczas code review.

Dzięki Event Modelingowi możesz zmarginalizować rolę code review. Nieporównywalnie tańsze jest przesunięcie karteczki, które trwa kilka sekund niż zmiana całej koncepcji w kodzie. Tym bardziej, gdy kod został już napisany i biznes zapłacił za jego powstanie kilka dni pracy developera! Dlatego przeprowadzaj eksperymenty i sprawdzaj atrybuty jakościowe swojego modelu na whiteboardzie zamiast już wykonanym programie. Jeśli Twój model może być przetłumaczony jednoznacznie do kodu, to code review staje się już tylko formalnością weryfikacji wykonania wcześniej opracowanego modelu zgodnie z ustaleniami. A w bardziej zdyscyplinowanym zespole nie będzie w ogóle potrzebne. Tym samym podniesiesz jakość systemu i zaoszczędzisz mnóstwo czasu programistów, a czas — to pieniądz, czyli czysty win-win.

🕒 Modelowanie 24h/7

Nie czekaj z ćwiczeniem modelowania na kolejny wpis! Wyjdź teraz z domu i obserwuj, jak działa świat przesiąknięty technologią wokół Ciebie:

  • Wybierasz się w podróż pociągiem. Jak zamodelowałbyś proces rezerwacji biletów? Jak zadbasz o to, aby było to wydajne i nie było możliwości dwóch pasażerów na 1 miejsce? Lepiej niż w PKP :) ?
  • Publikujesz ofertę pracy. W jaki sposób powinno to wyglądać? Czy ofertę zawsze trzeba dodać przez kreator? A może ktoś ją importuje z excela? Jakie nowe akcje są możliwe na opublikowanej ofercie?
  • Wsiadasz na rower miejski. Czy wiesz jakie procesy tam zachodzą? Jak jest zorganizowany odbiór rowerów zostawionych poza stacjami?

Zakładam, iż nie programujesz (jeszcze!) hipernapędu czy teleportu, więc prawdopodobnie inni rozwiązali już podobne problemy, jakie Ty teraz napotykasz. Ćwicz swoją intuicję, wystawiaj się na różne przypadki, a po czasie zaczniesz zauważać analogie w Twoich projektach. Oczywiście lepiej robić to razem niż samemu… dlatego, jeżeli chciałbyś dalej ze mną przechodzić przez modelowanie Heroes III, zapisz się na listę mailingową TUTAJ. Zawsze otrzymasz informacje o nowym wpisie, a także zaproszenia na spotkania LIVE, gdzie razem będziemy dalej eksplorować domenę Heroes III i przekładać ją na kod.

A może chcesz wprowadzić Event Modeling w Twojej organizacji? Skontaktuj się ze mną najlepiej na LinkedIn albo pisz email na: mateusz@nakodach.pl

Idź do oryginalnego materiału