Które podejście do obsługi eventów wolisz?

3

Piszę bibliotekę do event sourcingu i mam dylemat jakie podejście zastosować. Sprawa jest więc prosta- proszę abyście zaznaczyli które podejście jako konsument biblioteki preferujecie, i w miarę możliwości uargumentowali jakoś swój wybór.

Chodzi o odbudowywanie stanu z eventów. Umyślnie nie podaję kodu wywołującego te stany (obiekty), ponieważ zależy mi na Waszej opinii z perspektywy konsumenta biblioteki.

Opcja 1
Wymóg aby każdy stan implementował poniższy interfejs. Oczekuje się że metoda Rehydrate zwróci nową instancję stanu, ale oczywiście nie da się tego zagwarantować i implementacja zależy od klienta.

public interface IAggregateState
{
    IAggregateState Rehydrate(IEvent @event);
}

Oraz sama klasa stanowa:

public record OrderState : IAggregateState
{
    public string OrderId { get; init; } = string.Empty;
    public OrderStatus Status { get; init; } = OrderStatus.InProgress;

    public IAggregateState Rehydrate(IEvent @event) =>
        @event switch
        {
            OrderPlaced e => this with { OrderId = e.OrderId, Status = OrderStatus.Placed },
            OrderDispatched e => this with { Status = OrderStatus.Dispatched },
            OrderDelivered e => this with { Status = OrderStatus.Delivered },
            _ => this
        };
}

Zauważcie, że używam w przykładzie C# 10, stąd mogę napisać bardziej zwięzły kod. Klient korzystający z biblioteki może nie mieć takiej możliwości, więc wtedy trzeba by to napisać inaczej.

Opcja 2
Wymóg aby dla każdego eventu klasa stanowa posiadała publiczną metodę Apply, ale brak wymogu implementacji interfejsu. Wywołanie Apply odbywa się "magicznie", coś jak w przypadku ConfigureServices czy Configure w ASP Core:

public record OrderState
{
    public string OrderId { get; private set; } = string.Empty;
    public OrderStatus Status { get; private set; } = OrderStatus.InProgress;

    public void Apply(OrderPlaced @event)
    {
        OrderId = @event.OrderId;
        Status = OrderStatus.Placed;
    }

    public void Apply(OrderDispatched @event)
    {
        Status = OrderStatus.Dispatched;
    }

    public void Apply(OrderDelivered @event)
    {
        Status = OrderStatus.Delivered;
    }
}
2

Jakis argument za tym, że stringly-typed mialoby byc lepsze od strongly-typed? W sumie to jak ktoś może dać argument za tym w kontekście Startup z ASP .NET Core inny niż możliwość pisania metod per środowisko typu ConfigureProduction?

1

@Saalin: dzięki. Sam nie mam w zasadzie żadnego solidnego argumentu. Osobiście nie mam preferencji do żadnego z tych podejść, dla tego chciałem zaciągnąć opinii programistów.

Jeśli miałbym zagrać adwokata diabła to może podałbym takie powody przemawiające za opcją 2 (choć zdaję sobie sprawę że to wszystko w większości subiektywne):

  • Łatwiejsze rozdzielenie obsługi konkretnych eventów
  • Większa czytelność, szczególnie jeśli każda obsługa eventu to aktualizacja minimum kilku properties (Wiem, bardzo subiektywne. I funkcję w opcji 1 można podzielić na mniejsze funkcje)
  • W przypadku starszych wersji języka- bardziej zwięzły kod, ponieważ opcję 1 raczej trudno napisać zwięźle bez recordów i switch exressions
  • Brak niejasności czy mamy do czynienia z mutowalnym obiektem czy też nie- w opcji 1 konsument nadal może zwracać mutowalny stan, choć nie musi. Opcja 2 nie pozostawia wątpliwości- stan jest mutowalny i tyle

Co do ASP Core i pisania metod "per środowisko" to nie kojarzę żeby taki był powód przemawiający za taką a nie inną implementacją przyjętą przez MS. Możesz napisać coś więcej na ten temat?

Oczywiście kwestia środowisk nie ma znaczenia jeśli chodzi o temat tego wątku.

1

Dlaczego brak opcji generyczny interfejs IAggregateState<TEvent>? Nie, żebym uważał że tak będzie najlepiej, ale widzę takie rozwiązanie jako potencjalnie możliwe.

0

@maszrum: dobre pytanie i taką opcję również rozważałem. Pojawia się tylko problem z obsługą tego na poziomie frameworka. Ogólnie to u mnie wygląda (w dużym uproszczeniu) tak że agregaty implementują po generycznym roocie:

public abstract class AggregateRoot<TState> 
{
  public TState State { get; private set; } = new();
  
  // Dużo kodu pominięte dla czytelności

  public void Rehydrate(IEvent @event)
  {
    // ...
  }

Instancje IEvent to zassane z event store eventy implementujące interfejs IEvent. Dla obydwu przedstawionych opcji obsługa tych eventów i przekazanie do obiektu stanowego wygląda różnie. W przypadku stanu implementującego IAggregateState jest to oczywiście najprostsze bo wiemy na poziomie typów że mamy metodę Rehydrate którą stan implementuje. Ale jak wywołasz implementację IAggregateState<TEvent>, np. IAggregateState<OrderPlaced>, wiedząc jedynie że masz instancję IEvent? Być może ja nie jestem w stanie dojść do alternatywnego rozwiązania, ale nie wydaje mi się że cokolwiek się zyska generycznym IAggregateState<TEvent> w tym przypadku.

1

tzn. nie widzę tutaj całości pomysłu ani problemu, ale mi się osobiście oba podejścia nie podobają i wydają over-engineered. Jeśli celem jest "odbudowywanie stanu z eventów" to w ogóle bym tego nie robił na eventach tylko zastosował https://en.wikipedia.org/wiki/Command_pattern
wtedy komendy się zajmują obsługą stanu, klasa nie musi nic implementować i wygląda tak:

record OrderState(Guid OrderId, OrderStatus Status = OrderStatus.InProgress);

zaś w komendach jest zawarta logika:

enum OrderStatus
{
    Placed,
    Dispatched,
    Delivered,
    InProgress
};

public interface ICommand<T>
{
    T Execute(T input);
}

class OrderPlaced : ICommand<OrderState>
{
    private readonly Guid orderId;

    public OrderPlaced(Guid orderId)
    {
        this.orderId = orderId;
    }

    public OrderState Execute(OrderState state) => state with { OrderId = orderId, Status = OrderStatus.Placed };
}

class OrderDispatched : ICommand<OrderState>
{
    public OrderState Execute(OrderState state) => state with { Status = OrderStatus.Dispatched };
}

class OrderDelivered : ICommand<OrderState>
{
    public OrderState Execute(OrderState state) => state with { Status = OrderStatus.Delivered };
}

i tyle. Jak chcesz odtworzyć stan obiektu to wystarczy że wywołasz komendy w kolejności

var commands = new ICommand<OrderState>[]
{
    new OrderPlaced(Guid.NewGuid()),
    new OrderDispatched()
};

var order = new OrderState(Guid.Empty);
order = commands.Aggregate(order, (state, command) => command.Execute(state));

Console.WriteLine(order);

Bardzo łatwo to też rozwinąć o "undo".

0

@obscurity: chodzi o event sourcing z wykorzystaniem agregatów (zapożyczonych z DDD). Jest również miejsce dla commands ale jako element wejściowy do agregatu. Efektem logiki jest event. W Twoim przykładzie masz record w czasie przeszłym (event), który implementuje ICommand (polecenie), który ma generyczny parametr dla typu stanu. Doceniam chęci ale proponowane podejście wydaje mi się bardzo pokręcone, i właśnie over-engineered.

Idea jest prosta, obsługa całego przepływu danych wygląda tak:

Command (polecenie wykonania czegoś, element wejściowy) -> agregat (logika biznesowa) -> event (informacja o tym co się stało, element wyjściowy)

Za każdym razem kiedy tworzona jest instancja agregatu dla nowego command, zostaje również odtworzony stan agregatu poprzez przypuszczenie przez niego dotychczasowych eventów.

Jeśli celem jest "odbudowywanie stanu z eventów" to w ogóle bym tego nie robił na eventach
Event sourcing z definicji polega na odtwarzaniu stanu z eventów. Ja nie pytam o propozycje alternatyw dla event sourcingu, tylko na preferencje co do odtwarzania stanu właśnie w oparciu o event sourcing. Niemniej dziękuję za chęć pomocy.

0
Aventus napisał(a):

W Twoim przykładzie masz record w czasie przeszłym (event), który implementuje ICommand (polecenie), który ma generyczny parametr dla typu stanu.

Nie za bardzo rozumiem, albo źle przeczytałeś kod. Nazwy komend zaczerpnąłem z pierwszego posta żeby pokazać odpowiedniki, oczywiście nazwy komend nie powinny być w tej konwencji. Zauważ że to ten sam kod co w pierwszym Twoim przykładzie tylko logika jest wyizolowana na zewnętrz (do eventów tworząc z nich commandy) i nie łamie zasady podstawienia Liskov. Tak czy inaczej nie podoba mi się żeby obiekt stanowy zajmował się obsługą eventów. Powinien się tym zajmować jakiś event handler. Szczerze nie widzę przeszkód żeby eventem był command, choć jakiś purysta może się przyczepić. Można też mapować eventy na commandy, ale to sztuka dla sztuki albo zrobić zewnętrzny event handler - wtedy wszyscy są zadowoleni. Trzeba też rozdzielić pojęcie "command" z CQRS (polecenie na poziomie logiki biznesowej) od zwykłego command jako opakowaniem akcji z command pattern - trzeba też zauważyć że w większości przypadków w ogóle istnienie "command" nie ma sensu i wystarczy lambda, ale tej nie da się tak łatwo serializować i zapisywać w bazie / przesyłać.

Spójrz zresztą na dowolne schematy po wpisaniu "event sourcing" w googla - https://www.google.com/search?q=event+sourcing&tbm=isch
Na żadnym event handling nie jest częścią stanu aplikacji tylko zewnętrznym elementem. Trzymanie stanu i zarządzanie nim łamie zasadę jednej odpowiedzialności.
Oba podejścia wymagają zmiany kodu jeśli dojdzie nowy event (czyli łamie też zasadę open-closed) i w obu podejściach miałbym wtf jakbym zobaczył w kodzie i musiał rozgryzać o co w tym chodzi.
Tak więc Twoje kody łamią aż 3 z 5 zasad SOLID. Chyba że uważasz że CQRS się z SOLID wyklucza

1

Tu nie chodzi o obsługę eventów na zasadzie przetwarzania logiki biznesowej, tylko ich przetworzenie aby odbudować stan, ang. rehydration. Należy odróżnić te dwie kwestie. To co może być dla Ciebie mylące to fakt, że w moim przykładzie stan agregatu jest wydzielony to oddzielnej klasy- to jest po prostu decyzja projektowa. Gdyby usunąć ten stan jako oddzielną klasę, to wyglądało by to mniej więcej tak:

// Agregat, encja, you name it
class Order
{
    public string OrderId { get; private set; } = string.Empty;
    public OrderStatus Status { get; private set; } = OrderStatus.InProgress;

    // Nadal trzeba zrobić tutaj rehydrate, tylko tym razem w tej klasie
    public Rehydrate(IEvent @event)
    {
        // ...
    }

    // Logika biznesowa
    public void Handle(PlaceOrder order)
    {
      // ...
      // Na końcu
      Apply(new OrderPlaced(...));
    }
}

To co Ty proponujesz, to rozdzielnie wszystkiego na command handlery. CQRS w takim zastosowaniu to całkowicie inne podejście niż to. To nie jest kwestia dobrego i złego rozwiązania, tylko różnych rozwiązań. Notabene, jeśli wyszukał byś moje posty to znajdziesz co najmniej kilka gdzie promuję CQRS w oparciu o wzorzec mediator, a więc de facto wzorzec command-handler. Nie jest to dla mnie to nic nowego, i jest nawet czymś co aktywnie stosuję np. w obecnej pracy. Rzecz w tym że to nie ma nic do rzeczy, bo mowa o dwóch różnych podejściach. Rzucanie tym że łamane są zasady SOLID (z czego SRP jest zasadą wybitnie podatną na interpretację) nic tu nie wnosi.

Co do tego jak odbudować stan to można to zrobić na wiele sposobów. Może Ci się to nie podobać, i szanuję to, natomiast się z tym nie zgadzam. Także nie będę ciągnął więcej tego tematu.

Proponuję bardziej zapoznać się z ideą event sourcingu żeby dobrze zrozumieć o co chodzi. Swoją drogą jesteś pewny że nie pomyliłeś event-sourcingu z event-driven?

Kilka materiałów z którymi warto się zapoznać żeby lepiej zrozumieć skąd moje podejście:

https://www.eventstore.com/event-sourcing (tutaj warto zwrócić uwagę na zastosowanie CQRS ale na całkowicie innym poziomie, mianowicie rozdzielenie writes od reads, a więc aggregates od projections/materialized views).
https://martinfowler.com/bliki/DDD_Aggregate.html
https://docs.microsoft.com/en-us/azure/architecture/patterns/event-sourcing

0
Aventus napisał(a):

To co Ty proponujesz, to rozdzielnie wszystkiego na command handlery. CQRS w takim zastosowaniu to całkowicie inne podejście niż to. To nie jest kwestia dobrego i złego rozwiązania, tylko różnych rozwiązań.

Tzn. nie uważam że to różne podejścia. Uważam że to podejścia które można ze sobą połączyć tworząc bardziej elastyczny kod. Jeśli operujemy na obiektach mutable i chcemy zachować enkapsulację to obsługa musi być wewnątrz, ale jeśli mamy obiekty immutable to można z tego skorzystać.
W Twoim drugim podejściu niejako implementujesz wzorzec wizytatora https://en.wikipedia.org/wiki/Visitor_pattern , nie widzę przeszkód żeby łączyć ze sobą wzorce architektoniczne z projektowymi. Ja po prostu zaproponowałem użycie innego wzorca operacyjnego, bo moim zdaniem rozwiązuje tu więcej problemów. Event sourcing to moim rozumieniu po prostu rzetelnie prowadzony audyt, na tyle że na podstawie wpisów audytu / logów ("eventów") można odtworzyć stan z dowolnego momentu, nie ma tu większej logiki ani religii.

2

Nie widzę powodu czemu miałbym użyć tej biblioteki.

3

Bardziej podoba mi się podejście nr. 1 ze względu na interfejs i brak magii, aczkolwiek nie podoba mi się brak compile time safety, że gdy dochodzi nowy Event i ktoś zapomni go obsłużyć, to dowiemy się o tym dopiero w runtime. Można byłoby to rozwiązać wyżej wspomnianym odwiedzaczem, ale to średnie rozwiązanie, imo.

Ja osobiście bardziej zastanawiałem się nad tym w kontekście Enumów, aby np. switch statement sprawdzał czy wszystkie enumy są pokryte podczas kompilacji, bo aktualnie tak to nie działa.

Jako trochę rozszerzenie opcji 1 postanowiłem napisać jako POCkę analyzer, który jeżeli wszystko dobrze rozumiem byłby dołączany do biblioteki i działał nie tylko w Visual Studio, a również np. z CLI.

Wygląda to tak:

screenshot-20220723115619.png

screenshot-20220723115646.png

screenshot-20220723115713.png

co sądzicie architekci o angażowaniu analyzerów, a nie tylko poleganiu na języku / OOP w celu zapewnienia compile-time safety?

0

@1a2b3c4d5e: dzięki. Akurat compile time safety o jakim piszesz nie da się wyegzekwować z bardzo prostego powodu- z czasem, w systemie mogą istnieć eventy które kiedyś były używane, ale później już nie są.

Myślę że kwestia tego, czy event jest obsługiwany kiedy powinien, leży w gestii unit testów.

1

Bibliotek się nie piszę "a napiszę sobie bibliotekę, tylko muszę wymyślić jaką i jak". Tylko wychodzisz od powtarzającego się problemu, który chcesz rozwiązać. Np zaczynasz reużywać tych samych klas pomiędzy projektami, więc chcesz je udostępnić jako całość. Ewentulnie zauważasz że zmierzasz się z podobym problemem w wielu projekatch, i znajdujesz się w sytuacji w której jego rozwiązanie jest powtarzalne - wtedy tez możesz to wydzielić i upublicznić.

Może gdybyś podał przykład użycia tej biblioteki, albo pokazał repo/kod źrodłowy, etc. to możnaby sie jej lepiej przyjrzeć i ją lepiej ocenić.

Edit:

  • Dobrze, to wypowiem się w inny sposób. Zadałeś pytanie:
Aventus napisał(a):

Chodzi o odbudowywanie stanu z eventów. Umyślnie nie podaję kodu wywołującego te stany (obiekty), ponieważ zależy mi na Waszej opinii z perspektywy konsumenta biblioteki.

I odpowiedziałem zgodnie ze swoim sumieniem - nie wiem po co miałbym użyć tej biblioteki, co bym nią zyskał, jaki test miałbym napisać, który wykazałby potrzebę użycia jej. Kiedy rozwiązuję jakiś problem, to próbuję znaleźć rozwiązanie, i po prostu nie widzę gdzie wchodzi Twoja biblioteka, z jakimś rozwiązaniem.

Rozumiem że pytasz o interfejs Twojej biblioteki, ale nie widzę co ten interfejs sobą reprezentuje.

Jak na razie z tego co rozumiem, to to co ta biblioteka robi to serializuje obiekty (które nazywasz eventami), i pytasz o interfejs jaki serializowany obiekt miałby przyjąć. Nie wiem czy dobrze rozumiem?

10

@Riddle: jeśli będę chciał to napisze sobie bibliotekę tylko dla tego że mam takie "widzi mi się", i nic nikomu do tego. Mogę sobie ją napisać dla własnego użytku, dla znajomych, dla współpracowników, albo dla wszystkich i wystawić ja publicznie. Mogę ją zacząć pisać i nie skończyć. Mogę ją pisać "dla tego że", a mogę również pisać również "pomimo że". Znów- nic nikomu do tego. Celem tego wątku nie jest pytanie o opinię czy powinienem taka bibliotekę pisać. Jesteś moderatorem a uprawiasz offtop, pomimo tego że zaraportowałem Twój poprzedni post, co oczywiście musiałeś widzieć.

Może gdybyś podał przykład użycia tej biblioteki, albo pokazał repo/kod źrodłowy, etc. to możnaby sie jej lepiej przyjrzeć i ją lepiej ocenić.

Miałem taki zamiar, ale ze względu na przypadki takie jak Ty raczej tego nie zrobię na tym forum. Co najwyżej podeślę link z pytaniem o opinię do kilku użytkowników którzy nie wykazują się pasywnie agresywnym zachowaniem, oraz tych którzy na tym forum wielokrotnie pytali o pomoc w kwestiach związanych z event sourcingiem.

Także proszę, wykaż się rozsądkiem i usuń swoje posty, jak i ten post który właśnie napisałem, żeby usunąć offtop z wątku. Ja i tak będę raportował do skutku.

1

My w projekcie używamy coś podobnego do opcji 1 tyle że zamiast interface mamy void, A tam mamy switcha I się sprawdza więc jestem za 1 :)

0

@Aventus

Co nam daje taki event sourcing?
W jakiego rodzaju aplikacjach się to stosuje?

Ja klepie CRUDy i jakoś nie widzę potrzeby stosowania takich mechanizmów ale może się myle.

0

Proszę trzymać się tematu i nie off-topować. Inaczej wyciągniętę zostaną poważne konsekwencje.
Pytanie zadanie przez @Aventus jest proste i nie trzeba się zachowywać jak na elektrodzie. Jak ktoś nie chce się angażować to może też nie off-topować.

1
Aventus napisał(a):

Piszę bibliotekę do event sourcingu i mam dylemat jakie podejście zastosować. Sprawa jest więc prosta- proszę abyście zaznaczyli które podejście jako konsument biblioteki preferujecie, i w miarę możliwości uargumentowali jakoś swój wybór.

Wybieram opcję nr 3 - inne.

A dokładniej zamiast uprawiania NIH, zrobienie researchu istniejących rozwiązań i wybór odpowiedniej biblioteki, których już jest multum.

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