はじめに
DDDにおける重要概念で集約があります。
この記事では、成瀬さんさんが書かれたドメイン駆動設計入門から学んだ「集約」についてまとめてきます。
集約とは
集約とは変更の単位です。
この言葉が示す通り、集約は「ただオブジェクトの集まり」ではなく、
「データを変更するために、一緒に変更されるオブジェクトの集まり」
になります。
身近な例で集約を考えてみます。
ECサイトの注文では、次のような操作が行われます。
- 商品をカートに追加する
- 商品を削除する
- 注文を確定する
このとき、「注文」と「注文明細(商品)」は一緒に変更されます。
「注文明細の値段を単独で変更する」や「注文が完了したけど、その注文に後から商品を追加する」
という操作は、あってはなりません。
注文と注文明細は、変更のタイミング・整合性を共有している
この関係こそが、集約になります。
集約のルール
集約にはルールがあり、
- 外部からの集約に対する操作は集約ルート(Aggregate Root) を通して行われる
- 集約は整合性を守る責任を持つ
集約ルートとは、集約の外部から内部への操作を行うための窓口です。
外部のコードが、集約内部のエンティティや値を直接操作することで、集約で守られるべき不変条件を常に維持することが難しくなります。
外部のコードが、集約ルートを経由して、集約に対する操作を行うことで、集約内の不変条件を維持できるようにします。
集約が整合性を守る責任を持つとは、「正しい状態を維持する主体が集約自身である」 ということです。
「この操作を行ってよいのか」や「今の状態でこの変更は許されるのか」という判断を集約内部にもつことにより、ルールがコード上に分散することや不整合のリスクを低下させることができます。
集約が自ら整合性を守ることで、
- どこから呼ばれても同じルールが適用される
- ビジネスルールがコードとして集約に集まる
- 集約の外から不正な状態を作れなくなる
という設計になります。
集約の例
注文(Order)の集約を例に考えていきます。
注文の要件として、
- 1注文には1つ以上の注文明細(商品)が存在する
- 注文が完了したら、注文明細(商品)の追加ができない
が存在します。
この Order クラスは、注文に関する変更をすべて引き受ける集約ルートです。
商品の追加・削除・注文完了といった操作は、
すべて Order 経由で行われます。
OrderItem は外部から直接操作されることはなく、
Order の内部状態としてのみ存在しています。
まずは注文に追加する注文明細クラスを作成してみます。
// 注文明細(商品)クラス
public record OrderItem(
String id,
String productName,
int price
) {}
次に集約ルートのOrder クラスを作成してみます。
import lombok.RequiredArgsConstructor;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
// 集約ルート
@RequiredArgsConstructor
public class Order {
private final String id;
private final List<OrderItem> items = new ArrayList<>();
private Status status = Status.PENDING;
public String id() {
return id;
}
public List<OrderItem> items() {
return List.copyOf(items);
}
public Status status() {
return status;
}
// PENDING のときだけ追加 OK
public void addItem(String productName, int price) {
ensurePending();
String itemId = UUID.randomUUID().toString();
items.add(new OrderItem(itemId, productName, price));
}
// PENDING のときだけ削除 OK
public void removeItem(String itemId) {
ensurePending();
boolean removed = items.removeIf(item -> item.id().equals(itemId));
if (!removed) {
throw new IllegalArgumentException("指定された商品IDは注文に存在しません: " + itemId);
}
}
public int totalPrice() {
return items.stream()
.mapToInt(OrderItem::price)
.sum();
}
// 1つ以上の商品が必要
public void complete() {
if (items.isEmpty()) {
throw new IllegalStateException("注文を完了するには1つ以上の商品が必要です");
}
status = Status.COMPLETED;
}
private void ensurePending() {
if (status != Status.PENDING) {
throw new IllegalStateException("PENDING の注文のみ変更できます(現在: " + status + ")");
}
}
public enum Status {
PENDING,
COMPLETED
}
}
上記のコードにおいて、次のようなビジネスルールはOrderの中で保証されています。
- 注文が完了したら商品を追加・削除できない
- 注文を完了するには1つ以上の商品が必要
private void ensurePending() {
if (status != Status.PENDING) {
throw new IllegalStateException("PENDING の注文のみ変更できます");
}
}
これにより、
- 呼び出し側がルールを意識しなくてよい
- どこから呼ばれても必ず整合性が保たれる
という状態を作ることができます。
この設計では、「注文明細の変更」や「注文状態の変更」は、すべてOrder集約の中だけで完結します。
注文に関する仕様変更は、この集約を修正すればよい
という明確な変更単位が定義されています。
これが「集約とは変更の単位である」ということです。
終わりに
集約はDDDにおいて、極めて重要な概念です。
集約とは、データを変更するための単位として扱われるオブジェクトの集まり
集約によって、
- 整合性ルールが1箇所に集約される
- 変更の影響範囲が明確になる
- 「どこを修正すればよいか」が分かりやすくなる
といったメリットが得られます。
また、集約は「大きく作るもの」ではありません。
「一緒に変更されるものは何か?」という視点で、小さく設計することが重要です。