Перейти к основному содержимому

Сохраняющиеся агрегаты DDD

· 9 мин. чтения

1. Обзор

В этом руководстве мы рассмотрим возможности сохранения агрегатов DDD с использованием различных технологий.

2. Введение в агрегаты

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

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

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

Давайте посмотрим, как мы можем применить это при моделировании покупки заказа.

2.1. Пример заказа на поставку

Итак, предположим, что мы хотим смоделировать заказ на покупку:

class Order {
private Collection<OrderLine> orderLines;
private Money totalCost;
// ...
}
class OrderLine {
private Product product;
private int quantity;
// ...
}
class Product {
private Money price;
// ...
}

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

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

Посмотрим, что может пойти не так.

2.2. Наивный совокупный дизайн

Давайте представим, что могло бы произойти, если бы мы решили наивно добавить геттеры и сеттеры ко всем свойствам класса Order , включая setOrderTotal .

Ничто не мешает нам выполнить следующий код:

Order order = new Order();
order.setOrderLines(Arrays.asList(orderLine0, orderLine1));
order.setTotalCost(Money.zero(CurrencyUnit.USD)); // this doesn't look good...

В этом коде мы вручную устанавливаем свойство totalCost равным нулю, нарушая важное бизнес-правило. Определенно, общая стоимость не должна быть ноль долларов!

Нам нужен способ защитить наши бизнес-правила. Давайте посмотрим, как могут помочь Aggregate Roots.

2.3. Совокупный корень

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

Корень — это то, что заботится обо всех наших бизнес-инвариантах .

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

class Order {
private final List<OrderLine> orderLines;
private Money totalCost;

Order(List<OrderLine> orderLines) {
checkNotNull(orderLines);
if (orderLines.isEmpty()) {
throw new IllegalArgumentException("Order must have at least one order line item");
}
this.orderLines = new ArrayList<>(orderLines);
totalCost = calculateTotalCost();
}

void addLineItem(OrderLine orderLine) {
checkNotNull(orderLine);
orderLines.add(orderLine);
totalCost = totalCost.plus(orderLine.cost());
}

void removeLineItem(int line) {
OrderLine removedLine = orderLines.remove(line);
totalCost = totalCost.minus(removedLine.cost());
}

Money totalCost() {
return totalCost;
}

// ...
}

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

Как мы видим, это довольно простой агрегат.

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

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

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

3. JPA и спящий режим

В этом разделе давайте попробуем сохранить наш агрегат Order , используя JPA и Hibernate. Мы будем использовать Spring Boot и стартер JPA :

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

Для большинства из нас это кажется наиболее естественным выбором. В конце концов, мы потратили годы на работу с реляционными системами, и все мы знаем популярные ORM-фреймворки.

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

@DisplayName("given order with two line items, when persist, then order is saved")
@Test
public void test() throws Exception {
// given
JpaOrder order = prepareTestOrderWithTwoLineItems();

// when
JpaOrder savedOrder = repository.save(order);

// then
JpaOrder foundOrder = repository.findById(savedOrder.getId())
.get();
assertThat(foundOrder.getOrderLines()).hasSize(2);
}

В этот момент этот тест выдаст исключение: java.lang.IllegalArgumentException: Unknown entity: com.foreach.ddd.order.Order . Очевидно, нам не хватает некоторых требований JPA:

  1. Добавьте аннотации сопоставления
  2. Классы OrderLine и Product должны быть сущностями или классами @Embeddable , а не простыми объектами-значениями .
  3. Добавьте пустой конструктор для каждой сущности или класса @Embeddable.
  4. Замените свойства Money простыми типами

Хм, нам нужно изменить дизайн агрегата Order , чтобы иметь возможность использовать JPA. Хотя добавление аннотаций не имеет большого значения, другие требования могут создать много проблем.

3.1. Изменения в объектах-значениях

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

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

Вместо этого можно использовать аннотации @Embedded и @ElementCollection , но этот подход может сильно усложнить ситуацию при использовании сложного графа объектов (например , объект @Embeddable , имеющий другое свойство @Embedded и т. д .).

Использование аннотации @Embedded просто добавляет плоские свойства в родительскую таблицу. За исключением того, что базовые свойства (например, типа String ) по-прежнему требуют метода установки, который нарушает структуру объекта желаемого значения.

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

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

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

3.2. Сложные типы

К сожалению, мы не можем ожидать, что JPA автоматически сопоставит сторонние сложные типы с таблицами. Только посмотрите, сколько изменений нам пришлось внести в предыдущем разделе!

Например, при работе с нашим агрегатом Order мы столкнемся с трудностями при сохранении полей Joda Money .

В таком случае мы могли бы написать собственный тип @Converter, доступный в JPA 2.1. Однако для этого может потребоваться дополнительная работа.

В качестве альтернативы мы также можем разделить свойство Money на два основных свойства. Например , String для денежной единицы и BigDecimal для фактического значения.

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

3.3. Вывод

Хотя JPA является одной из самых популярных спецификаций в мире, возможно, это не лучший вариант для сохранения нашего агрегата заказов .

Если мы хотим, чтобы наша модель отражала истинные бизнес-правила, мы должны спроектировать ее так, чтобы она не была простым представлением базовых таблиц 1:1.

По сути, у нас есть три варианта:

  1. Создайте набор простых классов данных и используйте их для сохранения и воссоздания многофункциональной бизнес-модели. К сожалению, это может потребовать много дополнительной работы.
  2. Примите ограничения JPA и выберите правильный компромисс.
  3. Рассмотрим другую технологию.

Первый вариант имеет наибольший потенциал. На практике большинство проектов разрабатывается по второму варианту.

Теперь давайте рассмотрим другую технологию сохранения агрегатов.

4. Магазин документов

Хранилище документов — это альтернативный способ хранения данных. Вместо использования отношений и таблиц мы сохраняем целые объекты. Это делает хранилище документов потенциально идеальным кандидатом для сохраняемых агрегатов .

Для нужд этого руководства мы сосредоточимся на документах, подобных JSON.

Давайте подробнее рассмотрим, как наша проблема сохранения порядка выглядит в хранилище документов, таком как MongoDB.

4.1. Сохранение агрегата с использованием MongoDB

В настоящее время существует довольно много баз данных, которые могут хранить данные JSON, одна из популярных — MongoDB. MongoDB фактически хранит BSON или JSON в двоичной форме.

Благодаря MongoDB мы можем хранить совокупность примера Order как есть .

Прежде чем двигаться дальше, давайте добавим стартер Spring Boot MongoDB :

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>

Теперь мы можем запустить аналогичный тестовый пример, как в примере с JPA, но на этот раз с использованием MongoDB:

@DisplayName("given order with two line items, when persist using mongo repository, then order is saved")
@Test
void test() throws Exception {
// given
Order order = prepareTestOrderWithTwoLineItems();

// when
repo.save(order);

// then
List<Order> foundOrders = repo.findAll();
assertThat(foundOrders).hasSize(1);
List<OrderLine> foundOrderLines = foundOrders.iterator()
.next()
.getOrderLines();
assertThat(foundOrderLines).hasSize(2);
assertThat(foundOrderLines).containsOnlyElementsOf(order.getOrderLines());
}

Что важно — мы вообще не меняли исходные классы агрегатов Order ; нет необходимости создавать конструкторы по умолчанию, сеттеры или настраиваемый конвертер для класса Money .

А вот так выглядит наш агрегат Order в магазине:

{
"_id": ObjectId("5bd8535c81c04529f54acd14"),
"orderLines": [
{
"product": {
"price": {
"money": {
"currency": {
"code": "USD",
"numericCode": 840,
"decimalPlaces": 2
},
"amount": "10.00"
}
}
},
"quantity": 2
},
{
"product": {
"price": {
"money": {
"currency": {
"code": "USD",
"numericCode": 840,
"decimalPlaces": 2
},
"amount": "5.00"
}
}
},
"quantity": 10
}
],
"totalCost": {
"money": {
"currency": {
"code": "USD",
"numericCode": 840,
"decimalPlaces": 2
},
"amount": "70.00"
}
},
"_class": "com.foreach.ddd.order.mongo.Order"
}

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

Обратите внимание, что сложные объекты в документе BSON просто сериализуются как набор обычных свойств JSON. Благодаря этому даже сторонние классы (например, Joda Money ) можно легко сериализовать без необходимости упрощения модели.

4.2. Вывод

Сохранять агрегаты с помощью MongoDB проще, чем с помощью JPA.

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

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

5. Вывод

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

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

Хранилища документов могут упростить сохранение агрегатов без ущерба для сложности модели.

Полный исходный код всех примеров доступен на GitHub .