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

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.

Astrologowie ogłaszają - Event Modeling.

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 oprogramowania 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 w połączeniu z EventStormingiem, Context Mappingiem i opisanymi w poprzednim wpisie technikami. Z założeniami tej metody zapoznasz się na stronie m.in. w tym krótkim kursie, a my od razu przejdźmy do przykładu.

Jeśli Ty stosujesz coś innego, np. EventStorming Process Level czy Domain Storytelling i osiągasz podobne rezultaty, to też jest OK — koniecznie podziel się w komentarzu swoimi sposobami, abym ja i inni mogli rozbudowywać swój wachlarz możliwości!

👾 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) - ważny 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.

Event Modeling pozwala nam opowiedzieć historię jak film

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, że 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ę. Zadbaj o to, aby interfejs wynikał z Twojego modelu domeny, a nie model z tego, jak wygląda UI (frontend zmienia się częściej niż reguły w Twojej domenie);
  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, aby umożliwić mu podejmowanie kolejnych decyzji na podstawie wyświetlanych danych. Zazwyczaj jakieś projekty już są przygotowane, więc dobrze się nimi posiłkować budując wspólne rozumienie wraz z biznesem. Upewnisz się, że 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, że 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.

Astrologowie ogłaszają - Event Modeling.

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śli 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, że z mojego doświadczenia możesz ich użyć w C#, JavaScript, TypeScript, Ruby i zapewne 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).

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ń, za pomocą 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ę nawet tę nazwę niż wprowadzony przez Erica Evansa Aggregate, który mylnie sugeruje, że 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 właściwej 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 za pomocą 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.

Jeśli zapomnimy obsłużyć jakieś zdarzenie, to naszą pamięć odświeży kompilator.

Jeśli 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 nawet biznes może Ci potwierdzić czy tak to ma działać przed napisaniem nawet 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, że 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.

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, że 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ć nawet lepiej :)

Generowanie testów jednostkowych z Event Modelingu za pomocą ChatGPT.

Generowanie testów jednostkowych z Event Modelingu za pomocą 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 (EventStorming Design Level, czy jakąkolwiek inną technikę zapewniającą podobne rezultaty) 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ę, że trzeba zmieniać całą koncepcję? Nawet jeśli racja jest po stronie “recenzenta” to i tak już nikt nie zapłaci 2 razy za tą samą robotę. I mamy sytuację przegrana-przegrana.

strologowie ogłaszają - modularyzacja

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! Wyobraź sobie “radość” Twojego kolegi z zespołu, gdy myśli, że skończył pracę, a Ty uświadamiasz go, że tak naprawdę wszystko jest do zmiany. 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. W bardziej zdyscyplinowanym zespole code review może w ogóle nie być potrzebne, szczególnie jeśli dojdzie do tego pair-programming i feature flagi. Oczywiście zakładając, że kształtujecie swój sposób pracy i macie nad nim pęłną kontrolę, a nie jest to np. projekt open source. Tym samym podniesiecie jakość oprogramowania i zaoszczędzicie 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, że nie programujesz (jeszcze!) hipernapędu czy teleportu, więc zapewne 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śli 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

❤️ Inni też tym żyją

Jeśli już teraz chcesz dowiedzieć się więcej, o planowaniu bazującym na założeniach Event Modelingu i przekładaniu diagramów na kod, to zobacz:

Podziel się wpisem:

✉️ Lista Mailingowa

Otrzymasz materiały o Event Sourcingu, Event Modelingu, Domain-Driven Design, programowaniu obiektowym i funkcyjnym oraz innych powiązanych tematach. A także zaproszę Cię na wspólne sesje modelowania.

🫱🏻‍🫲🏽 Mentoring Online

Nauka Domain-Driven Design? Podział projektu na moduły? Zaplanowanie architektury? Konsultacja CV? A może rozwój w kierunku Seniora? Spotkajmy się! Umów się na mentoring lub konsultacje. Wspólnie opracujemy plan dla Ciebie 🫵

📖 SOLIDne CV?

🤔 Szukasz teraz nowej pracy? Masz wymagane kompetencje, ale nikt nie daje Ci szansy ich zaprezentować? Zmień to!

CV na kodach

💪 Przeprowadziłem ponad 100 rozmów rekrutacyjnych i widziałem tysiące CV. Nieraz miałem okazję porównać CV odrzucane, z tymi które robiły największe wrażenie. Na tej podstawie opracowałem porady, które pomogą Ci się pozytywnie wyróżnić i przejść ten etap rekrutacji.

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ę!