🍕 Dynamic Consistency Boundary: SourcingCriteria != AppendCriteria

**🇬🇧 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:

  1. CreditsToppedUp (zmiana: +1)
  2. CreditsUsed (zmiana: -1)
  3. 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, CreditsUsedtakie same jak SourcingCriteria (każde współbieżne zdarzenie kredytowe powoduje konflikt)

Współbieżne komendy: TopUpCredits(1) i UseCredits(1)

Te same kryteria powodują niepotrzebny konflikt

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)

Rozdzielone kryteria pozwalają obu komendom zakończyć się sukcesem

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 {
        // Source: Wczytaj OBA typy zdarzeń, by obliczyć saldo
        @SourcingCriteriaBuilder
        @JvmStatic
        fun sourcingCriteria(accountId: String): EventCriteria =
            EventCriteria
                .havingTags(Tag("accountId", accountId))
                .andBeingOneOfTypes("CreditsToppedUp", "CreditsUsed")

        // Append: Sprawdzaj konflikty wyłącznie dla CreditsUsed
        // Współbieżne doładowania nie zablokują naszej próby użycia kredytów
        @AppendCriteriaBuilder
        @JvmStatic
        fun appendCriteria(accountId: String): EventCriteria =
            EventCriteria
                .havingTags(Tag("accountId", accountId))
                .andBeingOneOfTypes("CreditsUsed")  // Doładowania wykluczone
    }

    @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)

Odrzucenie z powodu niewystarczającego salda

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

Niespójna historia ze zdarzeniami odrzuceń

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.

dcb-sourcing-append-criteria-decision.png

Co o tym sądzisz? Czy spotykasz w swoich projektach takie przypadki, gdzie byłoby pomocne rozdzielenie SourcingCriteria i AppendCriteria?

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.

Więcej o życiu na kodach

⚔️ Moc i magia Domain-Driven Design w świecie Heroes III: Mapa Kontekstów i Hydra - co mają wspólnego i jak kąsają Twój biznes?

Zobrazujemy zależności między modułami i zespołami — kto będzie musiał się do kogo dostosować, a gdzie potrzebne są wspólne ustalenia i partnerstwo. Context Mapping jest w stanie uwidocznić Ci problemy niedostrzegalne na pierwszy rzut oka, ale niestety odczuwalne przez wszystkich. Możesz wziąć niebieską pigułkę i dalej żyć w błogiej nieświadomości, aż brutalne realia projektu nie wyrwą Cię z tego marazmu. Albo przeczytać ten wpis...

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

W jaki sposób zbudować model architektury, gotowy do przełożenia na kod? Jak wyznaczyć co musimy zrobić najpierw i nad czym możemy pracować równocześnie? Dostrzeż w fantastycznej domenie Heroes III analogie do realnych projektów i sprawdź jak wykorzystać DDD i Event Modeling w Twojej pracy. Przeprowadzisz szybkie eksperymenty, zwiększysz jakość i unikniesz naparzanek przy code review, a dodatkowo zaoszczędzisz mnóstwo czasu i pieniędzy.

⚔️ Moc i magia Domain-Driven Design w świecie Heroes III: Modelowanie, modularyzacja i produktyzacja + Bounded Context

Statystyki bohatera wpływają na jednostki w bitwie, a wynik bitwy przecież na armię bohatera. Bohatera możemy zatrudnić w tawernie, która może być zbudowana w mieście, ale nie musi... W takim razie jak do tego podejść, aby znowu nie zginąć w gąszczu ifów i relacji między tabelkami z setkami kolumn? Zobacz jak wyznaczyć autonomiczne moduły i umożliwić powstawanie nowych produktów dzięki analogii do procesów znanych z Heroes III.

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

SzkolenieWarsztaty dla zespołów
Event Modeling
  • Proces, który buduje zaufanie
  • Od modelu wprost do kodu — 1:1
  • Idealny kontekst dla ludzi i AI
  • Spec-Driven Development zrobione dobrze
DDDEvent SourcingAIVertical Slices
Sprawdź, czy to dla Ciebie