Эта статья является конспектом материала Effective Aggregate Design Part II: Making Aggregates Work Together.

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

Мы можем захотеть использовать композиционную структуру во время проектирования агрегатов. Такая структура позволит нам обходить глубокие графы объектов. Однако это не должно быть целью. DDD утверждает, что один агрегат может содержать ссылки на корень других агрегатов. Но это не значит то, что агрегаты входят в границу согласованности другого агрегата, который на них ссылается. Ссылка не приводит к образованию единого агрегата.

Рис. 1. Изображено два агрегата, а не один.
Рис. 1. Изображено два агрегата, а не один.

На Java это выглядело бы следующим образом:

public class BacklogItem extends ConcurrencySafeEntity {
  ...
  private Product product;
  ...
}

BacklogItem содержит прямую связь с объектом Product.

Это имеет несколько последствий:

  • BacklogItem и Product не должны вместе изменяться в рамках одной транзакции, а только один из них.

  • Если изменяются несколько экземпляров агрегата в одной транзакции, это может свидетельствовать о нарушении границ согласованности и, следовательно, о ее неверности.

  • Если все-таки необходимо изменять несколько экземпляров агрегата за один запрос, это может указывать на необходимость использования конечной согласованности, а не атомарной (транзакционной).

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

Ссылайтесь на другие агрегаты по идентификатору

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

Рис. 2. BacklogItem содержит связи с другими агрегатами за пределами своей границы с помощью идентификаторов.
Рис. 2. BacklogItem содержит связи с другими агрегатами за пределами своей границы с помощью идентификаторов.
public class BacklogItem extends ConcurrencySafeEntity {
  ...
  private ProductId productId;
  ...
}

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

Модель навигации

Ссылки по идентификатору полностью не исключают доступ к другим агрегатам. Можно использовать репозиторий изнутри агрегата для поиска. Такой метод называется автономной доменной моделью (disconnected domain model). Однако существуют другие рекомендуемые подходы. Используйте репозиторий или доменную службу для поиска зависимых объектов снаружи агрегата, то есть, например, в службах уровня приложения (application service).

public class ProductBacklogItemService ... {
    ...
    @Transactional
    public void assignTeamMemberToTask( String aTenantId,
        String aBacklogItemId, String aTaskId,
        String aTeamMemberId) {

        BacklogItem backlogItem = backlogItemRepository.backlogItemOfId(
          new TenantId(aTenantId),
          new BacklogItemId(aBacklogItemId)
        );

        Team ofTeam =
        		teamRepository.teamOfId( backlogItem.tenantId(), backlogItem.teamId());

        backlogItem.assignTeamMemberToTask(
          new TeamMemberId(aTeamMemberId), ofTeam,
          new TaskId(aTaskId)
        );
      }
      ...
}

Наличие такого сервиса освобождает агрегат от зависимости репозитория или доменной службы. Однако, наличие ссылок на несколько агрегатов в рамках одного запроса не дает разрешение на изменение двух или более из них.

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

Если все эти советы приводят к менее удобной модели, подумайте о преимуществах, которые она дает. Уменьшение размеров агрегатов приводит к повышению производительности, можно добавить масштабируемость и распределение.

Поскольку всегда существует несколько ограниченных контекстов, ссылки на идентификаторы позволяют распределенным доменным моделям иметь ассоциации. Когда используется подход Event-Driven, события домена, содержащие идентификатор агрегата, рассылаются в виде сообщений. Подписчики этих сообщений во внешних ограниченных контекстах используют идентификаторы для выполнения операций в собственных доменных моделях. Транзакции между распределенными системами не являются атомарными. В итоге различные системы приводят несколько агрегатов в согласованное состояние.

Используйте конечную согласованность за пределами границ

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

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

На практике конечная согласованность в модели DDD поддерживается следующим образов: во время выполнения команды для изменения данных агрегата публикуется доменное событие, которое доставляется одному или нескольким асинхронным подписчикам.

public class BacklogItem extends ConcurrencySafeEntity {
  ...
  public void commitTo(Sprint aSprint) {
    ...
    DomainEventPublisher
    	.instance()
    	.publish(
      		new BacklogItemCommitted( 
            this.tenantId(), 
            this.backlogItemId(), 
            this.sprintId()
          )
    	);
  }
  ...
}

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

Что произойдет, если подписчик столкнется с проблемой, которая приведет к сбою его транзакции? Модификацию можно повторить, если подписчик не подтвердил успешное выполнение операции. Механизм обмена сообщениями доставит повторно сообщение. Будет запущена новая транзакция и попытка выполнения необходимой команды. Этот процесс может продолжаться до тех пор, пока не будет достигнута согласованность, или пока не будет достигнут предел повторных попыток. Если произойдет полный отказ, может потребоваться компенсация, или, как минимум, сообщение об отказе.

В некоторых сценариях предметной области сложно определить, следует ли использовать транзакционную или конечную согласованность. Те, кто использует DDD классическим способом, могут склоняться к транзакционной согласованности. Те, кто используют CQRS, могут склоняться к конечной согласованности. Но что правильно в итоге? По правде говоря, ни один из этих подходов не дает конкретного ответа, только техническое предпочтение. Есть ли лучше способ решить эту проблему?

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

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

Причины нарушения правил

Опытный специалист по DDD может иногда решить изменить несколько экземпляров агрегатов в одной транзакции, но только по уважительной причине. Какие же могут быть причины?

Причина первая: удобство пользовательского интерфейса

Иногда пользовательский интерфейс, для удобства, позволяет пользователю изменять одновременно характеристики многих вещей одновременно. Например, создания сразу множества элементов бэклога. Пользовательский интерфейс позволяет заполнить общие свойства в одном разделе, а затем отдельно для каждого несколько отличающихся свойств. Затем все элементы бэклога сразу сохраняются.

public class ProductBacklogItemService ... {
    ...
    @Transactional
    public void planBatchOfProductBacklogItems( 
      String aTenantId, String productId, 
      BacklogItemDescription[] aDescriptions) {

        Product product = productRepository.productOfId(
          new TenantId(aTenantId), new ProductId(productId)
        );

        for (BacklogItemDescription desc : aDescriptions) { 
          BacklogItem plannedBacklogItem = product.planBacklogItem( 
            desc.summary(), desc.category(), 
            BacklogItemType.valueOf(desc.backlogItemType()), 
            StoryPoints.valueOf(desc.storyPoints())
          );

            backlogItemRepository.add(plannedBacklogItem);
        }
    }
    ...
}

Это вызывает проблемы с управлением инвариантов? В данном случае нет, поскольку не имеет значения, были ли они созданы по одному или сразу все вместе. Создаваемые экземпляры объектов являются агрегатами, которые сами поддерживают собственные инварианты. Таким образом, если создание нескольких экземпляров агрегата за один раз семантически ничем не отличается от многократного создания одного за раз, это является первой причиной нарушения правил.

Однако Уди Дахан рекомендует избегать создание сервисов приложения для пакетной обработки данных. Более подробно можно прочесть в оригинале.

Причина вторая: отсутствие технических инструментов

Конечная согласованность требует дополнительные инструменты для внепроцессорной обработки, таких как обмен сообщениями или таймеры. Что делать, если проект, над которым вы работаете, не предусматривает такого механизма? Хотя большинство из нас сочло бы это странным, но автор оригинала сталкивался именно с таким ограничением. Без механизма обмена сообщениями, без таймеров и другого, что можно было бы сделать?

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

Автор упомянул еще один фактор, который способствует нарушению правил - user-aggregate affinity. Я не до конца понял, о чем идет речь, поэтому не стал добавлять его в конспект. Если интересно, можно посмотреть в оригинале.

Причина третья: глобальные транзакции

Другая рассматриваемая причина – это влияние устаревших технологий и корпоративной политики. Такое влияние может привести к необходимости использования глобальной двухфазной фиксации транзакций. Это одна из ситуаций, от которой невозможно отказаться, по крайней мере, в краткосрочной перспективе.

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

Причина четвертая: производительность запросов

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

Вывод

Вы можете столкнуться с различными проблемами (дизайн пользовательского интерфейса, технические ограничения, корпоративная политика и др.), которые потребуют от вас некоторых компромиссов. Однако в итоге соблюдения практических правил проектирования агрегатов принесет пользу проекту. Там, где это необходимо, обеспечим согласованность и будем поддерживать оптимальную производительность и масштабируемость системы.

Ссылки на все части