Dove dovrebbe essere completamente inizializzato un oggetto in CQRS + ES: nel costruttore o quando si applica il primo evento?


9

Sembra esserci un ampio consenso nella comunità OOP sul fatto che il costruttore della classe non dovrebbe lasciare un oggetto parzialmente o addirittura completamente non inizializzato.

Cosa intendo per "inizializzazione"? In parole povere, il processo atomico che porta un oggetto appena creato in uno stato in cui si trovano tutti gli invarianti della sua classe. Dovrebbe essere la prima cosa che accade a un oggetto (dovrebbe essere eseguito una sola volta per oggetto) e nulla dovrebbe essere autorizzato a prendere un oggetto non inizializzato. (Pertanto, il consiglio frequente di eseguire l'inizializzazione degli oggetti proprio nel costruttore della classe. Per lo stesso motivo, i Initializemetodi sono spesso disapprovati, poiché rompono l'atomicità e consentono di afferrare e utilizzare un oggetto che non è ancora stato in uno stato ben definito.)

Problema: quando CQRS è combinato con il sourcing di eventi (CQRS + ES), in cui tutte le modifiche di stato di un oggetto vengono rilevate in una serie ordinata di eventi (flusso di eventi), mi viene da chiedermi quando un oggetto raggiunga effettivamente uno stato completamente inizializzato: Alla fine del costruttore della classe o dopo che il primo evento è stato applicato all'oggetto?

Nota: mi sto trattenendo dall'usare il termine "radice aggregata". Se preferisci, sostituiscilo ogni volta che leggi "oggetto".

Esempio di discussione: supponiamo che ogni oggetto sia identificato in modo univoco da un Idvalore opaco (pensa GUID). Un flusso di eventi che rappresenta i cambiamenti di stato di quell'oggetto può essere identificato nell'archivio eventi con lo stesso Idvalore: (Non preoccupiamoci del corretto ordine degli eventi).

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

Supponiamo inoltre che ci siano due tipi di oggetti Customere ShoppingCart. Concentriamoci su ShoppingCart: una volta creati, i carrelli della spesa sono vuoti e devono essere associati esattamente a un cliente. L'ultimo bit è un invariante di classe: un ShoppingCartoggetto che non è associato a Customerè in uno stato non valido.

In OOP tradizionale, si potrebbe modellare questo nel costruttore:

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

Sono comunque in perdita su come modellarlo in CQRS + ES senza finire con l'inizializzazione differita. Poiché questo semplice bit di inizializzazione è effettivamente un cambiamento di stato, non dovrebbe essere modellato come un evento ?:

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.

Questo ovviamente dovrebbe essere il primo evento nel ShoppingCartflusso di eventi di qualsiasi oggetto e quell'oggetto verrà inizializzato solo dopo che l'evento è stato applicato ad esso.

Quindi, se l'inizializzazione diventa parte del flusso di eventi "playback" (che è un processo molto generico che probabilmente funzionerebbe allo stesso modo, sia per un Customeroggetto o un ShoppingCartoggetto o qualsiasi altro tipo di oggetto per quella materia) ...

  • Il costruttore dovrebbe essere senza parametri e non fare nulla, lasciando tutto il lavoro su un void Apply(CreatedEmptyShoppingCart)metodo (che è molto simile a quello accigliato Initialize())?
  • O il costruttore dovrebbe ricevere un flusso di eventi e riprodurlo (il che rende di nuovo atomica l'inizializzazione, ma significa che ogni costruttore di classi contiene la stessa logica generica di "riproduzione e applicazione", ovvero duplicazione di codice indesiderata)?
  • O dovrebbe esserci sia un costruttore OOP tradizionale (come mostrato sopra) che inizializza correttamente l'oggetto, e quindi tutti gli eventi, tranne il primo, sono void Apply(…)interessati ad esso?

Non mi aspetto che la risposta fornisca un'implementazione demo completamente funzionante; Sarei già molto felice se qualcuno potesse spiegare dove sono sbagliati i miei ragionamenti o se l'inizializzazione degli oggetti è davvero un "punto critico" nella maggior parte delle implementazioni di CQRS + ES.

Risposte:


3

Quando faccio CQRS + ES preferisco non avere affatto costruttori pubblici. La creazione delle mie radici aggregate dovrebbe avvenire tramite una fabbrica (per costruzioni abbastanza semplici come questa) o un costruttore (per radici aggregate più complicate).

Come inizializzare effettivamente l'oggetto è un dettaglio di implementazione. L'avviso "Non usare l'inizializzazione" OOP è imho sulle interfacce pubbliche . Non dovresti aspettarti che chiunque usi il tuo codice sappia che deve chiamare SecretInitializeMethod42 (bool, int, string) - questa è una cattiva progettazione dell'API pubblica. Tuttavia, se la tua classe non fornisce alcun costruttore pubblico ma esiste invece una ShoppingCartFactory con il metodo CreateNewShoppingCart (stringa), l'implementazione di quella fabbrica potrebbe benissimo nascondere qualsiasi tipo di inizializzazione / magia del costruttore che l'utente non deve conoscere about (fornendo così una bella API pubblica, ma permettendoti di realizzare oggetti più avanzati dietro le quinte).

Le fabbriche ottengono una cattiva reputazione dalle persone che pensano che ce ne siano troppe, ma usate correttamente possono nascondere molta complessità dietro una bella API pubblica di facile comprensione. Non aver paura di usarli, sono uno strumento potente che può aiutarti a rendere molto più semplice la costruzione di oggetti complessi, purché tu possa vivere con qualche altra riga di codice.

Non è una gara per vedere chi può risolvere il problema con il minor numero di righe di codice, ma è comunque una competizione in corso su chi può rendere le API pubbliche più belle! ;)

Modifica: aggiunta di alcuni esempi su come potrebbe apparire l'applicazione di questi motivi

Se hai solo un costruttore aggregato "facile" che ha un paio di parametri richiesti, puoi procedere con un'implementazione di fabbrica molto semplice, qualcosa del genere

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

Naturalmente, in questo caso dipende esattamente da come dividere creando FooCreatedEvent. Uno potrebbe anche essere un caso per avere un costruttore FooAggregate (FooCreatedEvent) o avere un costruttore FooAggregate (int, int) che crea l'evento. Il modo esatto in cui scegli di dividere la responsabilità qui dipende da ciò che ritieni sia il più pulito e da come hai implementato la registrazione degli eventi del tuo dominio. Scelgo spesso che la fabbrica crei l'evento, ma dipende da te poiché la creazione dell'evento è ora un dettaglio di implementazione interna che puoi modificare e refactoring in qualsiasi momento senza cambiare l'interfaccia esterna. Un dettaglio importante qui è che l'aggregato non ha un costruttore pubblico e che tutti i setter sono privati. Non vuoi che nessuno li usi esternamente.

Questo modello funziona bene quando si sostituiscono più o meno costruttori, ma se si dispone di una costruzione di oggetti più avanzata, questo potrebbe diventare troppo complesso da usare. In questo caso di solito rinuncio al modello di fabbrica e invece mi rivolgo a un modello di generatore, spesso con una sintassi più fluida.

Questo esempio è un po 'forzato poiché la classe che costruisce non è molto complessa, ma si spera che tu possa cogliere l'idea e vedere come faciliterebbe le attività di costruzione più complesse

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

E poi lo usi come

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

E ovviamente, in genere quando mi rivolgo al modello del costruttore non è solo due ints, può contenere elenchi di alcuni oggetti di valore o dizionari di qualche tipo di forse altre cose più pelose. È comunque anche molto utile se hai molte combinazioni di parametri opzionali.

Le cose più importanti da fare in questo modo sono:

  • Il tuo obiettivo principale è essere in grado di astrarre la costruzione di oggetti in modo che l'utente esterno non debba conoscere il tuo sistema di eventi.
  • Non è così importante dove o chi registra l'evento di creazione, la parte importante è che viene registrato e che puoi garantirlo - a parte questo, è un dettaglio di implementazione interno. Fai ciò che si adatta meglio al tuo codice, non seguire il mio esempio come una sorta di "questo è il modo giusto".
  • Se vuoi, andando in questo modo potresti far sì che le tue fabbriche / repository restituiscano interfacce invece di classi concrete, rendendole più facili da deridere per i test unitari!
  • Questo è un sacco di codice extra a volte, il che rende molte persone timide da esso. Ma è spesso un codice abbastanza semplice rispetto alle alternative e fornisce valore quando prima o poi devi cambiare roba. C'è una ragione per cui Eric Evans parla di fabbriche / depositi nel suo libro DDD come parti importanti di DDD: sono astrazioni necessarie per non svelare all'utente alcuni dettagli di implementazione. E le astrazioni che perdono sono cattive.

Spero che ti aiuti un po 'di più, basta chiedere chiarimenti nei commenti altrimenti :)


+1, non ho mai nemmeno pensato alle fabbriche come una possibile soluzione progettuale. Una cosa però: sembra che le fabbriche occupino essenzialmente lo stesso posto nell'interfaccia pubblica che i costruttori aggregati (+ forse un Initializemetodo) avrebbero occupato. Questo mi porta alla domanda: come potrebbe essere una tua fabbrica del genere?
stakx,

3

Secondo me, la risposta è più simile al tuo suggerimento n. 2 per gli aggregati esistenti; per i nuovi aggregati più come il n. 3 (ma elaborando l'evento come suggerito).

Ecco un po 'di codice, spero che sia di qualche aiuto.

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

Bene, il primo evento dovrebbe essere che un cliente crei un carrello , quindi quando viene creato il carrello hai già un ID cliente come parte dell'evento.

Se uno stato contiene due eventi diversi, per definizione è uno stato valido. Pertanto, se si afferma che un carrello della spesa valido è associato a un cliente, ciò significa che è necessario disporre delle informazioni sul cliente durante la creazione del carrello stesso


La mia domanda non riguarda come modellare il dominio; Sto deliberatamente usando un modello di dominio semplice (ma forse imperfetto) per fare una domanda. Dai un'occhiata CreatedEmptyShoppingCartall'evento nella mia domanda: contiene le informazioni sui clienti, proprio come suggerisci. La mia domanda riguarda maggiormente il modo in cui un tale evento si collega o compete con il costruttore della classe ShoppingCartdurante l'implementazione.
stakx,

1
Capisco meglio la domanda. Quindi, direi, la risposta corretta dovrebbe essere la terza. Penso che le tue entità dovrebbero essere sempre in uno stato valido. Se ciò richiede che un carrello acquisti abbia un ID cliente, questo deve essere fornito al momento della creazione, quindi la necessità di un costruttore che accetta un ID cliente. Sono più abituato al CQRS di Scala, dove gli oggetti di dominio sarebbero rappresentati da classi di casi, che sono immutabili e hanno costruttori per tutte le possibili combinazioni di parametri, quindi lo stavo dando per scontato
Andrea,

0

Nota: se non si desidera utilizzare la radice aggregata, "entità" racchiude la maggior parte di ciò che si sta chiedendo qui mentre si evitano le preoccupazioni sui limiti delle transazioni.

Ecco un altro modo di pensarci: un'entità è identità + stato. A differenza di un valore, un'entità è lo stesso ragazzo anche quando il suo stato cambia.

Ma lo stato stesso può essere pensato come un oggetto valore. Con questo intendo, lo stato è immutabile; la storia dell'entità è una transizione da uno stato immutabile al successivo - ogni transizione corrisponde a un evento nel flusso di eventi.

State nextState = currentState.onEvent(e);

Il metodo onEvent () è una query, ovviamente - currentState non è cambiato affatto, invece currentState sta calcolando gli argomenti usati per creare nextState.

Seguendo questo modello, tutte le istanze del carrello possono essere pensate a partire dallo stesso valore di seed ....

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

ShoppingCart cart = new ShoppingCart(id, currentState);

Separazione delle preoccupazioni: la ShoppingCart elabora un comando per capire quale evento dovrebbe essere il prossimo; lo stato ShoppingCart sa come arrivare allo stato successivo.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.