Страницы

Поиск по вопросам

воскресенье, 1 декабря 2019 г.

Агрегат должен знать и основывать свое поведение только на своем состоянии?

#шаблоны_проектирования #архитектура #проектирование #ddd #моделирование


Может ли агрегат использовать в своем поведении(методах) состояние других агрегатов?
Стоит ли давать агрегату ссылки на другие агрегаты, службы с доступом к другим агрегатам
или агрегат должен знать и основывать свое поведение только на своем состоянии?

Есть ли у DDD сообщества четкое понимание по паттерну "Агрегат" по данному вопросу?
Или данный вопрос не касается DDD? С другой стороны, я к примеру слышал досточно однозначное
мнение сообщества, что внедрять репозиторий в агрегат не стоит. Но стоит ли внедрнять
другие агрегаты и службы?

Где граница между тем, чтобы операция по смыслу принаджедащая агрегату не должна
выходить на уровень служб(анемичная модель), и между тем, чтобы уменьшить зависимость
и связанность агрегатов.

Если агрегат основывается только на своем состоянии и внешних зависимостей у него
не должно быть, то нужно делать слой из доменных служб?
Как называть такие доменные службы?

У меня есть пример. Я намеренно выкинул все лишнее, чтобы максимально упростить.
И оставил только одну операцию.
В нем я реализовал такой подход:
1. Агрегаты независимы и удовлетворяют инварианты только на основе своего состояния.
2. Логика принадлежащая предметной области, но взаимодействуюшая с другими агрегатами
и службами вынесена в доменные службы. 

Описание примера:
Это контекст аукциона. Здесь есть Lot(Лот), Bid(Ставка на конерктный лот), Bidder(Участник
аукциона - ставит ставки), Timer(Таймеры, которые ставятся либо перезапускаются для
отсчета перед победой). Операция которая тут рассматривается это "сделать ставку",
и вот что происходит у меня:


Application слой получает от клиента необходимые данные, получает на основе этих
данных нужные агрегаты, службы, репозитории. Далее вызывает доменную службу (#Domain
Service# BidMaker -> makeBid) и передает этой службе все необходимое.
Метод доменной службы (#Domain Service# BidMaker -> makeBid) 

а) Вызывает метод агрегата (#AR# Lot -> makeBid), он в свою очередь проверяет инварианты,
далее создает ставку(Bid) и кладет ее к себе в bidIds

б) Проверяет существуют ли уже таймеры у (Lot)Лота, если нет запускает новые или
перезапускает старые с помощью доменной службы - WinnerTimer.

в) Если таймеры новые сохраняет их с помощью репозитория

г) Вызывает метод агрегата (#AR# Lot -> restartTimers), он в свою очередь кладет
таймеры к себе в winnerTimerId и winnerTimerBefore2HoursId.


У меня получилось дублирование названия методов доменной службы и агрегата. И по
логике все же операция "сделать ставку" принадлежит агрегату Lot(Лота). Но тогда нужно
перенести логику из доменной службы BidMaker в агрегат, а это означает, что в том числе
нужно будет внедрить в агрегат репозиторий таймеров и службу таймеров.

Хотелось бы услышать мнения - чтобы вы изменили в данном примере и почему? А также
мнения по первому и главному вопросу. 
Заранее всех благодарю за ответы.

#Domain Service# 
BidMaker{
  void makeBid(
    WinnerTimer winnerTimer,
    TimerRepository timerRepository,
    int amount,
    Bidder bidder,
    Lot lot,
  ){
    //call lot.makeBid
    //check Lot's timers and put new or restart existing through WinnerTimer
    //save timers if they new through TimerRepository
    //call lot.restartTimers
  }
}

#Domain Service# 
WinnerTimer{
  Timer putWinnerTimer(){}
  Timer putWinnerTimerBefore2Hours(){}
  void restartWinnerTimer(Timer timerForRestart){}
  void restartWinnerTimerBefore2Hours(Timer timerForRestart){}
}


#AR# 
Lot{

  Lot{
    LotId id;
    BidId[] bidIds;
    TimerId winnerTimerId;
    TimerId winnerTimerBefore2HoursId;

    void makeBid(
      int amount,
      BidderId bidder,
      TimerId winnerTimerId,
      TimerId winnerTimerBefore2HoursId
    ){
      //check business invariants of #AR# within the boundaries
      //make Bid and add to bidIds
    }

    void restartTimers(TimerId winnerTimerId, TimerId winnerTimerBefore2Hours){
      //restart timers
    }
  }

  Bid{
    BidId id;
    int amount;
    BidderId bidderId;
  }

}

#AR# 
Timer{
  Timer{
    TimerId id;
    Date: date;
  }
}

#AR# 
Bidder{
  Bidder{
    BidderId: id;
  }
}

    


Ответы

Ответ 1



У меня получилось дублирование названия методов доменной службы и агрегата. И по логике все же операция "сделать ставку" принадлежит агрегату Lot(Лота). Но тогда нужно перенести логику из доменной службы BidMaker в агрегат, а это означает, что в том числе нужно будет внедрить в агрегат репозиторий таймеров и службу таймеров. Ваше решение вполне корректное. Можно сказать, что операция "сделать ставку" реализована на двух уровнях детализации, поэтому, по сути, это две разные операции с одинаковым названием. Может быть имеет смысл уточнить терминологию. Далее напишу про взаимосвязь Агрегатов, Репозиториев и Служб, надеюсь, это поможет уложить в голове некоторые аспекты. Если бы оперативная память не теряла своего содержимого при перезагрузке, если бы она была большой и могла хранить всю базу целиком, то Агрегатов и Репозиториев в DDD не было бы вовсе. Мы могли бы взять объект предметной области и по ссылкам выйти на любой другой объект. Но оперативная память ненадёжна и невелика, поэтому мы вынуждены хранить объекты в базах данных. Как правило, все наши сущности соответствуют таблицам в базе данных. Проблема заключается в том, что уровень хранения зачастую слишком детален для предметной области: загрузив Лот мы отдельно должны загрузить Ставки, но если Лот и Ставки всегда нужны вместе, мы постоянно будем писать лишний код. Сюда накладываются проблемы атомарности операций: между двумя загрузками может случиться какая-нибудь вставка, и чтобы гарантировать согласованность, нам в явном виде нужно привносить в бизнес-логику транзакции. Агрегаты как раз решают эту проблему. Заметив, что некоторые сущности почти всегда нужны вместе, мы их объединяем. На практике, одна из сущностей становится главной, так называем Корнем агрегата, а остальные сущности доступны как вложенные свойства. Например, Ставки могут быть коллекцией сущностей типа Bid. Все сущности Агрегата загружаются из базы данных целиком в рамках одной транзакции, и точно также, все сущности Агрегата обновляются в базе в рамках одной транзакции. В соответствии с принципом единственной ответственности (SRP), Агрегат решает свою основную задачу в рамках предметной области, загрузка и обновление Агрегата должна быть вынесена в отдельный класс. Этот класс — Репозиторий. Очень важный момент заключается в том, как Репозитории взаимосвязаны с Сущностями и Агрегатами. Репозиторий знает про все сущности Агрегата: он создаёт их при загрузке из базы, а также извлекает из них данные для сохранения в базе. Но вот сам Агрегат про Репозиторий знать не должен. Обсудим эту часть подробнее. С Агрегатами у нас возникает проблема определения границ этих самых Агрегатов. Например, Участники делают Ставки, но Участники довольно часто возникают в сценариях и без Ставок. Это означает, что Участники – отдельный Агрегат. Но если это отдельный Агрегат у него должен быть свой Репозиторий и загружаться он тоже должен отдельно. Как же быть, если нам нужен в рамках одного сценария и Лот со Ставками, и Участник? Здесь нам на помощью приходят Службы. Чем они отличаются от Агрегатов и как мы можем понять, что является ответственностью Службы, а что — ответственностью Агрегата? Важнейшим отличием является наличие состояния, у Агрегата оно есть, у Службы нет. По сути, любая операция с Агрегатом является операцией чтения или изменения состояния. А вот Службы представляют процессы, поэтому каждая операция сервиса обычно соответствует шагу процесса. Наконец, Агрегат управляет только своими сущностями, а Служба управляет Агрегатами. Теперь мы готовы объединить всё вместе. Агрегаты не могут содержать друг друга, вместо этого ссылаются друг на друга через идентификаторы. Например, Лот содержит Ставки, и каждая Ставка хранит Идентификатор Участника. Загрузку и сохранение Агрегатов осуществляют Репозитории. Здесь тонкий момент, про который надо помнить: Агрегатор ничего не должен знать про базы данных, а Репозиторий — про бизнес-логику. Службы выполняют бизнес-процессы. Они используют Репозитории чтобы загружать Агрегаты, выполняют отдельные операции Агрегатов, и снова используют Репозитории, чтобы сохранить новые состояния Агрегатов. Если в вашем коде вас волнует то, что методы одинаково называются в Службе и Агрегате, поговорите с экспертами предметной области, уточните процесс и термины. Речь здесь идёт о сложной составной операции, поэтому вполне может быть, для отдельных этапов есть свои обозначения и названия. Скажем, может быть явно как-то обозначаются новые Ставки без запущенных Таймеров. Тогда метод Агрегата Лот должен называться не makeBid, а как-то по другому, что-то вроде initiateBid.

Ответ 2



Ответ участника VoiceOfUnreason. С англоязычного stackoverflow: агрегат должен знать и основывать свое поведение только на своем состоянии? На своем состоянии + переданные аргументы, во время команды агрегату - измени свое состояние. Тк агрегаты описывают границу согласованности, не стоит пытаться объединить вместе два состояния разных агрегатов. Но нет ничего неправильного в том, чтобы обновлять один агрегат используя снимок(snapshot) другого агрегата как аргумент. Лично я обычно не использую сам агрегат для этого. Я думаю метод с двумя агрегатами может сбить с толку, тк нет прозрачного понимания - какой конкретно агрегат сейчас меняется. Но read-only агрегат вполне подходит для этого. Лучшее решение может быть использование доменной службы как посредника. Поэтому методы которые изменяют состояние Лота - никогда не смогут изменить состояние Таймера, и никогда не будут взаимодействовать с Таймером напрямую. Но Лот может спросить службу предметной области о состоянии Таймера, передав ей id Таймер, который интересен.

Ответ 3



Итак, агрегат. Он блюдёт своё собственное состояние и обеспечивает его консистентность. Для этого требуется сложная (как правило) бизнес-логика: проверка инвариантов, валидация и прочее. Представим себе, что мы хотим эту логику протестировать. Какие у нас тут варианты? Тестировать удобнее всего чистые функции. Потому что результат выполнения чистой функции детерминирован. Поэтому ОЧЕНЬ УДОБНО, когда команды агрегата реализованы в виде чистой функции. Функция на вход получает текущее состояние агрегата и команду, возвращает (выберите по вкусу) (новое состояние агрегата | сообщение об ошибке) или (событие об изменении состояния агрегата | сообщение об ошибке). Это легко протестировать, но что делать с зависимостями? Для того, чтобы логика агрегата была чистой, у него не должно быть зависимостей. То есть, все зависимости необходимо разрешить до отправки команды агрегату и предоставить ему все необходимые данные. Я не знаю, как правильно назвать часть кода, ответственную за получение данных от зависимостей. Можно назвать domain service. Можно use case. В моём коде это сложено в package под названием businesslogic. Идея в том, что там в одном куске кода описан целиком use case, и его выполнение увенчивается отправкой команды агрегату. Далее, в коде модели предметной области также присутствуют package-и downstream (интерфейсы взаимодействия с теми, кто от меня зависит - они делегируют выполнение методам юз-кейсов) и upstream (интерфейсы взаимодействия с теми, от кого завишу я, они передаются юз-кейсам в качестве аргументов конструктора). (Также может помочь знакомство с концепцией Data-Context-Interaction от Trygve Reenskaug). Таким образом, экземпляр use-case-а - вот кто нуждается в зависимостях. Но интерфейсы этих зависимостей я имплементирую в другом модуле, в модуле адаптеров. А в модуле, содержащем предметную область, я их только объявляю. Чтобы предметная область не зависела от реализаций ни downstream, ни upstream адаптеров. Может быть небольшая путаница в терминологии, но у себя я Application-ом называю ту часть кода, в которой связываются три части: конфигурация, предметная область и интерфейсы адаптеров с конкретными имплементациями. В частности, там создаются экземпляры юз-кейсов (часть предметной области) с переданными их конструкторам адаптерами. Далее, клиентский код у application-а берёт эти экземпляры юз-кейсов и вызывает их методы. Юз-кейсы, используя upstream-адаптеры, получают необходимые данные и с этими данными формируют команды для агрегатов. Когда агрегаты выполняют свою задачу, юз-кейсы, снова используя адаптеры, выполняют запись в БД/на диск или отправку в MQ и т.д. Если мы потом договоримся о терминологии - я поправлю ответ. Сейчас получается. так: Application создаёт экземпляры адаптеров и юз-кейсов, внедряя в них экземпляры адаптеров. Клиентский запрос парсится в downstream-адаптере таким образом, чтобы оказаться выраженным в через value-object-ы предметной области. Если не получилось распарсить - возвращаем bad request, даже не доходя до отработки юз-кейса. Если получилось - вызываем метод юз-кейса makeBid. В рамках этого метода используем адаптеры и получаем текущее значение таймера, необходимые атрибуты Bidder-а, агрегат Lot в текущем состоянии. Формируем команду для Lot-а и отправляем ему. Он отрабатывает свою чистую логику и возвращает новое состояние, или событие, или ошибку. Юз-кейс дёргает адаптеры снова и сохраняет успешный ответ от Lot-a. Результат возвращается обратно downstream-адаптеру, где формируется ответ клиенту. Итог: агрегаты содержат чистую логику, которую мы можем удобно покрыть тестами. Юз-кейс содержит полную картину взаимодействия с upstream-адаптерами и выражает порадок и зависимости операций друг от друга. То есть, координирует их. Здесь нет формул, нет инвариантов - есть только порядок, последовательность, зависимости. Соответственно, только это и тестируем. Адаптеры сфокусированы каждый на своём отдельном канале взаимодействия с внешним миром. Со стороны домена у адаптеров статические структуры данных, чтобы сосредоточить тестирование только на взаимодействии с этим внешним миром. Application просто связывает всё это + конфигурацию друг с другом. Непонятно, что там тестировать, как и надо ли. Комментируйте, если я что-то непонятно изложил.

Комментариев нет:

Отправить комментарий