1. 概要
このチュートリアルでは、さまざまなテクノロジーを使用して DDDAggregatesを永続化する可能性を探ります。
2. 集合体の紹介
アグリゲートは、常に一貫している必要があるビジネスオブジェクトのグループです。 したがって、トランザクション内で集計全体を保存および更新します。
Aggregateは、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;
// ...
}
これらのクラスは単純な集合体を形成します。 OrderのorderLinesフィールドとtotalCostフィールドは常に一貫している必要があります。つまり、totalCostの値は常にすべてのorderLines。
これで、これらすべてを本格的なJava Beanに変換したくなるかもしれません。ただし、 Order に単純なゲッターとセッターを導入すると、モデルのカプセル化が簡単に破られる可能性があることに注意してください。ビジネス上の制約に違反します。
何がうまくいかないか見てみましょう。
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 プロパティを手動でゼロに設定し、重要なビジネスルールに違反しています。 間違いなく、総費用はゼロドルであってはなりません!
ビジネスルールを保護する方法が必要です。 AggregateRootsがどのように役立つかを見てみましょう。
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とHibernate
このセクションでは、JPAとHibernateを使用してOrderアグリゲートを永続化してみましょう。 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:不明なエンティティ:com.baeldung.ddd.order.Order。 明らかに、JPA要件の一部が欠落しています:
- マッピング注釈を追加する
- OrderLineおよびProductクラスは、単純な値オブジェクトではなく、エンティティまたは@Embeddableクラスである必要があります
- 各エンティティまたは@Embeddableクラスに空のコンストラクターを追加します
- Moneyプロパティを単純なタイプに置き換えます
うーん、JPAを使用できるようにするには、Orderアグリゲートの設計を変更する必要があります。 注釈を追加することは大したことではありませんが、他の要件は多くの問題を引き起こす可能性があります。
3.1. 値オブジェクトへの変更
集合体をJPAに適合させようとする最初の問題は、値オブジェクトの設計を破る必要があることです。それらのプロパティはもはや最終的なものではなく、カプセル化を破る必要があります。
これらのクラスが識別子を持つように設計されていない場合でも、OrderLineとProductに人工IDを追加する必要があります。 それらを単純な値オブジェクトにしたかったのです。
代わりに@Embeddedおよび@ElementCollectionアノテーションを使用することは可能ですが、このアプローチは、複雑なオブジェクトグラフ(たとえば、 @Embeddable オブジェクト)を使用する場合に非常に複雑になる可能性があります。別の@Embeddedプロパティなどがあります)。
@Embedded アノテーションを使用すると、親テーブルにフラットプロパティが追加されるだけです。 それを除いて、基本的なプロパティ(例: String タイプの)でもsetterメソッドが必要であり、これは目的の値オブジェクトの設計に違反します。
空のコンストラクター要件により、値オブジェクトのプロパティが最終的なものではなくなり、元の設計の重要な側面が損なわれます。 正直なところ、Hibernateはプライベートの引数なしコンストラクターを使用できます。これにより問題が少し軽減されますが、それでも完全にはほど遠い状態です。
プライベートのデフォルトコンストラクターを使用している場合でも、プロパティをfinalとしてマークできないか、デフォルトコンストラクター内のデフォルト(多くの場合null)値でプロパティを初期化する必要があります。
ただし、JPAに完全に準拠する場合は、デフォルトのコンストラクターに少なくとも保護された可視性を使用する必要があります。つまり、同じパッケージ内の他のクラスは、プロパティの値を指定せずに値オブジェクトを作成できます。
3.2. 複雑なタイプ
残念ながら、JPAがサードパーティの複合型をテーブルに自動的にマップすることは期待できません。 前のセクションで導入しなければならなかった変更の数を確認してください。
たとえば、 Order アグリゲートを操作する場合、 JodaMoneyフィールドを保持するのが困難になります。
このような場合、JPA2.1から入手できるカスタムタイプ@Converterを記述してしまう可能性があります。 ただし、追加の作業が必要になる場合があります。
または、Moneyプロパティを2つの基本プロパティに分割することもできます。 たとえば、通貨単位の場合は String 、実際の値の場合はBigDecimalです。
実装の詳細を非表示にして、パブリックメソッドAPIを介して Money クラスを使用することはできますが、ほとんどの開発者は余分な作業を正当化できず、代わりにJPA仕様に準拠するようにモデルを縮退するだけです。
3.3. 結論
JPAは世界で最も採用されている仕様の1つですが、Orderアグリゲートを永続化するための最良のオプションではない可能性があります。
モデルに真のビジネスルールを反映させたい場合は、基礎となるテーブルの単純な1:1表現ではないようにモデルを設計する必要があります。
基本的に、ここには3つのオプションがあります。
- 一連の単純なデータクラスを作成し、それらを使用して、豊富なビジネスモデルを永続化および再作成します。 残念ながら、これには多くの追加作業が必要になる場合があります。
- JPAの制限を受け入れ、適切な妥協点を選択してください。
- 別のテクノロジーを検討してください。
最初のオプションは最大の可能性を秘めています。 実際には、ほとんどのプロジェクトは2番目のオプションを使用して開発されます。
次に、集計を永続化する別のテクノロジーについて考えてみましょう。
4. ドキュメントストア
ドキュメントストアは、データを保存する別の方法です。 リレーションとテーブルを使用する代わりに、オブジェクト全体を保存します。 これにより、ドキュメントストアは集約を永続化するための潜在的に完璧な候補になります。
このチュートリアルのニーズのために、JSONのようなドキュメントに焦点を当てます。
MongoDBのようなドキュメントストアで注文の永続性の問題がどのように見えるかを詳しく見てみましょう。
4.1. MongoDBを使用した集計の永続化
現在、JSONデータを保存できるデータベースがかなりあります。人気のあるものの1つはMongoDBです。 MongoDBは、実際にはBSONまたはJSONをバイナリ形式で保存します。
MongoDBのおかげで、Orderサンプルaggregateをそのままで保存できます。
先に進む前に、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.baeldung.ddd.order.mongo.Order"
}
この単純なBSONドキュメントには、 Order の集計全体が1つのピースに含まれており、これらすべてが共同で一貫している必要があるという当初の概念とうまく一致しています。
BSONドキュメント内の複雑なオブジェクトは、通常のJSONプロパティのセットとして単純にシリアル化されることに注意してください。 このおかげで、サードパーティのクラス( Joda Money など)でも、モデルを単純化することなく簡単にシリアル化できます。
4.2. 結論
MongoDBを使用した集約の永続化は、JPAを使用するよりも簡単です。
これは、MongoDBが従来のデータベースより優れていることを絶対に意味するものではありません。クラスを集計としてモデル化し、代わりにSQLデータベースを使用するべきではない正当なケースがたくさんあります。
それでも、複雑な要件に従って常に一貫している必要があるオブジェクトのグループを特定した場合、ドキュメントストアを使用することは非常に魅力的なオプションになる可能性があります。
5. 結論
DDDでは、アグリゲートには通常、システム内で最も複雑なオブジェクトが含まれています。 それらを操作するには、ほとんどのCRUDアプリケーションとは非常に異なるアプローチが必要です。
一般的なORMソリューションを使用すると、単純化された、または過度に公開されたドメインモデルにつながる可能性があり、複雑なビジネスルールを表現または適用できないことがよくあります。
ドキュメントストアを使用すると、モデルの複雑さを犠牲にすることなく、集計を簡単に永続化できます。
すべての例の完全なソースコードは、GitHubでから入手できます。