DDD 집계 유지

1. 개요

이 자습서에서는 다양한 기술을 사용하여 DDD Aggregates를 지속 할 수있는 가능성을 탐색합니다.

2. 집계 소개

집계는 항상 일관성이 있어야하는 비즈니스 개체 그룹입니다 . 따라서 트랜잭션 내에서 집계를 전체적으로 저장하고 업데이트합니다.

Aggregate는 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; // ... }

이러한 클래스는 간단한 집계를 형성합니다 . 두 orderLinesTOTALCOST의 의 필드 순서는 항상 일치해야, 즉 TOTALCOST는 항상 모든의 합과 동일 값을 가져야한다 orderLines .

이제 우리 모두는이 모든 것을 완전한 Java Bean으로 바꾸고 싶은 유혹을받을 수 있습니다. 그러나 Order에 간단한 getter 및 setter를 도입 하면 모델의 캡슐화를 쉽게 깨고 비즈니스 제약을 위반할 수 있습니다.

무엇이 잘못 될 수 있는지 봅시다.

2.2. 순진한 집계 디자인

setOrderTotal을 포함 하여 Order 클래스의 모든 속성에 게터와 세터를 순진하게 추가하기로 결정하면 어떤 일이 발생할 수 있는지 상상해 봅시다 .

다음 코드를 실행하는 것을 금지하는 것은 없습니다.

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

이 코드에서는 totalCost 속성을 수동으로 0 으로 설정하여 중요한 비즈니스 규칙을 위반합니다. 확실히 총 비용은 0 달러가되어서는 안됩니다!

비즈니스 규칙을 보호 할 방법이 필요합니다. Aggregate Roots가 어떻게 도움이되는지 살펴 보겠습니다.

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

집계 루트를 사용하면 이제 ProductOrderLine 을 모든 속성이 최종인 불변 객체로 쉽게 전환 할 수 있습니다 .

보시다시피 이것은 매우 간단한 집계입니다.

그리고 필드를 사용하지 않고 매번 총 비용을 간단히 계산할 수 있습니다.

그러나 지금은 집계 디자인이 아닌 집계 지속성에 대해 이야기하고 있습니다. 이 특정 도메인이 곧 도움이 될 것이므로 계속 지켜봐 주시기 바랍니다.

이것이 지속성 기술과 얼마나 잘 어울리는가? 한 번 보자. 궁극적으로 이는 다음 프로젝트에 적합한 지속성 도구를 선택하는 데 도움이 될 것 입니다.

3. JPA 및 최대 절전 모드

이 섹션에서는 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 : Unknown entity : com.baeldung.ddd.order.Order . 분명히 JPA 요구 사항 중 일부가 누락되었습니다.

  1. 매핑 주석 추가
  2. OrderLineProduct 클래스는 단순한 값 개체가 아닌 엔터티 또는 @Embeddable 클래스 여야 합니다.
  3. 각 엔터티 또는 @Embeddable 클래스 에 대해 빈 생성자를 추가합니다.
  4. Money 속성을 간단한 형식으로 바꾸기

음, JPA를 사용할 수 있도록 Order Aggregate 의 디자인을 수정해야합니다 . 주석을 추가하는 것은 큰 문제는 아니지만 다른 요구 사항으로 인해 많은 문제가 발생할 수 있습니다.

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에서 사용할 수 있습니다.