¿Dónde se debe inicializar completamente un objeto en CQRS + ES: en el constructor o al aplicar el primer evento?


9

Parece haber un acuerdo generalizado en la comunidad OOP de que el constructor de la clase no debe dejar un objeto parcial o incluso sin inicializar.

¿Qué quiero decir con "inicialización"? En términos generales, el proceso atómico que lleva a un objeto recién creado a un estado en el que se encuentran todos sus invariantes de clase. Debe ser lo primero que le sucede a un objeto (solo debe ejecutarse una vez por objeto) y no se debe permitir que nada se apodere de un objeto no inicializado. (Por lo tanto, el consejo frecuente de realizar la inicialización de objetos directamente en el constructor de la clase. Por la misma razón, los Initializemétodos a menudo están mal vistos, ya que estos separan la atomicidad y hacen posible agarrar y usar un objeto que aún no está en un estado bien definido)

Problema: cuando CQRS se combina con el abastecimiento de eventos (CQRS + ES), donde todos los cambios de estado de un objeto se capturan en una serie ordenada de eventos (secuencia de eventos), me pregunto cuándo un objeto realmente alcanza un estado completamente inicializado: ¿Al final del constructor de la clase, o después de que el primer evento se haya aplicado al objeto?

Nota: me abstengo de usar el término "raíz agregada". Si lo prefiere, sustitúyalo cada vez que lea "objeto".

Ejemplo de discusión: Suponga que cada objeto está identificado de manera única por algún Idvalor opaco (piense en GUID) Un flujo de eventos que representa los cambios de estado de ese objeto se puede identificar en el almacén de eventos con el mismo Idvalor: (No nos preocupemos por el orden correcto de los eventos).

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

Supongamos además que hay dos tipos de objetos Customery ShoppingCart. Centrémonos en ShoppingCart: Cuando se crean, los carritos de compras están vacíos y deben estar asociados con exactamente un cliente. Ese último bit es una clase invariante: un ShoppingCartobjeto que no está asociado a un Customerestá en un estado no válido.

En OOP tradicional, uno podría modelar esto en el constructor:

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

Sin embargo, no sé cómo modelar esto en CQRS + ES sin terminar con la inicialización diferida. Dado que este simple bit de inicialización es efectivamente un cambio de estado, ¿no tendría que modelarse como 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.

Obviamente, este tendría que ser el primer evento en ShoppingCartla secuencia de eventos de cualquier objeto, y ese objeto solo se inicializaría una vez que se le aplicara el evento.

Entonces, si la inicialización se convierte en parte de la "reproducción" de la secuencia de eventos (que es un proceso muy genérico que probablemente funcionaría igual, ya sea para un Customerobjeto o un ShoppingCartobjeto o cualquier otro tipo de objeto) ...

  • ¿Debería el constructor no tener parámetros y no hacer nada, dejando todo el trabajo a algún void Apply(CreatedEmptyShoppingCart)método (que es muy parecido al mal visto Initialize())?
  • ¿O debería el constructor recibir un flujo de eventos y reproducirlo (lo que hace que la inicialización sea atómica nuevamente, pero significa que el constructor de cada clase contiene la misma lógica genérica de "reproducir y aplicar", es decir, duplicación de código no deseada)?
  • ¿O debería haber un constructor tradicional de OOP (como se muestra arriba) que inicializa correctamente el objeto, y luego todos los eventos excepto el primero están void Apply(…)ligados a él?

No espero que la respuesta proporcione una implementación demo totalmente funcional; Ya estaría muy feliz si alguien pudiera explicar dónde está defectuoso mi razonamiento, o si la inicialización de objetos es realmente un "punto débil" en la mayoría de las implementaciones de CQRS + ES.

Respuestas:


3

Al hacer CQRS + ES prefiero no tener constructores públicos en absoluto. La creación de mis raíces agregadas debe hacerse a través de una fábrica (para construcciones lo suficientemente simples como esta) o un generador (para raíces agregadas más complicadas).

Cómo inicializar realmente el objeto es un detalle de implementación. El OOP "No use initialize" es mi opinión acerca de las interfaces públicas . No debe esperar que cualquiera que use su código sepa que debe llamar a SecretInitializeMethod42 (bool, int, string); eso es un mal diseño de API pública. Sin embargo, si su clase no proporciona ningún constructor público, sino que hay un ShoppingCartFactory con el método CreateNewShoppingCart (string), entonces la implementación de esa fábrica puede ocultar cualquier tipo de magia de inicialización / constructor que su usuario no necesita saber about (por lo tanto, proporciona una buena API pública, pero le permite hacer una creación de objetos más avanzada detrás de escena).

Las fábricas obtienen una mala reputación de las personas que piensan que hay demasiadas, pero si se usan correctamente, pueden ocultar una gran cantidad de complejidad detrás de una API pública agradable y fácil de entender. No tenga miedo de usarlos, son una herramienta poderosa que puede ayudarlo a hacer que la construcción de objetos complejos sea mucho más fácil, siempre que pueda vivir con más líneas de código.

No es una carrera para ver quién puede resolver el problema con la menor cantidad de líneas de código; sin embargo, es una competencia continua en cuanto a quién puede hacer las mejores API públicas. ;)

Editar: Agregar algunos ejemplos sobre cómo se vería la aplicación de estos patrones

Si solo tiene un constructor agregado "fácil" que tiene un par de parámetros requeridos, puede ir con una implementación de fábrica muy básica, algo en este sentido

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

Por supuesto, la forma exacta en que divide la creación del FooCreatedEvent depende en este caso de usted. También se podría argumentar que tiene un constructor FooAggregate (FooCreatedEvent) o que tiene un constructor FooAggregate (int, int) que crea el evento. Exactamente cómo elige dividir la responsabilidad aquí depende de lo que considere más limpio y cómo ha implementado el registro de su evento de dominio. A menudo elijo que la fábrica cree el evento, pero depende de usted, ya que la creación del evento ahora es un detalle de implementación interna que puede cambiar y refactorizar en cualquier momento sin cambiar su interfaz externa. Un detalle importante aquí es que el agregado no tiene un constructor público y que todos los establecedores son privados. No quieres que nadie los use externamente.

Este patrón funciona bien cuando solo está reemplazando más o menos constructores, pero si tiene una construcción de objetos más avanzada, esto puede volverse demasiado complejo de usar. En este caso, generalmente renuncio al patrón de fábrica y en su lugar recurro a un patrón de construcción, a menudo con una sintaxis más fluida.

Este ejemplo es un poco forzado ya que la clase que construye no es muy compleja, pero es de esperar que pueda comprender la idea y ver cómo facilitaría las tareas de construcción más complejas.

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

Y luego lo usas como

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

Y, por supuesto, en general, cuando recurro al patrón de construcción no son solo dos entradas, puede contener listas de algunos objetos de valor o diccionarios de algún tipo de tal vez otras cosas más peludas. Sin embargo, también es bastante útil si tiene muchas combinaciones de parámetros opcionales.

Las conclusiones importantes para seguir este camino son:

  • Su objetivo principal es poder construir objetos abstractos para que el usuario externo no tenga que conocer su sistema de eventos.
  • No es tan importante dónde o quién registra el evento de creación, la parte importante es que se registra y puede garantizar que, aparte de eso, es un detalle de implementación interna. Haz lo que mejor se adapte a tu código, no sigas mi ejemplo como una especie de "esta es la forma correcta".
  • Si lo desea, al hacerlo de esta manera, puede hacer que sus fábricas / repositorios devuelvan interfaces en lugar de clases concretas, lo que hace que sea más fácil burlarse de las pruebas unitarias.
  • Esto es una gran cantidad de código adicional a veces, lo que hace que muchas personas lo eviten. Pero a menudo es un código bastante fácil en comparación con las alternativas y proporciona valor cuando tarde o temprano necesitas cambiar cosas. Hay una razón por la que Eric Evans habla de las fábricas / repositorios en su libro DDD como partes importantes de DDD: son abstracciones necesarias para no filtrar ciertos detalles de implementación al usuario. Y las abstracciones permeables son malas.

Espero que ayude un poco más, solo pida aclaraciones en los comentarios de lo contrario :)


+1, nunca pensé en las fábricas como una posible solución de diseño. Sin embargo, una cosa: parece que las fábricas ocupan esencialmente el mismo lugar en la interfaz pública que los constructores agregados (+ quizás un Initializemétodo) habrían ocupado. Eso me lleva a la pregunta, ¿cómo podría ser una fábrica tuya?
stakx

3

En mi opinión, creo que la respuesta se parece más a su sugerencia # 2 para los agregados existentes; para nuevos agregados más como # 3 (pero procesando el evento como sugirió).

Aquí hay un código, espero que sea de alguna ayuda.

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

Bueno, el primer evento debe ser que un cliente cree un carrito de compras , de modo que cuando se crea el carrito de compras ya tenga una identificación de cliente como parte del evento.

Si un estado se mantiene entre dos eventos diferentes, por definición, es un estado válido. Entonces, si dice que un carrito de compras válido está asociado a un cliente, esto significa que debe tener la información del cliente al crear el carrito en sí


Mi pregunta no es sobre cómo modelar el dominio; Estoy usando deliberadamente un modelo de dominio simple (pero quizás imperfecto) para hacer una pregunta. Eche un vistazo al CreatedEmptyShoppingCartevento en mi pregunta: tiene la información del cliente, tal como sugiere. Mi pregunta es más acerca de cómo dicho evento se relaciona o compite con el constructor de la clase ShoppingCartdurante la implementación.
stakx

1
Entiendo mejor la pregunta. Entonces, yo diría, la respuesta correcta debería ser la tercera. Creo que sus entidades siempre deben estar en un estado válido. Si esto requiere que un carrito de compras tenga una identificación de cliente, entonces debe proporcionarse en el momento de la creación, de ahí la necesidad de un constructor que acepte una identificación de cliente. Estoy más acostumbrado a CQRS en Scala, donde los objetos de dominio estarían representados por clases de casos, que son inmutables y tienen constructores para todas las combinaciones posibles de parámetros, así que estaba dando esto por sentado
Andrea

0

Nota: si no desea utilizar la raíz agregada, "entidad" abarca la mayor parte de lo que está preguntando aquí mientras deja de lado las preocupaciones sobre los límites de las transacciones.

Aquí hay otra forma de pensar al respecto: una entidad es identidad + estado. A diferencia de un valor, una entidad es el mismo tipo incluso cuando su estado cambia.

Pero el estado mismo puede considerarse como un objeto de valor. Con esto quiero decir, el estado es inmutable; La historia de la entidad es una transición de un estado inmutable al siguiente: cada transición corresponde a un evento en la secuencia de eventos.

State nextState = currentState.onEvent(e);

El método onEvent () es una consulta, por supuesto: currentState no cambia en absoluto, en su lugar, currentState está calculando los argumentos utilizados para crear el nextState.

Siguiendo este modelo, se puede considerar que todas las instancias de Carrito de compras parten del mismo valor inicial ...

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

ShoppingCart cart = new ShoppingCart(id, currentState);

Separación de inquietudes: ShoppingCart procesa un comando para determinar qué evento debe ser el próximo; el ShoppingCart State sabe cómo llegar al siguiente estado.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.