Pull to refresh

Domain-Driven Design: тактическое проектирование. Часть 2

Reading time16 min
Views84K


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

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

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

Традиционно, начиная с книги Э.Эванса, первым рассматривается шаблон DDD – сущность.

Сущность (Entity)


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

Есть несколько стратегий создания идентификаторов:

Ввод пользователем уникального значения

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

Идентификатор генерируется приложением

Существуют быстрые и высоконадежные генераторы, которые можно использовать в приложениях для автоматического создания уникального идентификатора. Например, в среде Java существует класс java.util.UUID, который позволяет генерировать так называемый универсально уникальный идентификатор (universally unique identifier) четырьмя различными способами (time-based, DCE security, name-based, randomly generated UUIDs).

Примером такого UUID является: 046b6c7f-0b8a-43b9-b35d-6489e6daee91. То есть 36 байтовая строка.

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

Для примера можно рассмотреть такой идентификатор: APM-P-08-14-2016-046B6C7F.

Здесь: АPM – отдельный контекст управления проектированием; P – проект; 08-14-2016 – дата создания; 046B6C7F- – первый сегмент UUID. Когда такие идентификаторы попадаются в различных ограниченных контекстах, разработчики сразу видят, откуда они появились.

Идентификатор генерируется механизмом постоянного хранения

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

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

Идентификаторы, которые присваиваются другими ограниченными контекстами

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

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

Для сущности, обычно, кроме основного идентификатора предметной области, используется суррогатный идентификатор. Первый подчиняется требованиям предметной области, а второй предназначен уже для самого инструмента ORM (как Hibernate). Для создания суррогатного ключа обычно создается атрибут сущности типа long или int, в базе данных создается уникальный идентификатор, и он используется как первичный ключ. Потом включается отображение этого ключа в атрибут с помощью инструментов ORM. Такой суррогатный идентификатор обычно скрывают от внешнего мира, так как он не является частью модели предметной области.

Еще важно сказать, что для сохранения уникальности на протяжении существования объекта, его идентификатор необходимо защитить от модификации. Это делается в основном путем скрытия методов-установщиков идентификаторов, либо созданием проверок изменения состояния в методах-установщиках для запрещения таких изменений. Для примера можно рассмотреть ту же систему PFM, что и в предыдущей статье.

Для начала необходимо выделить сущности в предметной области. Совершенно очевидно, что существует сущность BankingAccount, и для ее идентификации напрашивается использование номера счета accountNumber. Тем не менее этот номер уникальный только в отдельном банке и может повторятся в других банках. (Можно использовать номер IBAN, но этот номер в основном используется только в банках Европейского союза.) То есть помимо номера счета еще можно использовать сегмент UUID. Тогда наш идентификатор будет состоять из PFM-A-424214343245-046b6c7f, где:

  • PFM – имя контекста
  • A – Account
  • 424214343245 – номер счета accountnumber
  • 046b6c7f – часть из UUID


Идентификатор можно задать как объект-значение. Необходимо детально рассмотреть этот очень важный шаблон DDD.

Объект-Значение (Value Object)


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

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

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

Очень редко объекты-значения делаются изменяемыми. Чтобы запретить доступ к полям, обычно методы установщики (setters) делают приватными, а публичным делают конструктор объекта, в который передаются все объекты, которые являются атрибутами значения. Создание объекта-значения должно быть атомарной операцией.

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

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

Рассмотрим классический пример объекта значения денежная сумма (во многих примерах в интернете встречается этот класс):

public class Money implements Serializable {
private BigDecimal amount;
private String currency;
public Money (BigDecimal anAmount, String aCurrency) {
    this.setAmount(anAmount);
    this.setCurrency(aCurrency);
}
…
}

Методы установщики здесь делаются скрытыми, создание объекта значения – атомарная операция. Примером конкретного значения есть {50 000 долларов}. По отдельности эти атрибуты либо описывают что-то другое, либо ничего конкретного не означают. Особенно это относится к числу 50000 и в некоторой степени к долларам. То есть эти атрибуты образует концептуально целое значение, которое описывает денежную сумму. Такая целостность концепции в предметной области играет очень большую роль. Важно понимать, что именуются типы значений и сами значения в соответствии с единым языком в своем ограниченном контексте.

Давайте перейдем к следующему важному шаблону тактического моделирования.

Служба Предметной Области (Domain Service)


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

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

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

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


Событие (Domain Event)


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

  • «Когда… »
  • «Если это случится… »
  • «Сообщите мне, если… » или «уведомьте меня, если… »
  • «В случае… »

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

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

Для примера можно рассмотреть событие FundsDeposited:


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

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

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

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

Таким образом, достаточно создать объект DomainEventPublisher, который будет хранить, регистрировать всех подписчиков и публиковать событие. При этом публикация для подписчиков будет идти синхронно в отдельном цикле и в одной транзакции. И каждый подписчик сможет отработать событие отдельно.

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

Существует много компонентов для передачи сообщений, которые относятся к классу промежуточного программного обеспечения (например, RabbitMQ, NServiceBus). Можно также сделать обмен сообщениями с помощью ресурсов REST, где автономные системы обращаются к издательской системе, требуя уведомления о событиях, которые еще не были обработаны.

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

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

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

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

Давайте рассмотрим следующий шаблон DDD.

Модуль (Module)


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

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

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

com.bankingsystems

Следующий сегмент имени модуля идентифицирует ограниченный контекст. Желательно, чтобы имя этого сегмента основывалось на имени ограниченного контекста:

com.bankingsystes.pfm

Далее следует квалификатор, который идентифицирует модуль именно предметной области:

com.bankingsystems.pfm.domain

Все модули модели можно поместить именно в этом разделе domain. Вот так:

com.bankingsystems.pfm.domain.account
  <<Entity>>BankingAccount
  <<ValueObject>>AccountId

В известной всем многоуровневой архитектуре именование было бы таким:

сom.bankingsystems.resources
сom.bankingsystems.resources.viewуровень пользовательского интерфейса (хранение представлений)

сom.bankingsystems.application
сom.bankingsystems.application.accountприкладной уровень (подмодуль прикладных сервисов)

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

Агрегат (Aggregate)


Агрегат является самым сложным из всех тактических инструментов DDD. Агрегатом называется кластер из объектов сущностей или значений. То есть эти объекты рассматриваются как единое целое с точки зрения изменения данных.

У каждого агрегата есть корень (Aggregate Root) и граница, внутри которой всегда должны быть удовлетворены инварианты.

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

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

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

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

Каждый агрегат может хранить ссылку как корни других агрегатов. При этом это не помещает этот агрегат в границы согласованности первого агрегата. Ссылка не порождает целостный агрегат.

В рамках одной транзакции в ограниченном контексте должно происходить изменение только одного агрегата.

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

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

В качестве примера агрегата можно привести кредитный отчет:


Каждый кредитный отчет должен содержать данные об идентификационных данных заемщика. Для этого мы сохраняем внешнюю связь по идентификатору сustomerId. Сustomer – совершенно другой агрегат, который содержит информацию о заемщике (имя, адрес, контакты). Инвариантом, допустим, будет правило пересчета кредитного рейтинга – в зависимости от данных кредитной истории.

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

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

При удалении агрегата отчета, должны удаляться все значения – записи об истории и запросах.

Фабрика (Factory)


Шаблон фабрика является более известным, чем другие.

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

Лучше выполнять создание сложных агрегатов или других объектов отдельно. Для этого используются фабрики. Фабрики – элементы программы, обязанности которого создавать другие объекты.

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

При создании фабричного метода в корне агрегата обязательно необходимо соблюдать все инварианты агрегата, создавая его как единое целое. Этот метод должен быть един и неделим. Все данные для создания (обычно только объекты-значения) должны быть переданы за одну коммуникационную операцию. Детали конструкции скрываются.

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

Хранилища (Repository)


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

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

Есть два типа проектов хранилищ:

  1. Ориентированные на имитацию коллекций;
  2. Ориентированные на механизм постоянного хранения.

Хранилища, ориентированные на имитацию коллекций, очень точно имитируют коллекцию, моделируя по крайней мере часть ее интерфейса. Сам интерфейс хранилища, в этом случае, не выдает существования механизма постоянного хранения, тем самым представляя традиционный, исходный шаблон DDD.

Представить такое хранилище можно как HashMap<ObjectId,Object>. В этой коллекции исключается повторная вставка одного и того же элемента. При этом, получая и изменяя объект, изменения фиксируются сразу.

Хоть клиенту и не нужно вмешиваться в работу механизма постоянного хранения, для его правильной работы необходимо неявно отслеживать изменения в объектах. Для этого есть два способа:

  1. Неявное копирование при чтении (implicity copy-on-read) (механизм постоянного хранения копирует каждый раз объект хранения при чтении из базы данных и сравнивает закрытую копию с клиентской при фиксации транзакции);
  2. Неявное копирование при записи (механизм управляет загруженными объектами с помощью прокси-объекта).

Такой механизм, как Hibernate, позволяет создавать хранилище, ориентированное на имитацию коллекции.

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

В случае с хранилищем, ориентированным на механизм постоянного хранения, не нужно отслеживать изменения в объектах, а необходимо каждый раз после изменений в агрегате фиксировать изменения методом save() или аналогичным. Например:


Реализация хранилища использует методы и объекты механизма. При это реализация находится на инфраструктурном уровне, а интерфейс объявляется в домене.

Здесь используется первый тип хранилища, ориентированный на имитацию коллекции. В нем необязательно реализовать метод save() или put(). Только методы коллекции.

Таким образом доступ к базе данных или к другому механизму инкапсулируется в хранилище. Клиент будет очень простым и не будет зависеть от конкретной реализации хранилища.

Вывод


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

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

Надеемся, что статья помогла вам продвинуться в понимании шаблонов DDD! Если есть вопросы, пишите в комментариях. С радостью ответим. И спасибо за внимание.

Статью подготовили: greebn9k (Сергей Грибняк), wa1one (Владимир Ковальчук), silmarilion (Андрей Хахарев).
Tags:
Hubs:
Total votes 16: ↑15 and ↓1+14
Comments27

Articles