**🇬🇧 This post is also available in English on LinkedIn - original version (CLICK)
**
Specyfikacja Dynamic Consistency Boundary mówi, że zapytanie failIfEventsMatch (służące do
wykrywania współbieżnych modyfikacji w ramach zdefiniowanej granicy spójności) jest zazwyczaj tym samym
zapytaniem, które służy do budowy modelu decyzyjnego.
Jakiś czas temu, tłumacząc DCB znajomemu na festiwalu pizzy, doznałem totalnego olśnienia — słowo „zazwyczaj” ma większe
znaczenie, niż się wydaje. Dlaczego? Bo nie każde zdarzenie, które wpływa na Twoją decyzję, jest w stanie złamać Twój
niezmiennik. A kiedy to zauważysz, możesz odblokować znaczące korzyści w zakresie współbieżności — bez poświęcania
spójności stanu systemu.
Przykładowa domena: system salda kredytowego
Rozważmy prosty system salda kredytowego. Istnieją dwie komendy:
TopUpCredits — doładowuje kredyty (produkuje zdarzenie CreditsToppedUp, zmiana salda: +N)
UseCredits — zużywa kredyty (produkuje zdarzenie CreditsUsed, zmiana salda: -N)
Każda komenda wymusza własny niezmiennik:
UseCredits: saldo >= żądana kwota (nie można wydać więcej, niż się posiada)
TopUpCredits: saldo + doładowanie <= maxCredits (nie można przekroczyć limitu konta, np. 100)
Obie komendy potrzebują aktualnego salda, aby podjąć decyzję. To oznacza odczytywanie zarówno zdarzeń CreditsToppedUp,
jak i CreditsUsed przy użyciu Event Sourcing z DCB.
Projektowanie granicy spójności
Zanim zagłębimy się w temat — dwa kluczowe pojęcia, których będę używał w artykule:
- SourcingCriteria — definiuje, które zdarzenia odczytujemy w celu zbudowania modelu decyzyjnego.
- AppendCriteria — definiuje, które współbieżne zdarzenia powodują konflikt przy próbie dopisania nowych zdarzeń. Na
razie skupiamy się wyłącznie na typach zdarzeń — zakładamy, że operujemy w zakresie salda pojedynczego konta.
Skupmy się na komendzie UseCredits i rozważmy, jak zaprojektować jej SourcingCriteria i AppendCriteria — oraz jakie to
niesie konsekwencje.
Wszystkie poniższe scenariusze mają ten sam początkowy stan event store’a. Jedyne, co się zmienia, to sposób
definiowania AppendCriteria — oraz współbieżne komendy, które są wydawane.
Zapisane w Event Store zdarzenia:
CreditsToppedUp (zmiana: +1)
CreditsUsed (zmiana: -1)
CreditsToppedUp (zmiana: +1)
SourcingCriteria (wspólne dla wszystkich scenariuszy): CreditsToppedUp, CreditsUsed — oba typy zdarzeń są zawsze
potrzebne do obliczenia aktualnego salda i podjęcia decyzji, czy zaakceptować, czy odrzucić komendę UseCredits.
Wczytane saldo: (+1 -1 +1) = 1
Scenariusz nr 1: SourcingCriteria = AppendCriteria
AppendCriteria: CreditsToppedUp, CreditsUsed — takie same jak SourcingCriteria (każde współbieżne zdarzenie
kredytowe powoduje konflikt)
Współbieżne komendy: TopUpCredits(1) i UseCredits(1)
Załóżmy, że TopUpCredits(1) wygrywa wyścig i dopisuje zdarzenie CreditsToppedUp na pozycji 4. Następnie
UseCredits(1) próbuje dopisać CreditsUsed — wczytane saldo wynosiło 1 (wystarczające), ale wykrywa nowe zdarzenie
CreditsToppedUp. Konflikt. Saldo nigdy nie było niewystarczające — doładowanie podniosło je do 2. A mimo to
odrzuciliśmy komendę, bo zapytanie AppendCriteria wykryło współbieżną modyfikację.
Rozważmy teraz odwrotny przypadek: UseCredits(1) wygrywa wyścig i dopisuje CreditsUsed na pozycji 4 (saldo spada do
0). Następnie TopUpCredits(1) próbuje dopisać CreditsToppedUp — wczytane saldo wynosiło 1, sprawdzenie
1 + 1 <= 100 (daleko poniżej maksimum), ale wykrywa nowe zdarzenie CreditsUsed na pozycji 4. Konflikt. Saldo
spadło — jest teraz jeszcze dalej od maksymalnego limitu. Doładowanie jest bardziej zasadne niż wcześniej, a mimo to
zostało odrzucone, ponieważ AppendCriteria wykryło jakąkolwiek współbieżną zmianę, niezależnie od kierunku. Komenda
została odrzucona, bo COKOLWIEK się zmieniło w tym saldzie (nieważne CO się zmieniło).
Pomyśl o tym — „nie możesz doładować kredytów, ponieważ ktoś inny ich użył.” Wyobraź sobie programistów spalających
kredyty/tokeny firmowego konta narzędziami AI przez cały dzień, podczas gdy osoba z finansów próbująca doładować konto
musi
ponawiać próby raz za razem, czekając na chwilę, gdy nikt nie korzysta z kredytów. To absurd. Spróbujmy być mądrzejsi.
Scenariusz nr 2: SourcingCriteria ≠ AppendCriteria
AppendCriteria: wyłącznie CreditsUsed — tylko zdarzenia zmniejszające saldo mogą złamać niezmiennik; doładowania
są wykluczone
Współbieżne komendy (takie same jak wcześniej): TopUpCredits(1) i UseCredits(1)
Rozpoczynamy przetwarzanie obu komend w tym samym czasie (z trzema pierwszymi zdarzeniami w Event Store).
TopUpCredits(1)
wygrywa wyścig i dopisuje CreditsToppedUp na pozycji 4. Następnie UseCredits(1) próbuje dopisać CreditsUsed —
wczytane saldo wynosiło 1 (wystarczające). AppendCriteria szuka wyłącznie współbieżnych zdarzeń CreditsUsed. Nowe
CreditsToppedUp na pozycji 4 nie pasuje do AppendCriteria, więc jest ignorowane. Obie komendy kończą się sukcesem.
Zero rywalizacji.
Zauważ, że dwie współbieżne komendy UseCredits nadal poprawnie wchodzą w konflikt — obie produkują CreditsUsed,
które jest w AppendCriteria.
To odblokowuje jeszcze większą współbieżność bez poświęcania poprawności. Aby podjąć biznesową decyzję, czy
zaakceptować, czy odrzucić komendę, zazwyczaj używamy jednego EventCriteria, ale na podstawie tego przykładu widać, że
kryteria dla source (co odpytujemy, by wczytać stan) i append (co odpytujemy, by sprawdzić konflikty) mogą w
niektórych przypadkach zasadnie się różnić. Można to zapewne uogólnić: zdarzenia, które nie mogą spowodować naruszenia
Twojej reguły biznesowej, nie muszą być częścią AppendCriteria.
Sprawdzaj wyłącznie współbieżne zdarzenia, które mogą naruszyć Twój niezmiennik. Ignoruj zdarzenia, które mogą jedynie
sprawić, że Twoja decyzja będzie bardziej zasadna.
Kod: Axon Framework 5 DCB
Oto moja propozycja, jak mogłoby to wyglądać z użyciem DCB API Axon Framework 5 (w Kotlinie):
@EventSourcedEntity
data class CreditsBalance(val balance: Int = 0) {
companion object {
@SourcingCriteriaBuilder
@JvmStatic
fun sourcingCriteria(accountId: String): EventCriteria =
EventCriteria
.havingTags(Tag("accountId", accountId))
.andBeingOneOfTypes("CreditsToppedUp", "CreditsUsed")
@AppendCriteriaBuilder
@JvmStatic
fun appendCriteria(accountId: String): EventCriteria =
EventCriteria
.havingTags(Tag("accountId", accountId))
.andBeingOneOfTypes("CreditsUsed")
}
@CommandHandler
fun handle(cmd: UseCredits, appender: EventAppender) {
require(balance >= cmd.amount) { "Insufficient credits" }
appender.append(CreditsUsed(cmd.accountId, cmd.amount))
}
@EventSourcingHandler
fun on(event: CreditsToppedUp) = copy(balance = balance + event.amount)
@EventSourcingHandler
fun on(event: CreditsUsed) = copy(balance = balance - event.amount)
}
To DCB w akcji: @SourcingCriteriaBuilder wczytuje wszystkie zdarzenia kredytowe do odtworzenia stanu.
@AppendCriteriaBuilder sprawdza konflikty wyłącznie dla zdarzeń użycia kredytów.
Scenariusz nr 3: Co jeśli reguła biznesowa jest już naruszona?
AppendCriteria: wyłącznie CreditsUsed — rozdzielone kryteria (tak jak w scenariuszu nr 2)
Współbieżne komendy: TopUpCredits(1) i UseCredits(2) — uwaga: żądana kwota (2) przekracza aktualne saldo (1)
UseCredits wczytuje saldo=1, sprawdza 1 < 2 i natychmiast odrzuca komendę z błędem InsufficientCredits.
Reguła biznesowa jest łamana zanim nastąpi jakakolwiek próba dopisania. Żadne zdarzenia nie są produkowane, żaden
warunkowy append nie jest wykonywany, żaden konflikt współbieżności nie może zostać wykryty — niezależnie od tego, jak
skonfigurowano AppendCriteria. Sprawdzenie AppendCriteria uruchamia się dopiero wtedy, gdy istnieją zdarzenia do
dopisania. W tym przypadku komenda została odrzucona i nic nie wyprodukowała.
Rozdzielone kryteria nie zmieniają wyniku, gdy niezmiennik jest już naruszony w momencie wczytywania stanu.
Niezależnie od tego, czy to niewystarczające saldo, przekroczona pojemność, czy jakikolwiek inny niespełniony warunek —
jeśli reguła biznesowa odrzuca komendę, żadne zdarzenia nie są produkowane i AppendCriteria nigdy nie wchodzi w grę. To
samo odrzucenie w obu podejściach — brak regresji.
Scenariusz nr 4: Przypadek brzegowy — niespójna historia przy zapisywaniu zdarzeń odrzucenia
Ale… sprawdźmy jeszcze jeden przypadek, zanim zaczniemy świętować. Jest jeden realny kompromis do rozważenia.
Ma to znaczenie wyłącznie jeśli utrwalasz zdarzenia o niepowodzeniu operacji biznesowej (np. CreditsUsageFailed).
AppendCriteria: wyłącznie CreditsUsed — rozdzielone kryteria (tak jak w scenariuszach nr 2 i 3)
Współbieżne komendy: TopUpCredits(1) i UseCredits(2) — zdarzenia odrzuceń komend są utrwalane
Jeśli TopUpCredits wygra wyścig:
- Pozycja 4:
CreditsToppedUp (saldo wynosi 2)
- Pozycja 5:
CreditsUsageFailed z powodem „saldo wynosiło 1”
UseCredits wczytał saldo=1 zanim pozycja 4 istniała, więc zapisane zostało niepowodzenie z nieaktualnym saldem.
Czytając log
sekwencyjnie, można by pomyśleć: „saldo wynosiło 2 na pozycji 4, więc dlaczego pozycja 5 zakończyła się niepowodzeniem z
saldem=1 jako powodem niewystarczających kredytów?”
Odrzucenie na pozycji 5 jest historycznie nieodpowiednie, ale mimo to —
reguły biznesowe nie zostały naruszone, a stan systemu jest spójny.
Ma to znaczenie, gdy zdarzenia o niepowodzeniach są istotne dla biznesu — wyciągi bankowe pokazujące odrzucone
płatności, logi
subskrypcji, wykrywanie podejrzanej aktywności na podstawie wzorców odrzuceń. Przy rozdzielonych kryteriach możemy
zapisać odrzucenie,
które nie musiałoby mieć miejsca, gdybyśmy utrzymywali kryteria symetryczne i wykrywali konflikt przy każdym
współbieżnym zdarzeniu zmieniającym to, co wcześniej wczytaliśmy.
Prawdziwe pytanie brzmi: czy optymalizujesz pod kątem dostępności (większa współbieżność), czy pod kątem w pełni
dokładnej historii decyzji i kompletnie spójnego event loga?
W praktyce większość systemów nie utrwala zdarzeń, które nic nie zmieniają w stanie systemu — po prostu jedynie odrzuca
komendę / rzuca exception. Dla takich
systemów rozdzielenie SourcingCriteria i AppendCriteria jest uzasadnioną optymalizacją bez żadnych negatywnych skutków.
Ale miej to na uwadze, bo „z wielką mocą (jaką daje Ci DCB) wiąże się wielka odpowiedzialność”.
Historia lubi się powtarzać
Im więcej o tym myślę, tym więcej znajduję przykładów, w których nie ma symetrii między SourcingCriteria a
AppendCriteria.
Weźmy klasyczny przykład DCB: studenci zapisujący się na kurs z ograniczoną liczbą miejsc.
Kiedy się zapisuję, co ma znaczenie? To, że inny student zapisał się współbieżnie — ponieważ zapisanie obu naraz
mogłoby skutkować przekroczoną maksymalną liczbą miejsc.
Ale jeśli ktoś wypisał się od czasu, gdy odczytałem liczbę wolnych miejsc, zwalniając jedno miejsce? To
jedynie sprawia, że mój zapis jest bardziej zasadny, nie mniej.
Ten sam wzorzec. Ta sama asymetria. SourcingCriteria ≠ AppendCriteria.
Ta asymetria redukuje możliwość konfliktowych zmian bez poświęcania spójności.
Zasada kciuka: Dla każdego niezmiennika, w którym jedne zdarzenia przesuwają wartość w kierunku naruszenia, a inne
od niego — uwzględniaj w AppendCriteria wyłącznie zdarzenia przesuwające wartość w kierunku naruszenia. Dla
UseCredits (saldo >= 0) oznacza to współbieżne zdarzenia zmniejszające. Dla TopUpCredits (saldo <= max) oznacza to
współbieżne zdarzenia zwiększające.
Obietnica agregatu — wreszcie spełniona
Istnieje dobrze znana zasada DDD: „Agregaty (granice spójności) powinny być tak małe, jak to możliwe, ale
nie za małe.”
W przypadku tradycyjnych agregatów było to w najlepszym razie życzenie. Granica agregatu była zawsze symetryczna (
często zbyt duża) —
to, co czytasz, to jednocześnie to, co blokujesz. Zmniejszenie agregatu oznaczało utratę stanu potrzebnego do podjęcia
decyzji; powiększenie go oznaczało niepotrzebną rywalizację. Nie dało się mieć jednego bez drugiego.
DCB łamie tę symetrię. Po raz pierwszy granica tego, co musisz wiedzieć, jest odseparowana od granicy tego, co może z
Tobą kolidować. Możesz sourcować więcej — czytać wszystko, co jest potrzebne do podjęcia decyzji biznesowej —
jednocześnie utrzymując granicę spójności wycelowaną wyłącznie w zdarzenia, które faktycznie mogą naruszyć Twój
niezmiennik.
Framework projektowania AppendCriteria w DCB
Jak żyć? Jeśli współbieżne zdarzenie może jedynie wzmocnić Twój niezmiennik, wyklucz je z AppendCriteria. Rób
to, chyba że utrwalasz zdarzenia odrzuceń o znaczeniu biznesowym.
Co o tym sądzisz? Czy spotykasz w swoich projektach takie przypadki, gdzie byłoby pomocne rozdzielenie SourcingCriteria
i AppendCriteria?