Onde um objeto no CQRS + ES deve ser totalmente inicializado: no construtor ou ao aplicar o primeiro evento?


9

Parece haver um amplo consenso na comunidade OOP de que o construtor de classes não deve deixar um objeto parcial ou totalmente não inicializado.

O que quero dizer com "inicialização"? Grosso modo, o processo atômico que leva um objeto recém-criado a um estado em que todos os seus invariantes de classe se mantêm. Deve ser a primeira coisa que acontece com um objeto (ele deve ser executado apenas uma vez por objeto) e nada deve ter permissão para se apossar de um objeto não inicializado. (Portanto, o conselho frequente de executar a inicialização do objeto diretamente no construtor da classe. Pelo mesmo motivo, os Initializemétodos geralmente são desaprovados, pois quebram a atomicidade e tornam possível obter e usar um objeto que ainda não está em um estado bem definido.)

Problema: Quando o CQRS é combinado com a fonte de eventos (CQRS + ES), onde todas as alterações de estado de um objeto são capturadas em uma série ordenada de eventos (fluxo de eventos), fico me perguntando quando um objeto realmente atinge um estado totalmente inicializado: No final do construtor da classe, ou após o primeiro evento ter sido aplicado ao objeto?

Nota: Não uso o termo "raiz agregada". Se preferir, substitua-o sempre que ler "objeto".

Exemplo para discussão: Suponha que cada objeto seja identificado exclusivamente por algum Idvalor opaco (pense em GUID). Um fluxo de eventos representando as alterações de estado desse objeto pode ser identificado no armazenamento de eventos pelo mesmo Idvalor: (Não vamos nos preocupar com a ordem correta do evento.)

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

Suponha ainda que existem dois tipos de objetos Customere ShoppingCart. Vamos nos concentrar em ShoppingCart: Quando criados, os carrinhos de compras estão vazios e devem estar associados a exatamente um cliente. Esse último bit é uma classe invariável: um ShoppingCartobjeto que não está associado a Customerestá em um estado inválido.

Na OOP tradicional, pode-se modelar isso no construtor:

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

No entanto, não sei como modelar isso no CQRS + ES sem terminar com a inicialização adiada. Como esse pequeno pedaço de inicialização é efetivamente uma alteração de estado, não precisa ser modelado como um 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, esse teria que ser o primeiro evento no ShoppingCartfluxo de eventos de qualquer objeto, e esse objeto só seria inicializado quando o evento fosse aplicado a ele.

Portanto, se a inicialização se tornar parte do fluxo de eventos "playback" (que é um processo muito genérico que provavelmente funcionaria da mesma forma, seja para um Customerobjeto ou ShoppingCartobjeto ou qualquer outro tipo de objeto) ...

  • O construtor deve ter menos parâmetros e não fazer nada, deixando todo o trabalho para algum void Apply(CreatedEmptyShoppingCart)método (que é praticamente o mesmo que o desaprovado Initialize())?
  • Ou o construtor deve receber um fluxo de eventos e reproduzi-lo (o que torna a inicialização atômica novamente, mas significa que o construtor de cada classe contém a mesma lógica genérica de "reproduzir e aplicar", ou seja, duplicação indesejada de código)?
  • Ou deveria haver um construtor OOP tradicional (como mostrado acima) que inicializa corretamente o objeto e, em seguida, todos os eventos, exceto o primeiro, estão void Apply(…)ligados a ele?

Não espero que a resposta forneça uma implementação de demonstração totalmente funcional; Eu já ficaria muito feliz se alguém pudesse explicar onde meu raciocínio é defeituoso ou se a inicialização do objeto é realmente um "ponto problemático" na maioria das implementações do CQRS + ES.

Respostas:


3

Ao fazer CQRS + ES, prefiro não ter construtores públicos. A criação de minhas raízes agregadas deve ser feita através de uma fábrica (para construções simples o suficiente como essa) ou de um construtor (para raízes agregadas mais complicadas).

Como realmente inicializar o objeto é um detalhe de implementação. O conselho "Não use inicializar" é imho sobre interfaces públicas . Você não deve esperar que alguém que use seu código saiba que deve chamar SecretInitializeMethod42 (bool, int, string) - isso é um mau design de API pública. No entanto, se sua classe não fornecer nenhum construtor público, mas houver um ShoppingCartFactory com o método CreateNewShoppingCart (string), a implementação dessa fábrica poderá muito bem ocultar qualquer tipo de mágica de inicialização / construtor que seu usuário não precise saber about (fornecendo uma boa API pública, mas permitindo a criação de objetos mais avançados nos bastidores).

As fábricas recebem um péssimo representante das pessoas que pensam que há muitas delas, mas, usadas corretamente, podem esconder muita complexidade por trás de uma API pública agradável e fácil de entender. Não tenha medo de usá-los, eles são uma ferramenta poderosa que pode ajudá-lo a facilitar a construção de objetos complexos - desde que você possa viver com mais algumas linhas de código.

Não é uma corrida para ver quem pode resolver o problema com o mínimo de linhas de código - é uma competição contínua sobre quem pode criar as APIs públicas mais agradáveis! ;)

Edit: Adicionando alguns exemplos de como a aplicação desses padrões pode parecer

Se você apenas tiver um construtor agregado "fácil" que possua alguns parâmetros necessários, poderá seguir apenas uma implementação de fábrica muito básica, algo nesse 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;
    }
}

Obviamente, exatamente como você divide a criação do FooCreatedEvent, neste caso, é com você. Também se poderia ter um construtor FooAggregate (FooCreatedEvent) ou um construtor FooAggregate (int, int) que cria o evento. Exatamente como você escolhe dividir a responsabilidade aqui é o que você acha mais limpo e como implementou o registro de eventos do domínio. Costumo escolher que a fábrica crie o evento - mas depende de você, pois a criação do evento agora é um detalhe interno da implementação que você pode alterar e refatorar a qualquer momento sem alterar a interface externa. Um detalhe importante aqui é que o agregado não possui um construtor público e que todos os setters são privados. Você não quer que ninguém os use externamente.

Esse padrão funciona bem quando você substitui mais ou menos os construtores, mas se você tiver uma construção de objetos mais avançada, isso pode se tornar muito complexo de usar. Nesse caso, geralmente abandono o padrão de fábrica e, em vez disso, recorro ao padrão de construtor - geralmente com uma sintaxe mais fluente.

Este exemplo é um pouco forçado, pois a classe que constrói não é muito complexa, mas espero que você possa entender a ideia e ver como isso facilitaria tarefas de construção mais complexas.

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 então você usa como

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

E, é claro, geralmente, quando me refiro ao padrão do construtor, não são apenas duas polegadas, ele pode conter listas de alguns objetos de valor ou dicionários de algum tipo, talvez de outras coisas mais cabeludas. No entanto, também é bastante útil se você tiver muitas combinações de parâmetros opcionais.

As principais dicas para seguir esse caminho são:

  • Seu objetivo principal é poder abstrair a construção do objeto para que o usuário externo não precise conhecer o seu sistema de eventos.
  • Não é tão importante onde ou quem registra o evento de criação, o importante é que ele seja registrado e que você pode garantir que - além disso, é um detalhe interno da implementação. Faça o que melhor se adapta ao seu código, não siga o meu exemplo, como algo do tipo "este é o caminho certo".
  • Se você quiser, dessa maneira, suas fábricas / repositórios retornarão interfaces em vez de classes concretas - facilitando a simulação de testes de unidade!
  • Às vezes, esse código é muito extra, o que faz com que muitas pessoas se esquivem dele. Mas muitas vezes é um código bastante fácil em comparação com as alternativas e fornece valor quando você mais cedo ou mais tarde precisa mudar as coisas. Há uma razão para Eric Evans falar sobre fábricas / repositórios em seu livro DDD como partes importantes do DDD - elas são abstrações necessárias para não vazar certos detalhes de implementação para o usuário. E abstrações com vazamentos são ruins.

Espero que ajude um pouco mais, basta pedir esclarecimentos nos comentários caso contrário :)


+1, nunca pensei nas fábricas como uma possível solução de design. Porém, uma coisa: parece que as fábricas essencialmente ocupam o mesmo lugar na interface pública que os construtores agregados (+ talvez um Initializemétodo) teriam ocupado. Isso me leva à pergunta: como seria sua fábrica?
stakx

3

Na minha opinião, acho que a resposta é mais parecida com a sua sugestão nº 2 para agregados existentes; para novos agregados mais parecidos com o nº 3 (mas processando o evento conforme sugerido).

Aqui está algum código, espero que seja de alguma ajuda.

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

Bem, o primeiro evento deve ser que um cliente crie um carrinho de compras ; portanto, quando o carrinho de compras for criado, você já terá um ID de cliente como parte do evento.

Se um estado mantém entre dois eventos diferentes, por definição, é um estado válido. Portanto, se você diz que um carrinho de compras válido está associado a um cliente, isso significa que você precisa ter as informações do cliente ao criar o próprio carrinho


Minha pergunta não é sobre como modelar o domínio; Estou usando deliberadamente um modelo de domínio simples (mas talvez imperfeito) para fazer uma pergunta. Dê uma olhada no CreatedEmptyShoppingCartevento na minha pergunta: ele contém as informações do cliente, como você sugere. Minha pergunta é mais sobre como esse evento se relaciona ou compete com o construtor de classes ShoppingCartdurante a implementação.
stakx

11
Eu entendo melhor a pergunta. Então, eu diria que a resposta correta deve ser a terceira. Eu acho que suas entidades sempre devem estar em um estado válido. Se isso exigir que um carrinho de compras tenha um ID de cliente, isso deve ser fornecido no momento da criação, daí a necessidade de um construtor que aceite um ID de cliente. Estou mais acostumado a CQRS em Scala, onde os objetos de domínio seriam representados por classes de casos, que são imutáveis e têm construtores para todas as combinações possíveis de parâmetros, assim eu dava a este para concedido
Andrea

0

Nota: se você não quiser usar raiz agregada, "entidade" abrange a maior parte do que você está perguntando aqui, além de abordar as preocupações sobre os limites da transação.

Aqui está outra maneira de pensar sobre isso: uma entidade é identidade + estado. Ao contrário de um valor, uma entidade é a mesma pessoa, mesmo quando seu estado muda.

Mas o próprio estado pode ser pensado como um objeto de valor. Com isso quero dizer, o estado é imutável; o histórico da entidade é uma transição de um estado imutável para o próximo - cada transição corresponde a um evento no fluxo de eventos.

State nextState = currentState.onEvent(e);

O método onEvent () é uma consulta, é claro - currentState não é alterado, em vez disso, currentState está calculando os argumentos usados ​​para criar o nextState.

Seguindo esse modelo, todas as instâncias do carrinho de compras podem ser consideradas como iniciando no mesmo valor inicial ....

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

ShoppingCart cart = new ShoppingCart(id, currentState);

Separação de preocupações - o ShoppingCart processa um comando para descobrir qual evento deve ser o próximo; o estado do ShoppingCart sabe como chegar ao próximo estado.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.