Jak obsłużyć batch w CQRS i DDD?
Cześć!
Ostatnio dostałem pytanie jak obsłużyć batch w CQRS? Na przykład: zmień menadżera dla dziesięciu pracowników, wyślij kupon rabatowy 7 klientom. Dodatkowo jak to obsłużyć w klasycznym flow z użyciem wzorca mediator (tudzież jego implementacji biblioteki MediatR). O wzorcu Mediator pisałem jakiś czas temu, więc jeśli chcesz się z nim zaznajomić to odsyłam do archiwum: https://oskar-dudycz.netlify.app/pl/wzorzec_mediator_czyli_jak_szybko_zwiekszyc_modularnosc_swojego_kodu/.
Najpierw przypomnijmy, że CQRS sam w sobie nic nie mówi o tym gdzie i jak mamy zapisywać wynik naszej komendy. CQRS sam w sobie odpowiada tylko za podział operacji na takie, które zmieniają stan i takie, które pobierają dane. To, że jedna komenda powinna modyfikować jeden obiekt wynika z dobrych praktyk. Dzięki temu łatwiej zarządza się transakcjami (mniejsze ryzyko błędów w zapisie itd.), plus zarządza się odpowiedzialnością danej encji/agregatu.
W CQRS każda komenda powinna mieć swój handler. Zwykle opakowujemy go o dekoratory typu: autoryzacja, logowanie, metryki, obsługa błędów, itd. Można to zrobić przy pomocy wzorca mediator/middleware. Niestety często te dekoratory robią bardzo dużo rzeczy, przez co wysłanie kilku komend i ich obsłużenie może mieć spory narzut infrastrukturalny. Sam miałem taki problem w jednym z moich projektów. Co zatem zrobić jeśli problem występuje też u nas?
Według mnie, zanim zacznie się myśleć od optymalizacji to warto sobie zadać następujące pytania:
- Czy komendy mogą być procesowane równolegle czy synchronicznie.
- Co się stanie jeśli, któraś komenda się wywali? Jak to wpłynie na pozostałe wykonania komend - czy mają zostać wycofane, czy ma być cześciowy sukces?
- Czy jest to operacja krytyczna w czasie. Czyli czy narzut na pipeline’ach jest tutaj problemem. To warto zmierzyć, nie bazować na metodzie polizanego palca wystawionego na wiatr.
W zależności od tego jakie nam wyjdą odpowiedzi to możemy przyjąć różne strategie.
Na przykład. jeśli możemy wykonać je równolegle to możliwe, że narzut pipeline’ów nie będzie taki duży. Jeśli synchronicznie, to kolejne pytanie to: czy wszystkie te pipeline’y w każdej sytuacji muszą być wykonane niezależnie? Czyli czy np. tracing, logowanie dla całego batcha jest ok czy nie, czy chcielibyśmy jednak osobno każdą operację.
Jeśli ma być możliwość odkręcenia zmian to raczej nie unikniemy tutaj użycia wzorca Saga.
Kolejnym ważnym pytaniem jest: co ten batch w ogóle robi? Czy to jest po prostu kilka operacji biznesowych, względnie lekkich, czy może jakiś duży import danych.
W zależności od odpowiedzi strategie mogą być różne. Czasem ludzie (ja tez tak miałem w projektach) próbują na siłę przepychać scenariusz replikacji przez zdarzenia i komendy co nie zawsze ma sens. Przez replikację mam na myśli po prostu przepchanie danych z jednego miejsca do drugiego. Jeśli nie ma reguł biznesowych i “prawa weta” po stronie odbiorcy to jest to po prostu replikacja, bo ktoś musi zaakceptować i zapisać sobie lokalną kopię przychodzących danych.
Jeżeli jednak wyjdzie nam, że pipeline’y i warstwy są problemem wydajnościowym to wtedy wg mnie nie zostaje nic innego niż ich cięcie.
Można wtedy np. pobrać wszystkie handlery i w pętli zaaplikować komendy, nastepnie zapisać te wszystkie zmiany w jednej sesji. Wiadomo wtedy jednak jest większe ryzyko trafienia się na np. optimistic concurrency i zastanowienia się czy ponowić wtedy czy nie. Ważne, żeby te handlery były idempotentne. Niezależnie od tego ile razy je wywołamy dla tej samej komendy to efekt powinien być ten sam.
Można też zejść całkiem nisko i utworzyć handler który po prostu obsługuje batch takich encji i je zapisuje. Pamiętajmy, że model logiczny (encje, agrgegaty) nie musi wprost odpowiadać modelowi technicznemu (np. tabele). Jeden obiekt zawierający w sobie zgrupowanie innych może być zamodelowany jako kolekcja obiektów i zapisany jako różne rekordy w bazie danych.
Wychodzi na to, że w wielu słowach napisałem “to zależy”.
Takie batche nie są wcale łatwe. Kiedyś też robiłem research na ten temat, ale nie znalazłem dużo konkretnych, dobrych porad.
Dlatego też uważam, że lepiej nie przesadzać z liczbą warstw i generyczności, bo może się okazać, że zaczynamy tworzyć behemota. Wydaje mi się, że lepiej jest pisać kod prostszy i stosować zasady kompozycji, zamiast brnąć w generyczność i magiczne warstwy. Wg mnie lepiej indywidualizować przypadki. Mamy co prawda nieco więcej kodu, ale przynajmniej mamy wprost widoczne co się dzieje. Jeśli będziemy mieć kompozycję wielu elementów zamiast generyczności to łatwiej potem ścinać co jest niepotrzebne w konkretnej sytuacji. No ale to i tak jest sztuka kompromisów, bo niektóre sytuacje niestety nie da się załatwić dobrze. Można tylko lepiej lub gorzej.
Co Ty o tym sądzisz?
Pozdrawiam
Oskar