Персистиращи DDD агрегати

1. Общ преглед

В този урок ще проучим възможностите за продължаване на DDD агрегатите с помощта на различни технологии.

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

Агрегатът е група бизнес обекти, които винаги трябва да бъдат последователни . Следователно ние запазваме и актуализираме агрегатите като цяло в рамките на транзакция.

Агрегатът е важен тактически модел в DDD, който помага да се поддържа последователността на нашите бизнес обекти. Идеята за агрегат обаче е полезна и извън контекста на DDD.

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

Нека да разгледаме как бихме могли да приложим това, когато моделираме покупка на поръчка.

2.1. Пример за поръчка за покупка

И така, да приемем, че искаме да моделираме поръчка за покупка:

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

Тези класове образуват прост агрегат . И полетата orderLines и totalCost на Поръчката трябва да бъдат винаги последователни, т.е. totalCost винаги трябва да имат стойността, равна на сумата от всички orderLines .

Сега всички ние може да се изкушим да превърнем всичко това в пълноценни Java Beans. Но имайте предвид, че въвеждането на прости гетери и сетери в ред може лесно да наруши капсулирането на нашия модел и да наруши бизнес ограниченията.

Да видим какво може да се обърка.

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 на нула, нарушавайки важно бизнес правило. Определено общите разходи не трябва да са нула долара!

Имаме нужда от начин да защитим нашите бизнес правила. Нека да разгледаме как агрегираните корени могат да помогнат.

2.3. Агрегиран корен

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

Коренът е това, което се грижи за всички наши бизнес инварианти .

И в нашия пример класът Order е правилният кандидат за обобщения корен. Просто трябва да направим някои модификации, за да гарантираме, че агрегатът винаги е последователен:

class Order { private final List orderLines; private Money totalCost; Order(List 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 и Hibernate

В този раздел, нека се опитаме да запазим нашата съвкупност от поръчки, използвайки JPA и Hibernate. Ще използваме Spring Boot и JPA стартер:

 org.springframework.boot spring-boot-starter-data-jpa 

За повечето от нас това изглежда най-естественият избор. В края на краищата прекарахме години в работа с релационни системи и всички познаваме популярните ORM рамки.

Вероятно най-големият проблем при работа с 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); }

В този момент този тест би хвърлил изключение: java.lang.IllegalArgumentException: Неизвестен обект: com.baeldung.ddd.order.Order . Очевидно липсват някои от изискванията на JPA:

  1. Добавете пояснения за картографиране
  2. Класовете OrderLine и Product трябва да бъдат обекти или класове @Embeddable , а не прости обекти със стойност
  3. Добавете празен конструктор за всеки обект или клас @Embeddable
  4. Заменете свойствата на Money с прости типове

Хм, трябва да модифицираме дизайна на агрегат за поръчки, за да можем да използваме JPA. Въпреки че добавянето на пояснения не е голяма работа, другите изисквания могат да създадат много проблеми.

3.1. Промени в стойностните обекти

The first issue of trying to fit an aggregate into JPA is that we need to break the design of our value objects: Their properties can no longer be final, and we need to break encapsulation.

We need to add artificial ids to the OrderLine and Product, even if these classes were never designed to have identifiers. We wanted them to be simple value objects.

It's possible to use @Embedded and @ElementCollection annotations instead, but this approach can complicate things a lot when using a complex object graph (for example @Embeddable object having another @Embedded property etc.).

Using @Embedded annotation simply adds flat properties to the parent table. Except that, basic properties (e.g. of String type) still require a setter method, which violates the desired value object design.

Empty constructor requirement forces the value object properties to not be final anymore, breaking an important aspect of our original design. Truth be told, Hibernate can use the private no-args constructor, which mitigates the problem a bit, but it's still far from being perfect.

Even when using a private default constructor, we either cannot mark our properties as final or we need to initialize them with default (often null) values inside the default constructor.

However, if we want to be fully JPA-compliant, we must use at least protected visibility for the default constructor, which means other classes in the same package can create value objects without specifying values of their properties.

3.2. Complex Types

Unfortunately, we cannot expect JPA to automatically map third-party complex types into tables. Just see how many changes we had to introduce in the previous section!

For example, when working with our Order aggregate, we'll encounter difficulties persisting Joda Money fields.

In such a case, we might end up with writing custom type @Converter available from JPA 2.1. That might require some additional work, though.

Alternatively, we can also split the Money property into two basic properties. For example String for currency unit and BigDecimal for the actual value.

While we can hide the implementation details and still use Money class through the public methods API, the practice shows most developers cannot justify the extra work and would simply degenerate the model to conform to the JPA specification instead.

3.3. Conclusion

While JPA is one of the most adopted specifications in the world, it might not be the best option for persisting our Order aggregate.

If we want our model to reflect the true business rules, we should design it to not be a simple 1:1 representation of the underlying tables.

Basically, we have three options here:

  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.

The first option has the biggest potential. In practice, most projects are developed using the second option.

Now, let's consider another technology to persist aggregates.

4. Document Store

A document store is an alternative way of storing data. Instead of using relations and tables, we save whole objects. This makes a document store a potentially perfect candidate for persisting aggregates.

For the needs of this tutorial, we'll focus on JSON-like documents.

Let's take a closer look at how our order persistence problem looks in a document store like MongoDB.

4.1. Persisting Aggregate Using MongoDB

Now, there are quite a few databases which can store JSON data, one of the popular being MongoDB. MongoDB actually stores BSON, or JSON in binary form.

Thanks to MongoDB, we can store the Order example aggregate as-is.

Before we move on, let's add the Spring Boot MongoDB starter:

 org.springframework.boot spring-boot-starter-data-mongodb 

Now we can run a similar test case like in the JPA example, but this time using 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 foundOrders = repo.findAll(); assertThat(foundOrders).hasSize(1); List foundOrderLines = foundOrders.iterator() .next() .getOrderLines(); assertThat(foundOrderLines).hasSize(2); assertThat(foundOrderLines).containsOnlyElementsOf(order.getOrderLines()); }

What's important – we didn't change the original Order aggregate classes at all; no need to create default constructors, setters or custom converter for Money class.

And here is what our Order aggregate appears in the store:

{ "_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" }

This simple BSON document contains the whole Order aggregate in one piece, matching nicely with our original notion that all this should be jointly consistent.

Note that complex objects in the BSON document are simply serialized as a set of regular JSON properties. Thanks to this, even third-party classes (like Joda Money) can be easily serialized without a need to simplify the model.

4.2. Conclusion

Persisting aggregates using MongoDB is simpler than using JPA.

This absolutely doesn't mean MongoDB is superior to traditional databases. There are plenty of legitimate cases in which we should not even try to model our classes as aggregates and use a SQL database instead.

И все пак, когато сме идентифицирали група обекти, които трябва да бъдат винаги последователни в съответствие със сложните изисквания, тогава използването на хранилище на документи може да бъде много привлекателна опция.

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

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

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

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

Пълният изходен код на всички примери е достъпен на GitHub.