Где должен быть полностью инициализирован объект в CQRS + ES: в конструкторе или при применении первого события?

9

Похоже, что в сообществе ООП широко распространено мнение, что конструктор класса не должен оставлять объект частично или даже полностью неинициализированным.

Что я имею в виду под «инициализацией»? Грубо говоря, атомарный процесс, который приводит вновь созданный объект в состояние, в котором содержатся все его классовые инварианты. Это должно быть первое, что происходит с объектом (он должен запускаться только один раз для каждого объекта), и ничто не должно иметь права завладеть неинициализированным объектом. (Таким образом, часто советуют выполнять инициализацию объекта прямо в конструкторе класса. По той же причине Initializeметоды часто не одобряются, поскольку они разбивают атомарность и позволяют получить и использовать объект, который еще не создан. в четко определенном состоянии.)

Проблема: Когда CQRS объединяется с источником событий (CQRS + ES), где все изменения состояния объекта попадают в упорядоченную серию событий (поток событий), мне остается только удивляться, когда объект фактически достигает полностью инициализированного состояния: В конце конструктора класса или после того, как к объекту было применено самое первое событие?

Примечание: я воздерживаюсь от использования термина «совокупный корень». Если вы предпочитаете, заменяйте его всякий раз, когда читаете «объект».

Пример для обсуждения: предположим, что каждый объект уникально идентифицируется по некоторому непрозрачному Idзначению (например, GUID). Поток событий, представляющий изменения состояния этого объекта, может быть идентифицирован в хранилище событий по тому же Idзначению: (Давайте не будем беспокоиться о правильном порядке событий.)

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

Предположим далее, что есть два типа объектов Customerи ShoppingCart. Давайте сосредоточимся на том, что ShoppingCart: корзины для покупок пусты и должны быть связаны только с одним клиентом. Этот последний бит является инвариантом класса: ShoppingCartобъект, который не связан с, Customerнаходится в недопустимом состоянии.

В традиционном ООП это можно смоделировать в конструкторе:

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

Однако я не знаю, как смоделировать это в CQRS + ES, не заканчивая отложенной инициализацией. Поскольку этот простой бит инициализации фактически является изменением состояния, не нужно ли его моделировать как событие ?:

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.

Очевидно, это должно быть самое первое событие в ShoppingCartпотоке событий любого объекта, и этот объект будет инициализирован только после того, как событие будет применено к нему.

Поэтому, если инициализация становится частью потока воспроизведения «Воспроизведение» (это очень общий процесс, который, вероятно, будет работать одинаково, будь то для Customerобъекта или ShoppingCartобъекта или любого другого типа объекта в этом отношении)…

  • Должен ли конструктор быть без параметров и ничего не делать, оставляя всю работу какому-либо void Apply(CreatedEmptyShoppingCart)методу (который почти такой же, как нахмурившийся Initialize())?
  • Или же конструктор должен принять поток событий и воспроизвести его (что делает инициализацию снова атомарной, но означает, что каждый конструктор класса содержит одну и ту же универсальную логику «воспроизведения и применения», то есть нежелательное дублирование кода)?
  • Или должен существовать традиционный конструктор ООП (как показано выше), который правильно инициализирует объект, а затем все события, кроме первого, void Apply(…)связаны с ним?

Я не ожидаю ответа, чтобы обеспечить полностью работающую демонстрационную реализацию; Я уже был бы очень рад, если бы кто-то мог объяснить, где мои рассуждения ошибочны, или действительно ли инициализация объекта является «болевым пунктом» в большинстве реализаций CQRS + ES.

stakx
источник

Ответы:

3

При выполнении CQRS + ES я предпочитаю вообще не иметь публичных конструкторов. Создание моих агрегатных корней должно производиться через фабрику (для достаточно простых конструкций, таких как эта) или конструктор (для более сложных агрегатных корней).

Как затем фактически инициализировать объект - это деталь реализации. ООП «Не использовать инициализацию» - это совет по открытым интерфейсам . Не следует ожидать, что любой, кто использует ваш код, знает, что он должен вызвать SecretInitializeMethod42 (bool, int, string) - это плохой дизайн публичного API. Однако, если ваш класс не предоставляет какого-либо общедоступного конструктора, а вместо этого есть ShoppingCartFactory с методом CreateNewShoppingCart (string), реализация этой фабрики может очень хорошо скрыть любой вид магии инициализации / конструктора, который ваш пользователь тогда не должен знать о (таким образом, предоставляя хороший публичный API, но позволяя вам создавать более сложные объекты за кулисами).

Фабрики получают плохую репутацию от людей, которые думают, что их слишком много, но при правильном использовании они могут спрятать много сложностей за хорошим и понятным общедоступным API. Не бойтесь использовать их, это мощный инструмент, который может помочь вам значительно упростить конструирование сложных объектов - при условии, что вы сможете жить с еще несколькими строками кода.

Это не гонка, чтобы увидеть, кто может решить проблему с наименьшим количеством строк кода, - это, тем не менее, постоянная конкуренция за то, кто может создать самый хороший публичный API! ;)

Изменить: добавив несколько примеров того, как может выглядеть применение этих шаблонов

Если у вас есть «простой» агрегатный конструктор, который имеет пару обязательных параметров, вы можете использовать простую фабричную реализацию, что-то вроде этого

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

Конечно, именно то, как вы делите создание FooCreatedEvent, в этом случае зависит от вас. Можно также привести пример наличия конструктора FooAggregate (FooCreatedEvent) или конструктора FooAggregate (int, int), который создает событие. То, как вы решите разделить ответственность, зависит от того, что вы считаете самым чистым, и от того, как вы реализовали регистрацию событий вашего домена. Я часто выбираю, чтобы фабрика создала событие, но это зависит от вас, поскольку создание события теперь является внутренней деталью реализации, которую вы можете изменить и изменить в любое время, не меняя внешний интерфейс. Важной деталью здесь является то, что агрегат не имеет общедоступного конструктора и что все установщики являются частными. Вы не хотите, чтобы кто-то использовал их внешне.

Этот шаблон отлично работает, когда вы просто более или менее заменяете конструкторы, но если у вас более продвинутая конструкция объектов, это может стать слишком сложным для использования. В этом случае я обычно отказываюсь от заводского шаблона и обращаюсь к шаблону компоновщика - часто с более свободным синтаксисом.

Этот пример немного сложен, так как класс, который он создает, не очень сложен, но можно надеяться, что вы поймете идею и увидите, как он облегчит более сложные задачи построения

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

И тогда вы используете это как

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

И, конечно же, обычно, когда я обращаюсь к шаблону построителя, это не просто два целых числа, он может содержать списки некоторых объектов-значений или словари некоторых, может быть, некоторых более волосатых вещей. Однако это также весьма полезно, если у вас есть много комбинаций необязательных параметров.

Важные выводы для такого пути:

  • Ваша основная цель - это возможность абстрагировать конструкцию объекта, чтобы внешний пользователь не знал о вашей системе событий.
  • Не так важно, где или кто регистрирует событие создания, важно то, что оно регистрируется, и вы можете гарантировать, что, кроме этого, это внутренняя деталь реализации. Делайте то, что лучше всего подходит вашему коду, не следуйте моему примеру, как что-то вроде «это правильный путь» - все.
  • Если вы хотите, поступив таким образом, вы можете заставить ваши фабрики / репозитории возвращать интерфейсы вместо конкретных классов - упрощая их для проверки на модульных тестах!
  • Иногда это лишний код, который заставляет многих уклоняться от него. Но это часто довольно простой код по сравнению с альтернативами, и он дает ценность, когда вам рано или поздно нужно что-то изменить. Есть причина, по которой Эрик Эванс говорит о фабриках / репозиториях в своей DDD-книге как о важных частях DDD - они являются необходимыми абстракциями, чтобы не пропускать определенные детали реализации для пользователя. И дырявые абстракции плохие.

Надеюсь, что это поможет немного больше, просто попросите разъяснения в комментариях в противном случае :)

wasatz
источник
+1, я даже не думал о фабриках как о возможном дизайнерском решении. Хотя одно: кажется, что фабрики по сути занимают то же место в общедоступном интерфейсе, которое Initializeзанимали бы конструкторы совокупности (+ возможно, метод). Это подводит меня к вопросу, на что может быть похожа такая ваша фабрика?
stakx
3

На мой взгляд, я думаю, что ответ больше похож на ваше предложение № 2 для существующих агрегатов; для новых агрегатов больше похоже на # 3 (но обрабатывает событие, как вы предложили).

Вот код, надеюсь, он поможет.

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

Что ж, первое событие должно состоять в том, что клиент создает корзину покупок , поэтому, когда корзина покупок создана, у вас уже есть идентификатор клиента как часть события.

Если состояние выполняется между двумя различными событиями, по определению это допустимое состояние. Таким образом, если вы говорите, что с покупателем связана действительная корзина, это означает, что вам нужно иметь информацию о покупателе при создании самой корзины.

Andrea
источник
Мой вопрос не о том, как смоделировать домен; Я намеренно использую простую (но, возможно, несовершенную) модель предметной области для того, чтобы задать вопрос. Взгляните на CreatedEmptyShoppingCartсобытие в моем вопросе: там есть информация о клиенте, как вы предлагаете. Мой вопрос больше о том, как такое событие связано или конкурирует с конструктором класса ShoppingCartво время реализации.
Stakx
1
Я лучше понимаю вопрос. Так что, я бы сказал, правильный ответ должен быть третьим. Я думаю, что ваши сущности всегда должны быть в действительном состоянии. Если для этого требуется, чтобы корзина покупок имела идентификатор клиента, то это должно быть предоставлено во время создания, следовательно, необходим конструктор, который принимает идентификатор клиента. Я больше привык к CQRS в Scala, где доменные объекты будут представлены классами case, которые являются неизменяемыми и имеют конструкторы для всех возможных комбинаций параметров, поэтому я считал это само собой разумеющимся
Andrea
0

Примечание: если вы не хотите использовать агрегатный корень, «сущность» охватывает большую часть того, о чем вы здесь спрашиваете, в то же время не обращая внимания на границы транзакций.

Вот еще один способ думать об этом: сущность - это личность + состояние. В отличие от значения, сущность - это тот же парень, даже когда его состояние меняется.

Но само государство можно рассматривать как объект стоимости. Я имею в виду, что государство неизменно; история объекта - это переход из одного неизменного состояния в другое - каждый переход соответствует событию в потоке событий.

State nextState = currentState.onEvent(e);

Конечно, метод onEvent () является запросом - currentState вообще не изменяется, вместо этого currentState вычисляет аргументы, используемые для создания nextState.

Следуя этой модели, можно считать, что все экземпляры корзины покупок начинаются с одинакового начального значения

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

ShoppingCart cart = new ShoppingCart(id, currentState);

Разделение проблем - ShoppingCart обрабатывает команду, чтобы выяснить, какое событие должно быть следующим; Штат ShoppingCart знает, как добраться до следующего штата.

VoiceOfUnreason
источник