Wo soll ein Objekt in CQRS + ES vollständig initialisiert werden: im Konstruktor oder beim Anwenden des ersten Ereignisses?

9

In der OOP-Community scheint es weit verbreitete Übereinstimmung zu geben, dass der Klassenkonstruktor ein Objekt nicht teilweise oder sogar vollständig nicht initialisiert lassen sollte.

Was meine ich mit "Initialisierung"? Grob gesagt der atomare Prozess, der ein neu erstelltes Objekt in einen Zustand bringt, in dem alle seine Klasseninvarianten gelten. Es sollte das erste sein, was mit einem Objekt passiert (es sollte nur einmal pro Objekt ausgeführt werden), und es sollte nichts erlaubt sein, an ein nicht initialisiertes Objekt zu gelangen. (Daher der häufige Rat, die Objektinitialisierung direkt im Klassenkonstruktor durchzuführen. Aus dem gleichen Grund werden InitializeMethoden häufig verpönt, da diese die Atomizität aufbrechen und es ermöglichen, ein Objekt zu erreichen und zu verwenden, das noch nicht vorhanden ist in einem genau definierten Zustand.)

Problem: Wenn CQRS mit Event Sourcing (CQRS + ES) kombiniert wird, bei dem alle Statusänderungen eines Objekts in einer geordneten Reihe von Ereignissen (Ereignisstrom) erfasst werden, frage ich mich, wann ein Objekt tatsächlich einen vollständig initialisierten Status erreicht: Am Ende des Klassenkonstruktors oder nachdem das allererste Ereignis auf das Objekt angewendet wurde?

Hinweis: Ich verzichte auf die Verwendung des Begriffs "Aggregatwurzel". Wenn Sie es vorziehen, ersetzen Sie es, wenn Sie "Objekt" lesen.

Diskussionsbeispiel: Angenommen, jedes Objekt wird durch einen undurchsichtigen IdWert eindeutig identifiziert (denken Sie an die GUID). Ein Ereignisstrom, der die Statusänderungen dieses Objekts darstellt, kann im Ereignisspeicher durch denselben IdWert identifiziert werden: (Machen wir uns keine Sorgen über die richtige Ereignisreihenfolge.)

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

Angenommen, es gibt zwei Objekttypen Customerund ShoppingCart. Konzentrieren wir uns auf ShoppingCart: Beim Erstellen sind Einkaufswagen leer und müssen genau einem Kunden zugeordnet werden. Das letzte Bit ist eine Klasseninvariante: Ein ShoppingCartObjekt, das nicht mit a verknüpft ist, Customerbefindet sich in einem ungültigen Zustand.

In der traditionellen OOP könnte man dies im Konstruktor modellieren:

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;
    }
}

Ich bin jedoch ratlos, wie ich dies in CQRS + ES modellieren kann, ohne dass es zu einer verzögerten Initialisierung kommt. Da diese einfache Initialisierung effektiv eine Statusänderung ist, müsste sie nicht als Ereignis modelliert werden?:

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.

Dies müsste offensichtlich das allererste Ereignis im Ereignisstrom eines ShoppingCartObjekts sein, und dieses Objekt würde erst initialisiert, wenn das Ereignis darauf angewendet würde.

Wenn also die Initialisierung Teil der "Wiedergabe" des Ereignisstroms wird (was ein sehr allgemeiner Prozess ist, der wahrscheinlich genauso funktionieren würde, sei es für ein CustomerObjekt oder ein ShoppingCartObjekt oder einen anderen Objekttyp)…

  • Sollte der Konstruktor parameterlos sein und nichts tun und die gesamte Arbeit einer void Apply(CreatedEmptyShoppingCart)Methode überlassen (die der verpönten sehr ähnlich ist Initialize())?
  • Oder sollte der Konstruktor einen Ereignisstrom empfangen und wiedergeben (was die Initialisierung wieder atomar macht, aber bedeutet, dass der Konstruktor jeder Klasse dieselbe generische "Wiedergabe & Anwenden" -Logik enthält, dh unerwünschte Codeduplizierung)?
  • Oder sollte es sein , sowohl ein traditioneller OOP - Konstruktor (wie oben dargestellt) , dass das Objekt korrekt initialisiert, und dann alle Ereignisse , aber die ersten sind void Apply(…)-ied darauf?

Ich erwarte keine Antwort auf eine voll funktionsfähige Demo-Implementierung. Ich würde schon sehr freuen, wenn jemand erklären könnte , wo meine Argumentation fehlerhaft ist, oder ob Objektinitialisierung wirklich ist ein „Schmerzpunkt“ in den meisten CQRS + ES - Implementierungen.

stakx
quelle

Antworten:

3

Wenn ich CQRS + ES mache, bevorzuge ich es, überhaupt keine öffentlichen Konstruktoren zu haben. Das Erstellen meiner Aggregatwurzeln sollte über eine Factory (für ausreichend einfache Konstruktionen wie diese) oder einen Builder (für kompliziertere Aggregatwurzeln) erfolgen.

Wie das Objekt dann tatsächlich initialisiert wird, ist ein Implementierungsdetail. Der OOP-Rat "Don't initialize verwenden" befasst sich imho mit öffentlichen Schnittstellen . Sie sollten nicht erwarten, dass jeder, der Ihren Code verwendet, weiß, dass er SecretInitializeMethod42 (bool, int, string) aufrufen muss - das ist ein schlechtes öffentliches API-Design. Wenn Ihre Klasse jedoch keinen öffentlichen Konstruktor bereitstellt, sondern stattdessen eine ShoppingCartFactory mit der Methode CreateNewShoppingCart (Zeichenfolge) vorhanden ist, kann die Implementierung dieser Factory möglicherweise jede Art von Initialisierungs- / Konstruktormagie verbergen, die Ihr Benutzer dann nicht kennen muss about (bietet somit eine nette öffentliche API, ermöglicht Ihnen jedoch die erweiterte Objekterstellung hinter den Kulissen).

Fabriken haben einen schlechten Ruf von Leuten, die denken, dass es zu viele von ihnen gibt, aber wenn sie richtig eingesetzt werden, können sie eine Menge Komplexität hinter einer netten, leicht verständlichen öffentlichen API verbergen. Haben Sie keine Angst, sie zu verwenden, sie sind ein leistungsstarkes Tool, mit dem Sie komplexe Objektkonstruktionen erheblich vereinfachen können - solange Sie mit mehr Codezeilen leben können.

Es ist kein Rennen, um zu sehen, wer das Problem mit den wenigsten Codezeilen lösen kann - es ist jedoch ein ständiger Wettbewerb, wer die schönsten öffentlichen APIs herstellen kann! ;)

Bearbeiten: Fügen Sie einige Beispiele hinzu, wie das Anwenden dieser Muster aussehen könnte

Wenn Sie nur einen "einfachen" Aggregatkonstruktor haben, der einige erforderliche Parameter enthält, können Sie nur eine sehr grundlegende werkseitige Implementierung durchführen, etwas in dieser Richtung

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;
    }
}

In diesem Fall liegt es natürlich an Ihnen, wie Sie das Erstellen des FooCreatedEvent aufteilen. Man könnte auch einen FooAggregate-Konstruktor (FooCreatedEvent) oder einen FooAggregate-Konstruktor (int, int) verwenden, der das Ereignis erstellt. Wie genau Sie die Verantwortung hier aufteilen, hängt davon ab, was Ihrer Meinung nach am saubersten ist und wie Sie Ihre Domain-Event-Registrierung implementiert haben. Ich entscheide mich oft dafür, dass die Factory das Ereignis erstellt - aber es liegt an Ihnen, da die Ereigniserstellung jetzt ein internes Implementierungsdetail ist, das Sie jederzeit ändern und umgestalten können, ohne Ihre externe Schnittstelle zu ändern. Ein wichtiges Detail hierbei ist, dass das Aggregat keinen öffentlichen Konstruktor hat und dass alle Setter privat sind. Sie möchten nicht, dass jemand sie extern verwendet.

Dieses Muster funktioniert gut, wenn Sie nur mehr oder weniger Konstruktoren ersetzen. Wenn Sie jedoch eine fortgeschrittenere Objektkonstruktion haben, kann dies viel zu komplex werden, um verwendet zu werden. In diesem Fall verzichte ich normalerweise auf das Factory-Muster und wende mich stattdessen einem Builder-Muster zu - oft mit einer flüssigeren Syntax.

Dieses Beispiel ist etwas erzwungen, da die Klasse, die es erstellt, nicht sehr komplex ist, aber Sie können die Idee hoffentlich verstehen und sehen, wie es komplexere Konstruktionsaufgaben erleichtern würde

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;
    }
}

Und dann benutzt du es gerne

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

Und natürlich, wenn ich mich dem Builder-Muster zuwende, sind es im Allgemeinen nicht nur zwei Ints, sondern möglicherweise Listen einiger Wertobjekte oder Wörterbücher irgendeiner Art von vielleicht anderen haarigeren Dingen. Es ist jedoch auch sehr nützlich, wenn Sie viele Kombinationen optionaler Parameter haben.

Die wichtigsten Erkenntnisse für diesen Weg sind:

  • Ihr Hauptziel ist es, die Objektkonstruktion zu abstrahieren, damit der externe Benutzer nichts über Ihr Ereignissystem wissen muss.
  • Es ist nicht so wichtig, wo oder wer das Erstellungsereignis registriert. Der wichtige Teil ist, dass es registriert wird und dass Sie dies garantieren können - abgesehen davon handelt es sich um ein internes Implementierungsdetail. Tun Sie, was am besten zu Ihrem Code passt, folgen Sie nicht meinem Beispiel, da dies eine Art "das ist der richtige Weg" ist.
  • Wenn Sie möchten, können Ihre Fabriken / Repositorys auf diese Weise Schnittstellen anstelle konkreter Klassen zurückgeben, sodass sie für Unit-Tests leichter verspottet werden können!
  • Dies ist manchmal eine Menge zusätzlicher Code, der viele Leute davor zurückschreckt. Aber es ist oft recht einfacher Code im Vergleich zu den Alternativen und er bietet Wert, wenn Sie früher oder später etwas ändern müssen. Es gibt einen Grund, warum Eric Evans in seinem DDD-Buch von Fabriken / Repositories als wichtigen Teilen von DDD spricht - sie sind notwendige Abstraktionen, um bestimmte Implementierungsdetails nicht an den Benutzer weiterzugeben. Und undichte Abstraktionen sind schlecht.

Hoffe das hilft ein bisschen mehr, bitte einfach um Klarstellung in den Kommentaren sonst :)

wasatz
quelle
+1, ich habe Fabriken nie als mögliche Designlösung angesehen. Eines jedoch: Es hört sich so an, als ob Fabriken im Wesentlichen denselben Platz in der öffentlichen Schnittstelle einnehmen, den Aggregatkonstruktoren (+ möglicherweise eine InitializeMethode) besetzt hätten. Das führt mich zu der Frage, wie eine solche Fabrik von Ihnen aussehen könnte.
Stakx
3

Meiner Meinung nach entspricht die Antwort eher Ihrem Vorschlag Nr. 2 für vorhandene Aggregate. für neue Aggregate eher wie # 3 (aber Verarbeitung des Ereignisses wie von Ihnen vorgeschlagen).

Hier ist ein Code, ich hoffe, er hilft.

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; }
}
Stephen Drew
quelle
0

Nun, das erste Ereignis sollte sein, dass ein Kunde einen Warenkorb erstellt . Wenn der Warenkorb erstellt wird, haben Sie bereits eine Kunden-ID als Teil des Ereignisses.

Wenn ein Zustand zwischen zwei verschiedenen Ereignissen besteht, ist er per Definition ein gültiger Zustand. Wenn Sie also sagen, dass einem Kunden ein gültiger Warenkorb zugeordnet ist, bedeutet dies, dass Sie die Kundeninformationen benötigen, wenn Sie den Warenkorb selbst erstellen

Andrea
quelle
Bei meiner Frage geht es nicht darum, wie die Domain modelliert werden soll. Ich verwende absichtlich ein einfaches (aber vielleicht unvollkommenes) Domain-Modell, um eine Frage zu stellen. Schauen Sie sich die CreatedEmptyShoppingCartVeranstaltung in meiner Frage an: Sie enthält die Kundeninformationen, genau wie Sie es vorschlagen. Meine Frage bezieht sich eher darauf, wie sich ein solches Ereignis auf den Klassenkonstruktor ShoppingCartwährend der Implementierung bezieht oder mit diesem konkurriert .
Stakx
1
Ich verstehe die Frage besser. Ich würde also sagen, die richtige Antwort sollte die dritte sein. Ich denke, Ihre Entitäten sollten immer in einem gültigen Zustand sein. Wenn dies erfordert, dass ein Warenkorb eine Kunden-ID hat, sollte diese zum Zeitpunkt der Erstellung bereitgestellt werden, weshalb ein Konstruktor erforderlich ist, der eine Kunden-ID akzeptiert. Ich bin eher an CQRS in Scala gewöhnt, wo Domänenobjekte durch Fallklassen dargestellt werden, die unveränderlich sind und Konstruktoren für alle möglichen Kombinationen von Parametern haben, also gab ich dies als selbstverständlich an
Andrea
0

Hinweis: Wenn Sie kein aggregiertes Stammverzeichnis verwenden möchten, umfasst "Entität" das meiste, worüber Sie hier fragen, während Sie die Bedenken hinsichtlich der Transaktionsgrenzen umgehen.

Hier ist eine andere Art, darüber nachzudenken: Eine Entität ist Identität + Zustand. Im Gegensatz zu einem Wert ist eine Entität derselbe Typ, selbst wenn sich sein Zustand ändert.

Aber der Staat selbst kann als Wertobjekt betrachtet werden. Damit meine ich, der Staat ist unveränderlich; Die Historie der Entität ist ein Übergang von einem unveränderlichen Zustand zum nächsten - jeder Übergang entspricht einem Ereignis im Ereignisstrom.

State nextState = currentState.onEvent(e);

Die onEvent () -Methode ist natürlich eine Abfrage - currentState wird überhaupt nicht geändert, stattdessen berechnet currentState die Argumente, die zum Erstellen des nextState verwendet werden.

Nach diesem Modell kann davon ausgegangen werden, dass alle Instanzen des Einkaufswagens mit demselben Startwert beginnen.

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

ShoppingCart cart = new ShoppingCart(id, currentState);

Trennung von Bedenken - Der ShoppingCart verarbeitet einen Befehl, um herauszufinden, welches Ereignis als nächstes kommen soll. Der ShoppingCart-Status weiß, wie er zum nächsten Status gelangt.

VoiceOfUnreason
quelle