Де об'єкт в CQRS + ES повинен бути повністю ініціалізований: в конструкторі або при застосуванні першої події?


9

У спільноті OOP, як видається, існує широка думка, що конструктор класів не повинен залишати об'єкт частково або навіть повністю неініціалізованим.

Що я маю на увазі під "ініціалізацією"? Грубо кажучи, атомний процес, який приводить новостворений об'єкт у стан, у якому тримаються всі його класові інваріанти. Це має бути першим, що трапляється з об'єктом, (він повинен запускатися лише один раз на об'єкт), і нічого не повинно бути дозволено, щоб дістатись неініціалізованого об'єкта. (Таким чином, часті поради щодо виконання права ініціалізації об'єкта в конструкторі класу. З тієї ж причини Initializeметоди часто нахмурюються, оскільки вони розбивають атомність і дають можливість отримати і використовувати об'єкт, який ще не існує. у чітко визначеному стані.)

Проблема: Коли CQRS поєднується з джерелом подій (CQRS + ES), де всі зміни стану об'єкта потрапляють у впорядковану серію подій (потік подій), мені залишається цікаво, коли об’єкт насправді досягає повністю ініціалізованого стану: Наприкінці конструктора класу чи після того, як до об'єкта було застосовано першу подію?

Примітка. Я утримуюсь від використання терміна "сукупний корінь". Якщо ви віддаєте перевагу, замінюйте його щоразу, коли читаєте "об'єкт".

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

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

Припустимо, що існує два типи об'єктів Customerі ShoppingCart. Давайте зупинимось на наступному ShoppingCart: Коли створені, кошики для покупки порожні, їх потрібно асоціювати саме з одним клієнтом. Цей останній біт - інваріант класу: ShoppingCartОб'єкт, який не асоційований з a, 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())?
  • Чи повинен конструктор отримувати потік події та відтворювати його (що робить ініціалізацію знову атомною, але означає, що кожен конструктор класу містить однакову загальну логіку «відтворення та застосування», тобто небажане дублювання коду)?
  • Чи повинен бути як традиційний конструктор OOP (як показано вище), який належним чином ініціалізує об'єкт, і тоді всі події, окрім першої, - void Apply(…)це до нього?

Я не сподіваюсь на відповідь, щоб забезпечити повністю працюючу демо-версію; Я вже буду дуже радий, якби хтось міг пояснити, де моє міркування хибно, чи ініціалізація об'єкта насправді є "больовою точкою" у більшості реалізацій CQRS + ES.

Відповіді:


3

Роблячи CQRS + ES, я волію взагалі не мати публічних конструкторів. Створення моїх сукупних коренів слід проводити через завод (для досить простих конструкцій, таких як цей) або конструктор (для більш складних корінних коренів).

Як тоді насправді ініціалізувати об’єкт - це деталь реалізації. Повідомлення OOP "Не використовуй ініціалізувати" має велике значення щодо публічних інтерфейсів . Не варто сподіватися, що кожен, хто використовує ваш код, знає, що він повинен викликати SecretInitializeMethod42 (bool, int, string) - це поганий дизайн публічного API. Однак якщо ваш клас не пропонує жодного публічного конструктора, а натомість є ShoppingCartFactory з методом CreateNewShoppingCart (string), то реалізація цієї фабрики може дуже добре приховати будь-яку магію ініціалізації / конструктора, яку користувачеві потім не потрібно знати about (надаючи приємний публічний 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;
    }
}

Звичайно, саме в цьому випадку ви залежати від того, як ви розділите створення FooCreateEvent. Можна також створити випадок конструктора FooAggregate (FooCreateEvent) або конструктора 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 - вони необхідні абстракції, щоб не просочуватися користувачеві певні деталі реалізації. І нещільні абстракції - це погано.

Сподіваюся, що допоможе трохи більше, просто попросіть роз'яснення в коментарях інакше :)


+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

Ну, першою подією повинно бути те, що клієнт створює кошик для покупок , тому коли створено кошик, у вас вже є ідентифікатор клієнта як частина події.

Якщо стан має місце між двома різними подіями, за визначенням це дійсний стан. Отже, якщо ви говорите, що дійсний кошик пов’язаний із клієнтом, це означає, що вам потрібно мати інформацію про клієнта під час створення кошика


Моє питання не в тому, як моделювати домен; Я навмисно використовую просту (але, можливо, недосконалу) модель домену для того, щоб задати питання. Погляньте на CreatedEmptyShoppingCartподію в моєму запитанні: вона містить інформацію про клієнта, як і ви пропонуєте. Моє питання більше стосується того, як така подія стосується або конкурує з цим конструктором класів ShoppingCartпід час впровадження.
stakx

1
Я краще розумію питання. Отже, я б сказав, правильна відповідь повинна бути третьою. Я думаю, що ваші суб'єкти завжди повинні бути у дійсному стані. Якщо для цього потрібно, щоб кошик мав ідентифікатор клієнта, він повинен бути наданий під час створення, отже, необхідність конструктора, який приймає ідентифікатор клієнта. Я більше звик до CQRS в Scala, де доменні об'єкти будуть представлені класами випадків, які є незмінними і мають конструктори для всіх можливих комбінацій параметрів, тому я давав це як належне
Андреа,

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 знає, як дістатися до наступного стану.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.