Modelowanie DDD - sparing szachowy z rankingami

0

Hej, już kiedyś poruszałem ten wątek, ale chyba poszedłem nie w tą stronę.
Chciałem poprawnie za pomocą praktyk DDD zamodelować mecz szachowy. Idąc tropem agregatów DDD powiniem tak zamodelować agregat, aby zamknąć w nim niezmienniki.

Agregat powinien dostać ID wygranego gracza i wyliczyć ich nowy ranking. Wynika z tego, że powinien on przechowywać aktualne rankingi obu graczy. Na początku nie mogłem w ogóle dojść do tego jak za pomocą id tego agregatu wyciągnąć najnowsze rankingi z bazy. Zrozumiałem wreszcie, że agregat nie musi być przecież tabelką w DB. Zrobiłem zatem coś takiego:

data class Rank(val value: Int) {

    fun apply(difference: Int) = Rank(value + difference)

}

data class MatchScoreId(val player1Id: String, val player2Id: String)

data class MatchScore(val id: MatchScoreId, private val player1Rank: Rank, private val player2Rank: Rank) {

    fun applyResult(winPlayerId: String, calculateRankDifference: (Int, Int) -> Int): MatchScore {
        val difference = calculateRankDifference(player1Rank.value, player2Rank.value)
        return copy(
                player1Rank = player1Rank.apply(difference),
                player2Rank = player1Rank.apply(-difference)
        )
    }
}

Teraz po prostu dochodzi z frontu jakiś request z winPlayerId i losePlayerId. Pobieram to z repozytorium, wykonuję metodę applyResult i zapisuję.

Jeśli chodzi o szczegół czyli DB to mam tam po prostu dużą tabelę Player z ich obecnymi rankingami i innymi danymi specyficznymi dla gracza. Agregat natomiast modeluje to co potrzebuje. Mecze nie muszę być zapisywane w DB - tak akurat wymyśliłem sobie biznes, że nie musi to być zapisywane jako fakt historyczny. Po prostu wysłanie requestu opisanego powyżej z idWin i idLose jest triggerem naliczenia nowych rankingów.

Czy to jest ok?

Nie muszę dzięki temu uderzać do innych serwisów po aktualne rankingi. Wszystko jest skrojone w agregacie pod wykonanie biznesu naliczenia nowych rankingów.

0

@Charles_Ray:

Jako, że ogarniasz modelowanie - może będziesz w stanie mnie nakierować.

1

Twój model pozwala na wiele zwycięstw :) a co z sytuacją patową? Po drugie dlaczego agregat jest data class?

Musiałbym zobaczyć jakie są komendy i niezmienniki. Obecnie widzę, że można tylko zakończyć mecz wskazując zwycięzcę. Nie ma jakichś pionów na planszy, żadnych ruchów? Jak wygląda cały proces takiego meczu?

Ja bym kombinował w takim kierunku, że mam agregat Match, który przyjmuje komendę endWithResult() co emituje event MatchEnded. Rekalkulacja rankingu IMO to inny bounded context, zakończenie meczy może trigerrować wiele rzeczy a dzięki eventowi masz to zdecouplowane.

0

@Charles_Ray:
To nie przeszkadza, że pozwalam na wiele zwycięstw. Na potrzeby obecnego problemu załóżmy, że nie mam tam remisu. Z frontu dochodzi winnerId, loserId - masz to zestawić i zliczyć im rankingi. Jedyne to chcesz zapisać w systemie to ich nowe rankingi obliczone na podstawie starych i fakt, że user1 wygrał, a user2 przegrał (lub odwrotnie). Żadnego stanu gry w kontekście pionków, ruchów i innych nie chcę tutaj zapisywać.

Reasumując: z frontu może mi dojść raz winnerId = 1 i loserId =2 i wtedy ich rankingi to założmy 5050 i 4950, a może dojść taki event 10x i wtedy ich rankingi to 5300 i 4700.

Z Twoim podejściem kiedy z agregatu Match emitujesz event to w jaki sposób w 1 transakcji chciałbyś potem zapisać nowe rankingu obu playerów?

0

A do czego potrzebujesz transakcji? Zmieniasz stan meczu na zakończony i emitujesz event. Kontekst rankingu subskrybuje się na to zdarzenie i opaździerza zmianę punktów graczy. Operacja powinna być idempotentna, tzn. przy retransmisji eventu nie powinno się zmienić rankingu wiele razy (jakaś kontrola wersji). Innym subskrybentem tego zdarzenia może być np dashboard aktualnie rozgrywanych meczy w systemie. To Ci daje podejście eventowe. Nie powinieneś w jednej transakcji modyfikować wielu agregatów ze względu na skalowalność. Jeśli duży ruch nie jest problemem, to możesz sobie dociągnąć te rankingi i zrobić update - wtedy nie kopiesz się z at least once delivery i masz to atomowo.

0

@Charles_Ray:
Właśnie chodzi mi o to jak wygląda ten kontekst rankingu, który zasubskrybuje się na wynik meczu. Z tą transakcją chodzi mi o to, że rankingi obu graczy muszą być zmienione w jednej transakcji (i jak to zamodelować - moje podejście to spełnia).

I czy przy biznesie, który ja opisałem jest potrzebne emitowanie eventu?

0

Mi chodziło o coś prostego :) Match nic nie wie o sposobie przeliczania rankingu - zajmuje się tym inny kontekst (moduł, mikroserwis, ...).

class Match {

   fun finish(matchId: MatchId, winnerId: UserId) {
      val match = matchRepo.findById(matchId)
      match.finish(winnerId)
      matchRepo.save(match)
      eventBus.dispatch(MatchFinished(matchId, match.players, winnerId))
   }
}

class PlayerRanking {

   fun onMatchFinished(event:MatchFinished) {
    val (player1, player2) = playerRepo.findByIds(event.players)
    recalculateRankings(player1, player2, event.winnerId)
    playerRepo.save(player1, player2)
  }

}
0

@Charles_Ray: jak rozumiem klasa Match i PlayerRanking to są jakieś serwisy aplikacyjne/fasady tak?

W metodzie onMatchFinished z tego co widzę zapisujesz dwa agregaty w transakcji - player1 i player2.

1

W zasadzie to są fasady/application servicy, które żyją w niezależnych komponentach. Nie widzę w tym rankingu jakichś reguł, których trzeba by bronić, wiec potraktowałem to najprościej. Można tez ogarnąć to rzeczywiście osobnymi transakcjami - po jednej na playera.

0

No właśnie mi się wydaje, że reguły biznesowe tutaj kręcą się wokół rankingów obu graczy i że, aby zachować spójność to musisz w 1 transakcji, czyli w 1 agregacie przeliczyć i zaktualizować oba. Podobnie jak jest chyba z przelewem bankowym?

@Charles_Ray sorki, że tak dopytuję, ale chcę w pełni zrozumieć koncepcję z zamykaniem reguł biznesowych w agregacie. Po prostu byłem przesiąknięty przekonaniem, że agregat to 1 do 1 z tabelką w DB, a widzę, że właśnie niekoniecznie, bo pewne pola agregatu mogą być selectami z różnych rekordów tabeli.

1

Jeśli te konta są w różnych bankach, to w jaki sposób to ma być transakcyjne? :) w temacie reprezentacji stanu agregatu możesz tez zaczepić się o event sourcing - zamiast mutować nagrywasz zmiany.

Koncepcja agregatu polega na tym, że podnosisz z bazy minimum danych potrzebnych do walidacji reguł. Może to być wiele tabelek.

0

@Charles_Ray: No ok, jeśli to są dwa konta to masz rację. Zatem zostańmy po prostu przy aktualizacji rankingów. Czy zaprojektowany przeze mnie MatchScore ma sens? Uwzględniając oczywiście biznes, który wymagam.

Czy zapis agregatu musi być do jednego wiersza w DB? Czy mogą to być dwa inserty do jednej tabelki albo tez do wielu tabelek?

1

Nie wiem jaki jest ten biznes i na jaki poziom spójności jesteś w stanie sobie pozwolić. Jest prezka Sobótki, podczas której pokazał, jak ten sam koncept można zamodelować na 3 różne sposoby w zależności od tego, jakie mamy wymagania biznesowe. Jak napiszesz, jakie warunki ten system/kawałek domeny ma spełniać, to możemy coś pomodelować. Tabelki są wtórne, może być wiele (np. zamówienie i pozycje zamówienia).

0

@Charles_Ray Widziałem ten talk Sobótki kilka razy w ciągu 4 lat - dopiero ostatnio zacząłem rozumieć sedno tego filmu. Pewnie teraz usiąde raz jeszcze z kartką i długopisem, żeby wydusić z tego maksimum.
Tak jak mówiłem - w moim przypadku biznes jest bardzo prosty. To na czym mi zależy to aby zachować spójność obu rankingów i zapisać je w jednej transakcji. Po przeliczeniu rankingów wykonuję inne operacje- ale one nie muszą zajść w systemie od razu, dlatego są reakcją na event RanksRecalculated.

Także reasumując - jak dojdzie z frontu winPlayerId i losePlayerId to to na czym mi zalezy OD RAZU to rekalkulacja ich rankingów na podstawie wyniku i obecnego rankingu oraz jakiejś tam RankCalculationPolicy.

Może po prostu zmyliło Cię to, że napisałem o szacach - po prostu chciałem uprościć, bo szachy to najprostszy przyjład, Ja akurat modeluję logikę dla apki w stylu Facemash. User na froncie dostaje 2 zdjęcia lasek, klika jedną z nich i ma się przeliczyć ranking lasek. Po przeliczeniu idzie event. Na event podłączają się mniej ważne logiki mogące zajść później - dodanie jakiś punktów dla usera klikającego itd.

Jeszcze mam takie pytanie: czy jak chcemy sobie trzymać jakiś ReadModel w systemie, który jest tworzony na podstawie eventów to wtedy ten widok budujemy na podstawie eventów domenowych, które służa do wykonania biznesów czy emitujemy osobne eventy stricte pod agregacje danych do róznych read modeli? Mam po prostu osobny mikroserwis z elasticsearchem, który ma być odpowiedzialny za wystawienie Query endpointów dla różnych statystyk, raportów itd.

0

Chyba nie ma co tutaj kombinować. Najmniejszym możliwym agregatem jest (userId, ranking). Cały ten mecz jest w takim przypadku view modelem (kontrowersyjne), nie ma żadnego stanu, który trzeba zapisywać (chyba, że chcesz zapewnić, że się nie powtórzy konfiguracja).

Odnośnie read modeli - jeśli event opuszcza serwis, to staje się częścią kontraktu, jest opublikowany. Rozdziela się eventy domenowe wewnątrz kontekstu od tych używanych do integracji z uwagi na elastyczność (ewolucja modelu, kompatybilność).

0

@Charles_Ray: ale jeśli agregatem jest (userId, ranking) to podczas przekalkulowania rankingów i zapisania ich musisz zapisać dwa agregaty a nie jeden w transakcji. Bo będziesz miał wtedy do zapisu:
(userId: 1, rank: 6000) oraz (userId: 2, rank: 6500). Ja wiem, że najprostszy model tego to tak jak mówisz userId + rank, ale to prowadzi do 2 agregatów per transakcja, bo nie ma bata, musisz zrobić tak:

@Transactional
public void recalculateRanks(final long winPlayerId, final long losePlayerId) {
        final User winner = userRepo.findById(winPlayerId);
        final User loser = userRepo.findById(losePlayerId);
        
        final double rankDifference = rankCalculationPolicy.calculate(winner.getRank(), loser.getRank());
        winner.recalculate(rankDifference);
        loser.recalculate(-rankDifference);
        userRepo.save(winner); // pierwszy agregat zapisujesz
        userRepo.save(loser); // drugi agregat zapisujesz
    }

W moim modelu ogrywam to jednym agregatem, który wzbogaca model, bo też przelicza sobie w środku ranking:

@Transactional
public void recalculateRanks(final long winPlayerId, final long losePlayerId) {
     final MatchResult matchResult = matchResultRepo.find(new MatchResultId(winPlayerId, losePlayerId));
     matchResult.recalculate(winPlayerId, rankPolicy); // zamknięta logika naliczania w środku
     matchResultRepo.save(matchResult); // jeden save
 }

Zauważ, że zapis mojego agregatu to są dwa updaty na tabeli usera. Nie jest to standardowy update/insert jednego rekordu w konkretnej tabelce.

1

Twoje wygląda spoko zakładając, że dany gracz może być jednocześnie tylko w jednym meczu - musisz mieć w agregacie aktualny rating. Przez to jakieś admińskie zmiany rankingu nie będą możliwe. Natomiast jak najbardziej może to być poprawny model.

0

Może być w kilku meczach - dana kobietka może się wyświetlić kilku userom w sparringu, ale w tym biznesie to nie ma znaczenia - ranking naliczany jest na podstawie tego co aktualnie wyciągniesz z DB selectem :D

Dzięki wielkie! O to mi chodziło :) Chciałem się upewnić czy nie kombinuję w totalnie złym kierunku.

PS. Co do read modelu, mówisz o rozdzielaniu tych zewnętrznych, na które mogą słuchać inne mikroserwisy od tych które nasłuchuje tylko ten sam mikroserwis. Akurat u siebie nie mam takich eventów lokalnych, które słucha ten sam mikroserwis.

Mi chodziło o rozdzielenie eventów emitowanych na potrzeby domenowe od tych które maja zasilać elasticsearcha. Czy to też się rozdziela?

0

@Charles_Ray:

Jeszcze jedno pytanie mnie naszło - co w sytuacji, kiedy Twoje reguły biznesowe polegają na sprawdzeniu unikalności jakiegoś pola w systemie albo innego niezmiennika, który wymaga sprawdzenia wszystkich rekordów w DB?

Zaciągasz to wszystko do agregatu czy robisz to jaka jakąś UniquePolicy, której wymaga Factorka i po prostu sprawdzasz osobno?

0

Wszystkie agregaty "siedzą" w repozytorium, więc to odpowiedzialność tego building blocka.

0

Ale to jest tak, że do agregatu ładujesz select * do jakiejś listy i sprawdzasz jakimś streamem unikatowność czy w agregacie masz pole unique: Boolean do której zaciągasz skrojonego selecta sprawdzającego to ?

0

Nie wydaje mi się, ze agregat musi zawierać metadane o całej populacji, może dostać je z zewnątrz. To nie jest cześć stanu agregatu.

0

Czyli byś to zrobił jako jakąś UniquePolicy i dostarczył z zewnątrz do faktorki ?

1

Ja bym chyba to sprawdzenie najzwyczajniej w świecie wrzucił do serwisu, w którym mam kolejne kroki utworzenia nowego agregatu. Spójrz tutaj https://enterprisecraftsmanship.com/posts/email-uniqueness-as-aggregate-invariant/ Postawiłbym na pragmatyzm i czegoś, co mogę ograć po prostu constraintem na bazie, nie modelował jako politykę.

0

@Charles_Ray: dzięki, mega spoko stronka, poczytam sobie bo ciekawe materiały.

1 użytkowników online, w tym zalogowanych: 0, gości: 1