«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. Корень агрегата

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

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

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;
    }

    // ...
}

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

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

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

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

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

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

3. JPA и Hibernate

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

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

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

@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);
}

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

  1. Add mapping annotations
  2. OrderLine and Product classes must be entities or @Embeddable classes, not simple value objects
  3. Add an empty constructor for each entity or @Embeddable class
  4. Replace Money properties with simple types

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

Хм, нам нужно изменить дизайн агрегата 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. Create a set of simple data classes and use them to persist and recreate the rich business model. Unfortunately, this might require a lot of extra work.
  2. Accept the limitations of JPA and choose the right compromise.
  3. Consider another technology.

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

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

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

4. Хранилище документов

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

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

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

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

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

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

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

Прежде чем двигаться дальше, давайте добавим стартер Spring Boot 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());
}

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

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

{
  "_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.baeldung.ddd.order.mongo.Order"
}

«А вот то, что наш агрегат Order появляется в магазине:

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

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

4.2. Заключение

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

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

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

5. Заключение

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

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

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