Gdzie obiekt w CQRS + ES powinien być w pełni zainicjowany: w konstruktorze lub podczas stosowania pierwszego zdarzenia?


9

Wydaje się, że istnieje powszechna zgoda w społeczności OOP, że konstruktor klasy nie powinien pozostawiać obiektu częściowo, a nawet całkowicie niezainicjowanego.

Co rozumiem przez „inicjalizację”? Z grubsza mówiąc, proces atomowy , który wprowadza nowo utworzony obiekt w stan, w którym utrzymują się wszystkie niezmienniki jego klasy. Powinna być pierwszą rzeczą, która przytrafia się obiektowi (powinna działać tylko raz na obiekt) i nic nie powinno mieć możliwości przechwycenia niezainicjowanego obiektu. (Dlatego częsta rada dotycząca inicjowania obiektu bezpośrednio w konstruktorze klasy. Z tego samego powodu Initializemetody są często odrzucane, ponieważ rozbijają one atomowość i umożliwiają uzyskanie i użycie obiektu, który nie jest jeszcze w dobrze zdefiniowanym stanie).

Problem: Kiedy CQRS jest połączone z pozyskiwaniem zdarzeń (CQRS + ES), gdzie wszystkie zmiany stanu obiektu są wychwytywane w uporządkowanej serii zdarzeń (strumień zdarzeń), zastanawiam się, kiedy obiekt faktycznie osiągnie w pełni zainicjowany stan: Na końcu konstruktora klasy, czy po zastosowaniu do obiektu pierwszego zdarzenia?

Uwaga: powstrzymuję się od używania terminu „agreguj root”. Jeśli wolisz, zamień go za każdym razem, gdy czytasz „obiekt”.

Przykład do dyskusji: Załóżmy, że każdy obiekt jest jednoznacznie identyfikowany przez jakąś nieprzezroczystą Idwartość (pomyśl GUID). Strumień zdarzeń reprezentujący zmiany stanu tego obiektu można zidentyfikować w magazynie zdarzeń na podstawie tej samej Idwartości: (Nie martwmy się o prawidłową kolejność zdarzeń).

interface IEventStore
{
    IEnumerable<IEvent> GetEventsOfObject(Id objectId); 
}

Załóżmy ponadto, że istnieją dwa typy obiektów Customeri ShoppingCart. Skupmy się na ShoppingCart: Po utworzeniu koszyki są puste i muszą być powiązane z dokładnie jednym klientem. Ten ostatni bit jest niezmiennikiem klasy: ShoppingCartObiekt, który nie jest powiązany z, Customerjest w niepoprawnym stanie.

W tradycyjnym OOP można modelować to w konstruktorze:

partial class ShoppingCart
{
    public Id Id { get; private set; }
    public Customer Customer { get; private set; }

    public ShoppingCart(Id id, Customer customer)
    {
        this.Id = id;
        this.Customer = customer;
    }
}

Brakuje mi jednak sposobu modelowania tego w CQRS + ES bez odroczonej inicjalizacji. Ponieważ ta prosta inicjalizacja jest w rzeczywistości zmianą stanu, czy nie trzeba jej modelować jako zdarzenia ?:

partial class CreatedEmptyShoppingCart
{
    public ShoppingCartId { get; private set; }
    public CustomerId { get; private set; }
}
// Note: `ShoppingCartId` is not actually required, since that Id must be
// known in advance in order to fetch the event stream from the event store.

To oczywiście musi być pierwsze zdarzenie w ShoppingCartstrumieniu zdarzeń dowolnego obiektu, a obiekt ten zostanie zainicjowany dopiero po zastosowaniu zdarzenia do niego.

Więc jeśli inicjalizacja stanie się częścią „odtwarzania” strumienia zdarzeń (co jest bardzo ogólnym procesem, który prawdopodobnie działałby tak samo, czy to dla Customerobiektu, ShoppingCartobiektu czy innego typu obiektu w tym przypadku)…

  • Czy konstruktor powinien być pozbawiony parametrów i nic nie robić, pozostawiając całą pracę jakiejś void Apply(CreatedEmptyShoppingCart)metodzie (która jest prawie taka sama jak zmarszczone brwi Initialize())?
  • A może konstruktor powinien odbierać strumień zdarzeń i odtwarzać go (co powoduje, że inicjalizacja staje się atomowa, ale oznacza, że ​​konstruktor każdej klasy zawiera tę samą ogólną logikę „odtwarzaj i stosuj”, tj. Niechciane powielanie kodu)?
  • A może powinien istnieć zarówno tradycyjny konstruktor OOP (jak pokazano powyżej), który poprawnie inicjuje obiekt, a następnie wszystkie zdarzenia oprócz pierwszego są void Apply(…)z nim powiązane?

Nie oczekuję, że odpowiedź zapewni w pełni działającą implementację demonstracyjną; Byłbym bardzo szczęśliwy, gdyby ktoś mógł wyjaśnić, w jaki sposób moje rozumowanie jest wadliwe, lub czy inicjalizacja obiektu naprawdę jest „przeszkodą” w większości implementacji CQRS + ES.

Odpowiedzi:


3

Robiąc CQRS + ES wolę nie mieć publicznych konstruktorów. Tworzenie moich zagregowanych korzeni powinno odbywać się za pośrednictwem fabryki (w przypadku prostych konstrukcji takich jak ta) lub konstruktora (w przypadku bardziej skomplikowanych korzeni zagregowanych).

Jak następnie faktycznie zainicjować obiekt, jest szczegół implementacji. OOP „Nie używaj inicjalizacji” -rada dotyczy im interfejsów publicznych . Nie należy oczekiwać, że każdy, kto używa twojego kodu, wie, że musi wywołać SecretInitializeMethod42 (bool, int, string) - to zły projekt publicznego interfejsu API. Jeśli jednak twoja klasa nie udostępnia żadnego publicznego konstruktora, ale zamiast tego istnieje ShoppingCartFactory z metodą CreateNewShoppingCart (string), wówczas implementacja tej fabryki może bardzo dobrze ukryć wszelką magię inicjalizacji / konstruktora, której użytkownik nie musi wiedzieć about (zapewniając w ten sposób przyjemny publiczny interfejs API, ale umożliwiając bardziej zaawansowane tworzenie obiektów za kulisami).

Fabryki są źle reprezentowane przez ludzi myślących, że jest ich zbyt wiele, ale właściwie użyte, mogą ukryć wiele komplikacji za ładnym, łatwym do zrozumienia publicznym API. Nie obawiaj się ich używać, są potężnym narzędziem, które może znacznie ułatwić budowę złożonych obiektów - o ile możesz żyć z kilkoma liniami kodu.

To nie jest wyścig, aby zobaczyć, kto może rozwiązać problem za pomocą najmniejszej liczby linii kodu - jest to jednak ciągła konkurencja, kto może stworzyć najpiękniejsze publiczne interfejsy API! ;)

Edycja: dodanie kilku przykładów, jak może wyglądać stosowanie tych wzorów

Jeśli masz po prostu „łatwy” konstruktor agregacji, który ma kilka wymaganych parametrów, możesz przejść z bardzo prostą implementacją fabryczną, coś w tym stylu

public class FooAggregate {
     internal FooAggregate() { }

     public int A { get; private set; }
     public int B { get; private set; }

     internal Handle(FooCreatedEvent evt) {
         this.A = a;
         this.B = b;
     }
}

public class FooFactory {
    public FooAggregate Create(int a, int b) {
        var evt = new FooCreatedEvent(a, b);
        var result = new FooAggregate();
        result.Handle(evt);
        DomainEvents.Register(result, evt);
        return result;
    }
}

Oczywiście to, jak dokładnie podzielisz się, tworząc FooCreatedEvent, zależy od Ciebie. Można również uzasadnić posiadanie konstruktora FooAggregate (FooCreatedEvent) lub konstruktora FooAggregate (int, int), który tworzy zdarzenie. To, jak zdecydujesz się tutaj podzielić odpowiedzialność, zależy od tego, co uważasz za najczystsze i jak zaimplementowałeś rejestrację zdarzenia w domenie. Często decyduję się na to, aby fabryka utworzyła zdarzenie - ale to zależy od ciebie, ponieważ tworzenie zdarzenia jest teraz wewnętrznym szczegółem implementacji, który możesz zmienić i refaktoryzować w dowolnym momencie bez zmiany interfejsu zewnętrznego. Ważnym szczegółem tutaj jest to, że agregacja nie ma publicznego konstruktora i że wszystkie setery są prywatne. Nie chcesz, aby ktokolwiek używał ich zewnętrznie.

Ten wzorzec działa dobrze, gdy zastępujesz konstruktory, ale jeśli masz bardziej zaawansowaną konstrukcję obiektu, może to stać się zbyt skomplikowane, aby można go było użyć. W tym przypadku zwykle rezygnuję z wzorca fabrycznego i zamiast tego przechodzę do wzorca konstruktora - często z bardziej płynną składnią.

Ten przykład jest nieco wymuszony, ponieważ budowana przez niego klasa nie jest zbyt złożona, ale mamy nadzieję, że zrozumiesz pomysł i zobaczysz, jak ułatwiłby bardziej złożone zadania konstrukcyjne

public class FooBuilder {
    private int a;
    private int b;   

    public FooBuilder WithA(int a) {
         this.a = a;
         return this;
    }

    public FooBuilder WithB(int b) {
         this.b = b;
         return this;
    }

    public FooAggregate Build() {
         if(!someChecksThatWeHaveAllState()) {
              throw new OmgException();
         }

         // Some hairy logic on how to create a FooAggregate and the creation events from our state
         var foo = new FooAggregate(....);
         foo.PlentyOfHairyInitialization(...);
         DomainEvents.Register(....);

         return foo;
    }
}

A potem używasz go jak

var foo = new FooBuilder().WithA(1).Build();

I oczywiście, ogólnie rzecz biorąc, kiedy przechodzę do wzorca konstruktora, nie są to tylko dwie liczby int, może zawierać listy niektórych obiektów wartości lub słowniki jakiegoś innego, bardziej włochatego. Jest to jednak bardzo przydatne, jeśli masz wiele kombinacji parametrów opcjonalnych.

Ważnymi rzeczami, jakie należy podjąć w tym kierunku, są:

  • Twoim głównym celem jest umiejętność abstrakcyjnej budowy obiektów, aby użytkownik zewnętrzny nie musiał wiedzieć o systemie zdarzeń.
  • Nie jest ważne, gdzie i kto rejestruje zdarzenie tworzenia, ważne jest, aby zostało zarejestrowane i że możesz to zagwarantować - poza tym jest to wewnętrzny szczegół implementacji. Rób to, co najlepiej pasuje do twojego kodu, nie podążaj za moim przykładem, jako coś w rodzaju „to jest właściwa droga”.
  • Jeśli chcesz, postępując w ten sposób, Twoje fabryki / repozytoria mogą zwracać interfejsy zamiast konkretnych klas - co ułatwia kpiny z testów jednostkowych!
  • Jest to czasem dodatkowy kod, który sprawia, że ​​wielu ludzi się od niego unika. Ale często jest to dość łatwy kod w porównaniu z alternatywami i zapewnia wartość, gdy prędzej czy później trzeba coś zmienić. Jest powód, dla którego Eric Evans mówi o fabrykach / repozytoriach w swojej książce DDD jako o ważnych częściach DDD - są to niezbędne abstrakty, aby nie ujawnić użytkownikowi niektórych szczegółów implementacji. A przeciekające abstrakcje są złe.

Mam nadzieję, że to pomoże trochę więcej, po prostu poproś o wyjaśnienia w komentarzach inaczej :)


+1, nigdy nie myślałem o fabrykach jako możliwym rozwiązaniu projektowym. Jedna rzecz: brzmi to tak, jakby fabryki zajmowały to samo miejsce w publicznym interfejsie, Initializektóre zajmowałyby agregatory konstruktorów (+ być może metoda). To prowadzi mnie do pytania, jak mogłaby wyglądać twoja fabryka?
stakx

3

Moim zdaniem, odpowiedź bardziej przypomina twoją sugestię nr 2 dotyczącą istniejących agregatów; dla nowych agregatów bardziej jak nr 3 (ale przetwarzanie zdarzenia zgodnie z sugestią).

Oto kod, mam nadzieję, że to pomoże.

public abstract class Aggregate
{
    Dictionary<Type, Delegate> _handlers = new Dictionary<Type, Delegate>();

    protected Aggregate(long version = 0)
    {
        this.Version = version;
    }

    public long Version { get; private set; }

    protected void Handles<TEvent>(Action<TEvent> action)
        where TEvent : IDomainEvent            
    {
        this._handlers[typeof(TEvent)] = action;
    }

    private IList<IDomainEvent> _pendingEvents = new List<IDomainEvent>();

    // Apply a new event, and add to pending events to be committed to event store
    // when transaction completes
    protected void Apply(IDomainEvent @event)
    {
        this.Invoke(@event);
        this._pendingEvents.Add(@event);
    }

    // Invoke handler to change state of aggregate in response to event
    // Event may be an old event from the event store, or may be an event triggered
    // during the lifetime of this instance.
    protected void Invoke(IDomainEvent @event)
    {
        Delegate handler;
        if (this._handlers.TryGetValue(@event.GetType(), out handler))
            ((Action<TEvent>)handler)(@event);
    }
}

public class ShoppingCart : Aggregate
{
    private Guid _id, _customerId;

    private ShoppingCart(long version = 0)
        : base(version)
    {
         // Setup handlers for events
         Handles<ShoppingCartCreated>(OnShoppingCartCreated);
         // Handles<ItemAddedToShoppingCart>(OnItemAddedToShoppingCart);  
         // etc...
    } 

    public ShoppingCart(long version, IEnumerable<IDomainEvent> events)
        : this(version)
    {
         // Replay existing events to get current state
         foreach (var @event in events)
             this.Invoke(@event);
    }

    public ShoppingCart(Guid id, Guid customerId)
        : this()
    {
        // Process new event, changing state and storing event as pending event
        // to be saved when aggregate is committed.
        this.Apply(new ShoppingCartCreated(id, customerId));            
    }            

    private void OnShoppingCartCreated(ShoppingCartCreated @event)
    {
        this._id = @event.Id;
        this._customerId = @event.CustomerId;
    }
}

public class ShoppingCartCreated : IDomainEvent
{
    public ShoppingCartCreated(Guid id, Guid customerId)
    {
        this.Id = id;
        this.CustomerId = customerId;
    }

    public Guid Id { get; private set; }
    public Guid CustomerID { get; private set; }
}

0

Cóż, pierwsze zdarzenie powinno polegać na tym, że klient tworzy koszyk , więc kiedy koszyk jest tworzony, masz już identyfikator klienta w ramach wydarzenia.

Jeśli stan utrzymuje się między dwoma różnymi zdarzeniami, z definicji jest to stan poprawny. Jeśli więc powiesz, że prawidłowy koszyk jest powiązany z klientem, oznacza to, że musisz mieć informacje o kliencie podczas tworzenia samego koszyka


Moje pytanie nie dotyczy sposobu modelowania domeny; Celowo używam prostego (ale być może niedoskonałego) modelu domeny w celu zadawania pytań. Spójrz na CreatedEmptyShoppingCartwydarzenie w moim pytaniu: Zawiera informacje o kliencie, tak jak sugerujesz. Moje pytanie dotyczy bardziej tego, jak takie zdarzenie odnosi się do konstruktora klasy ShoppingCartpodczas implementacji lub z nim konkuruje .
stakx

1
Rozumiem lepiej pytanie. Powiedziałbym, że poprawna odpowiedź powinna być trzecią. Myślę, że twoje byty powinny zawsze być w dobrym stanie. Jeśli wymaga to, aby koszyk miał identyfikator klienta, należy go podać w momencie tworzenia, stąd potrzeba konstruktora, który akceptuje identyfikator klienta. Bardziej przyzwyczajam się do CQRS w Scali, gdzie obiekty domeny byłyby reprezentowane przez klasy przypadków, które są niezmienne i mają konstruktory dla wszystkich możliwych kombinacji parametrów, więc dawałem to za pewnik
Andrea

0

Uwaga: jeśli nie chcesz używać zagregowanego katalogu głównego, „encja” obejmuje większość tego, o co tutaj pytasz, jednocześnie obawiając się o granice transakcji.

Oto inny sposób myślenia o tym: bytem jest tożsamość + stan. W przeciwieństwie do wartości, jednostka jest tym samym facetem, nawet gdy zmienia się jej stan.

Ale sam stan można uznać za obiekt wartości. Rozumiem przez to, że państwo jest niezmienne; historia bytu jest przejściem z jednego niezmiennego stanu do następnego - każde przejście odpowiada zdarzeniu w strumieniu zdarzeń.

State nextState = currentState.onEvent(e);

Metoda onEvent () jest oczywiście zapytaniem - currentState wcale się nie zmienia, zamiast tego currentState oblicza argumenty użyte do utworzenia nextState.

Zgodnie z tym modelem wszystkie wystąpienia koszyka można uznać za rozpoczynające się od tej samej wartości początkowej ....

State currentState = ShoppingCart.SEED;
for (Event e : history) {
    currentState = currentState.onEvent(e);
}

ShoppingCart cart = new ShoppingCart(id, currentState);

Rozdzielenie obaw - ShoppingCart przetwarza polecenie, aby dowiedzieć się, jakie wydarzenie powinno nastąpić; Stan ShoppingCart wie, jak przejść do następnego stanu.

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.