Où un objet dans CQRS + ES doit-il être complètement initialisé: dans le constructeur, ou lors de l'application du premier événement?

9

Il semble y avoir un large consensus dans la communauté OOP selon lequel le constructeur de classe ne doit pas laisser un objet partiellement ou même non initialisé.

Qu'est-ce que j'entends par "initialisation"? Grosso modo, le processus atomique qui amène un objet nouvellement créé dans un état où tous ses invariants de classe tiennent. Cela devrait être la première chose qui arrive à un objet (il ne devrait s'exécuter qu'une seule fois par objet) et rien ne devrait être autorisé à mettre la main sur un objet non initialisé. (D'où les conseils fréquents pour effectuer une initialisation d'objet directement dans le constructeur de classe. Pour la même raison, les Initializeméthodes sont souvent désapprouvées, car elles séparent l'atomicité et permettent de saisir et d'utiliser un objet qui n'est pas encore dans un état bien défini.)

Problème: lorsque CQRS est combiné avec le sourcing d'événements (CQRS + ES), où tous les changements d'état d'un objet sont capturés dans une série ordonnée d'événements (flux d'événements), je me demande quand un objet atteint réellement un état entièrement initialisé: À la fin du constructeur de classe, ou après que le tout premier événement a été appliqué à l'objet?

Remarque: je m'abstiens d'utiliser le terme "racine agrégée". Si vous préférez, remplacez-le chaque fois que vous lisez "objet".

Exemple de discussion: Supposons que chaque objet est identifié de manière unique par une Idvaleur opaque (pensez GUID). Un flux d'événements représentant les changements d'état de cet objet peut être identifié dans le magasin d'événements par la même Idvaleur: (Ne nous inquiétons pas du bon ordre des événements.)

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

Supposons en outre qu'il existe deux types d'objet Customeret ShoppingCart. Concentrons-nous sur ShoppingCart: Une fois créés, les paniers sont vides et doivent être associés à exactement un client. Ce dernier bit est un invariant de classe: un ShoppingCartobjet qui n'est pas associé à un Customerest dans un état non valide.

Dans la POO traditionnelle, on pourrait modéliser cela dans le constructeur:

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

Cependant, je ne sais pas comment modéliser cela dans CQRS + ES sans se retrouver avec une initialisation différée. Puisque ce simple bout d'initialisation est effectivement un changement d'état, ne devrait-il pas être modélisé comme un événement?:

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.

Cela devrait évidemment être le tout premier événement dans ShoppingCartle flux d'événements d' un objet, et cet objet ne serait initialisé qu'une fois que l'événement lui aurait été appliqué.

Donc, si l'initialisation fait partie du flux d'événements "lecture" (qui est un processus très générique qui fonctionnerait probablement de la même manière, que ce soit pour un Customerobjet ou un ShoppingCartobjet ou tout autre type d'objet d'ailleurs) ...

  • Le constructeur devrait-il être sans paramètre et ne rien faire, laissant tout le travail à une void Apply(CreatedEmptyShoppingCart)méthode (qui est à peu près la même que celle désapprouvée Initialize())?
  • Ou le constructeur doit-il recevoir un flux d'événements et le lire (ce qui rend l'initialisation atomique à nouveau, mais signifie que le constructeur de chaque classe contient la même logique générique "lecture et application", c'est-à-dire duplication de code indésirable)?
  • Ou devrait-il y avoir à la fois un constructeur OOP traditionnel (comme indiqué ci-dessus) qui initialise correctement l'objet, puis tous les événements sauf les premiers lui sont void Apply(…)liés?

Je ne m'attends pas à ce que la réponse fournisse une implémentation de démonstration pleinement fonctionnelle; Je serais déjà très heureux si quelqu'un pouvait expliquer où mon raisonnement est défectueux, ou si l'initialisation d'objet est vraiment un "point douloureux" dans la plupart des implémentations CQRS + ES.

stakx
la source

Réponses:

3

Lorsque je fais CQRS + ES, je préfère ne pas avoir du tout de constructeurs publics. La création de mes racines agrégées doit se faire via une usine (pour des constructions assez simples comme celle-ci) ou un constructeur (pour des racines agrégées plus compliquées).

Comment ensuite réellement initialiser l'objet est un détail d'implémentation. Le conseil "Ne pas utiliser l'initialisation" est à mon avis sur les interfaces publiques . Vous ne devez pas vous attendre à ce que quiconque utilise votre code sache qu'il doit appeler SecretInitializeMethod42 (bool, int, string) - c'est une mauvaise conception d'API publique. Cependant, si votre classe ne fournit aucun constructeur public mais qu'il existe à la place un ShoppingCartFactory avec la méthode CreateNewShoppingCart (chaîne), l'implémentation de cette fabrique peut très bien masquer tout type de magie d'initialisation / constructeur que votre utilisateur n'a alors pas besoin de connaître. about (fournissant ainsi une belle API publique, mais vous permettant de créer des objets plus avancés dans les coulisses).

Les usines sont mal vues par les gens qui pensent qu'il y en a trop, mais utilisées correctement, elles peuvent cacher beaucoup de complexité derrière une belle API publique facile à comprendre. N'ayez pas peur de les utiliser, ils sont un outil puissant qui peut vous aider à faciliter la construction d'objets complexes - tant que vous pouvez vivre avec plus de lignes de code.

Ce n'est pas une course pour voir qui peut résoudre le problème avec le moins de lignes de code - c'est cependant une compétition permanente pour savoir qui peut créer les meilleures API publiques! ;)

Edit: Ajout d'exemples sur l'apparence de l'application de ces modèles

Si vous avez juste un constructeur d'agrégat "facile" qui a quelques paramètres requis, vous pouvez opter pour une implémentation d'usine très basique, quelque chose du genre

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

Bien sûr, la manière exacte de diviser la création de FooCreatedEvent dépend de vous. On pourrait également justifier un constructeur FooAggregate (FooCreatedEvent) ou un constructeur FooAggregate (int, int) qui crée l'événement. La façon exacte dont vous choisissez de répartir la responsabilité dépend de ce que vous pensez être le plus propre et de la manière dont vous avez mis en œuvre l'enregistrement de votre événement de domaine. Je choisis souvent de confier la création de l'événement à l'usine - mais c'est à vous de décider que la création d'événement est désormais un détail d'implémentation interne que vous pouvez modifier et refactoriser à tout moment sans changer votre interface externe. Un détail important ici est que l'agrégat n'a pas de constructeur public et que tous les setters sont privés. Vous ne voulez pas que quelqu'un les utilise en externe.

Ce modèle fonctionne bien lorsque vous remplacez plus ou moins les constructeurs, mais si vous avez une construction d'objet plus avancée, cela peut devenir beaucoup trop complexe à utiliser. Dans ce cas, je renonce généralement au modèle d'usine et je me tourne plutôt vers un modèle de générateur - souvent avec une syntaxe plus fluide.

Cet exemple est un peu forcé car la classe qu'il construit n'est pas très complexe, mais vous pouvez, espérons-le, saisir l'idée et voir comment cela faciliterait les tâches de construction plus complexes

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

Et puis vous l'utilisez comme

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

Et bien sûr, généralement lorsque je passe au modèle de générateur, ce n'est pas seulement deux pouces, il peut contenir des listes d'objets de valeur ou des dictionnaires d'une sorte de peut-être d'autres choses plus velues. Il est cependant également très utile si vous disposez de nombreuses combinaisons de paramètres facultatifs.

Les points importants à retenir pour suivre cette voie sont les suivants:

  • Votre objectif principal est de pouvoir abstraire la construction d'objets afin que l'utilisateur externe n'ait pas à connaître votre système d'événements.
  • Peu importe où ou qui enregistre l'événement de création, l'important est qu'il soit enregistré et que vous puissiez garantir cela - à part cela, c'est un détail d'implémentation interne. Faites ce qui convient le mieux à votre code, ne suivez pas mon exemple comme une sorte de "c'est la bonne façon".
  • Si vous le souhaitez, en procédant de cette façon, vous pourriez faire en sorte que vos usines / référentiels retournent des interfaces au lieu de classes concrètes - ce qui les rend plus faciles à simuler pour les tests unitaires!
  • C'est parfois beaucoup de code supplémentaire, ce qui fait que beaucoup de gens s'en détournent. Mais c'est souvent un code assez facile par rapport aux alternatives et il fournit de la valeur lorsque vous devez tôt ou tard changer des choses. Il y a une raison pour laquelle Eric Evans parle des usines / référentiels dans son livre DDD comme des parties importantes de DDD - ce sont des abstractions nécessaires pour ne pas divulguer certains détails d'implémentation à l'utilisateur. Et les abstractions qui fuient sont mauvaises.

J'espère que cela aide un peu plus, demandez simplement des clarifications dans les commentaires sinon :)

wasatz
la source
+1, je n'ai même jamais pensé aux usines comme une solution de conception possible. Une chose cependant: il semble que les usines occupent essentiellement la même place dans l'interface publique Initializequ'auraient occupé les constructeurs d'agrégats (+ peut-être une méthode). Cela m'amène à la question: à quoi pourrait ressembler une telle usine?
stakx
3

À mon avis, je pense que la réponse ressemble plus à votre suggestion # 2 pour les agrégats existants; pour les nouveaux agrégats plus comme # 3 (mais en traitant l'événement comme vous l'avez suggéré).

Voici du code, j'espère qu'il vous sera utile.

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
la source
0

Eh bien, le premier événement doit être qu'un client crée un panier d'achat , donc lorsque le panier d'achat est créé, vous avez déjà un identifiant client dans le cadre de l'événement.

Si un état se tient entre deux événements différents, par définition, c'est un état valide. Donc, si vous dites qu'un panier d'achat valide est associé à un client, cela signifie que vous devez avoir les informations du client lors de la création du panier lui-même

Andrea
la source
Ma question n'est pas de savoir comment modéliser le domaine; J'utilise délibérément un modèle de domaine simple (mais peut-être imparfait) pour poser une question. Jetez un œil à l' CreatedEmptyShoppingCartévénement dans ma question: il contient les informations client, comme vous le suggérez. Ma question porte davantage sur la manière dont un tel événement est lié au constructeur de classe ou en concurrence avec lui ShoppingCartpendant la mise en œuvre.
stakx
1
Je comprends mieux la question. Je dirais donc que la bonne réponse devrait être la troisième. Je pense que vos entités devraient toujours être dans un état valide. Si cela nécessite qu'un panier d'achat ait un identifiant client, celui-ci doit être fourni au moment de la création, d'où la nécessité d'un constructeur qui accepte un identifiant client. Je suis plus habitué au CQRS à Scala, où les objets de domaine seraient représentés par des classes de cas, qui sont immuables et ont des constructeurs pour toutes les combinaisons possibles de paramètres, donc je donnais cela pour acquis
Andrea
0

Remarque: si vous ne souhaitez pas utiliser la racine agrégée, "entité" englobe la plupart de ce que vous demandez ici, tout en évitant les préoccupations concernant les limites des transactions.

Voici une autre façon de voir les choses: une entité est identité + état. Contrairement à une valeur, une entité est le même type même lorsque son état change.

Mais l'État lui-même peut être considéré comme un objet de valeur. J'entends par là que l'État est immuable; l'historique de l'entité est une transition d'un état immuable au suivant - chaque transition correspond à un événement dans le flux d'événements.

State nextState = currentState.onEvent(e);

La méthode onEvent () est une requête, bien sûr - currentState n'est pas changé du tout, mais currentState calcule les arguments utilisés pour créer le nextState.

Suivant ce modèle, toutes les instances du panier peuvent être considérées comme commençant à la même valeur de départ ....

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

ShoppingCart cart = new ShoppingCart(id, currentState);

Séparation des préoccupations - le ShoppingCart traite une commande pour déterminer quel événement devrait être le suivant; l'État ShoppingCart sait comment passer à l'état suivant.

VoiceOfUnreason
la source