はじめに
本記事では、ドメイン駆動設計のパターンとして用いられる集約について、初心者向けに解説したものです。
対象読者層
- ドメイン駆動設計初心者
- ドメイン駆動設計における、集約がわからない方
- ドメイン駆動設計における、エンティティの概念がわかる方
結論
- 集約とは、関連するオブジェクトのグループを1つの単位として扱う概念
- 集約は、オブジェクトの不変条件を守るうえで重要な概念
- 各集約には、集約ルートと呼ばれるエンティティが存在します
- オブジェクトの変更は必ず集約ルートを介して行う
集約は、オブジェクトの不変条件を維持するための概念
集約は、関連する複数のオブジェクトを1つの親オブジェクトにまとめる概念です。
これは、オブジェクトの変更を行う際の重要な概念です。
集約を意識することは、以下のメリットがあります。
- オブジェクトの不変条件を守る
- データの一貫性を保持する
メリットだけでは具体的な集約の概念を理解するのが難しいため、
ここから具体的な集約の解説に移ります。
集約は、関連するオブジェクトのグループを1つの単位として扱う
集約の代表例がエンティティです。
例として、注文票に関する集約例を見てみましょう。
以下は、注文票の簡単な仕様をクラス図に落とし込んだものです。
- 1つの注文票の中で、注文した商品が管理されている
- 商品の注文は、複数個可能
※簡潔さを重視して、各クラスのメソッドは省略しています。
注文票であるOrderクラスには、複数の注文商品(OederItemクラス)が集約としてまとめられています。
ここまではただのエンティティに見えますが、集約の要はオブジェクトの不変条件とデータの一貫性を保持するにあります。
ここからは、あえて集約ルールが守られていない悪い例で紹介します。
NG例:集約ルールが守られていないケース
例として、注文票に注文商品を追加するケースを見てみましょう。
// NG例:外部から直接データを操作する
order.items.add(new OrderItem("りんご",1,100));
// OK例:注文票を通してデータを操作する
order.addItems(new OrderItem("りんご",1,100));
NG例では、注文票に定義している注文商品一覧を直接操作しています。
これは、まさにオブジェクトの不変条件を侵す操作です。
仮に、1度に注文できる商品は10個までというビジネスルールがあると仮定します。
NG例の場合、10個を超えて商品を注文できてしまいます。
注文票を介して注文商品を追加すれば、このルールを守ることができます。
// 1回の注文で許可する最大商品数
private static final int MAX_ITEMS = 10;
// 注文商品一覧 (外部から直接アクセスできないようにprivate修飾子に変更)
private List<OrderItem> items = new ArrayList<>();
// 商品を追加するメソッド
public void addItems(OrderItem item) {
if (items.size() >= MAX_ITEMS) {
System.out.println("❌ これ以上商品を追加できません。(最大 " + MAX_ITEMS + " 個)");
return;
}
items.add(new OrderItem(productName, quantity, price));
}
これで、注文商品一覧が10個を超えることが無くなりました。
このように、集約内のオブジェクトを操作するときは親となるオブジェクトを通して行うことが重要です。
この親となるオブジェクトが、集約ルートと呼ばれています。
集約内部のオブジェクト操作は、必ず集約ルートで行う
先ほどのサンプルコードを例にすると、注文商品一覧を直接操作できるのは、集約ルートである注文票だけとなります。
集約ルートが責任を持って内部オブジェクトを操作することで、オブジェクトの不変条件とデータの一貫性保持に繋がります。
オブジェクト指向におけるカプセル化とは目的が異なる
ここまでの解説で、集約はカプセル化と似た概念に感じた方もいらっしゃると思います。
ですが、集約とカプセル化は目的が明確に異なります。
具体的には、以下の違いがあります。
- カプセル化 : オブジェクトの内部データを守る仕組み(クラスレベル)
- 集約 : 複数のオブジェクトを一つの単位として管理し、整合性を保証(ドメインレベル)
🔍 詳細な違い
集約(Aggregate) | カプセル化(Encapsulation) | |
---|---|---|
概念の範囲 | システム全体のデータ整合性を保つ設計単位 | 個々のオブジェクトの内部データを隠蔽 |
適用レイヤー | ドメインモデリング(DDD) | クラス設計(OOP) |
目的 | データの整合性を保証し、適切な境界を定義する | 内部実装を隠し、不正なアクセスを防ぐ |
主なルール | ルートエンティティを通じてのみ変更可能 | プライベートメンバーを外部から直接変更できない |
スコープ | エンティティ・値オブジェクトのグループ | クラス単体の設計 |
カプセル化の例
public class BankAccount {
private int balance;
public BankAccount(int balance) {
this.balance = balance;
}
public void deposit(int amount) {
if (amount > 0) {
this.balance += amount;
}
}
public int getBalance() {
return balance;
}
}
- オブジェクト単位で、不正に操作ができないように設計されたもの
- balance を private にして、外部から直接変更できないようにしている
- deposit() メソッドを通じてのみ balance を変更可能
集約の例
class Order {
// 1回の注文で許可する最大商品数
private static final int MAX_ITEMS = 10;
// 注文商品一覧 (外部から直接アクセスできないようにprivate修飾子に変更)
private List<OrderItem> items = new ArrayList<>();
// 商品を追加するメソッド
public void addItem(OrderItem item) {
if (items.size() >= MAX_ITEMS) {
System.out.println("❌ これ以上商品を追加できません。(最大 " + MAX_ITEMS + " 個)");
return;
}
items.add(new OrderItem(productName, quantity, price));
}
public List<OrderItem> getItems() {
return Collections.unmodifiableList(items);
}
}
class OrderItem {
private String productName;
private int quantity;
}
- ドメインルールである、「1度に注文できる商品数は10個まで」をコードで表現したもの
- Order は 集約のルートエンティティであり、OrderItem は外部から直接変更されない
- OrderItem を直接操作できず、Orderを通じてのみデータを管理
つまり、
「カプセル化はオブジェクト単位、集約はビジネスルール単位」 と考えることができます。
集約の区切り方
どのように集約を区切ってエンティティを設計するかはとても難しいテーマです。
ここでは一般的な方法として、以下の方法を紹介します。
ビジネスルールに基づいて区切る
ビジネスルールを守るために、必要なものをまとめる 考え方です。
架空のECサイトを例にしてみましょう。
ECサイトには以下のビジネスルールが存在すると仮定します。
- 注文には複数の商品が含まれる(OrderItem)
- 注文が確定すると、その中の商品も確定される
このルールに基づいて集約を区切ると、以下のようにできます。
// 注文票クラス
class Order { // 集約のルートエンティティ
// 注文には複数の商品が含まれることを表現
private List<OrderItem> items = new ArrayList<>();
public void addItem(OrderItem item) {
items.add(item);
}
// 注文を確定させると、その中の商品も確定される ことを表現
public void confirmOrder() {
// 注文確定ロジック
// ここでは省略するが、実際は itemsの要素を確定させる処理を行う
}
}
// 商品クラス
class OrderItem { // 集約内部のエンティティ
private String productName;
private int quantity;
}
トランザクションの一貫性を考慮する
一度のトランザクションで安全に更新すべき範囲を集約とする考え方です。
これは、データの整合性に重点を置いて集約を区切る考え方です。
銀行口座を例にしてみましょう。
銀行口座への預金・引き落としには以下の考え方があります。
- 口座の残高は、入金・出金のたびに即時更新されるべき
- 口座ごとに独立して整合性を保ちたい
この考え方に基づいて集約を区切ると、以下のようにできます。
class BankAccount { // 口座は1つの集約
// 口座に残っている残高
private int balance;
// 口座へ預金を行う
public void deposit(int amount) {
if (amount > 0) {
// 口座の残高へ即時に預金額を反映
this.balance += amount;
}
}
// 口座から引き落としを行う
public void withdraw(int amount) {
if (amount > 0 && this.balance >= amount) {
// 口座の残高から即時に指定額を引き落とし
this.balance -= amount;
}
}
}
「所有」の関係に注目する
このデータは、他のデータに依存していないか? に注目する考え方です。
勤怠管理システムにおける 従業員とIDカードを例にしてみましょう。
従業員とIDカードは以下の関係であると仮定します。
- 従業員はIDカードを必ず持っている
- IDカード単体では意味がなく、従業員が持つことで機能する
- IDカードを発行・更新・無効化するときは、必ず一人の社員に紐づいて処理されるべき
この考え方に基づいて集約を区切ると、以下のようにできます。
// 従業員クラス
class Employee { // 集約のルートエンティティ
private String name;
private IDCard idCard;
public Employee(String name, IDCard idCard) {
this.name = name;
this.idCard = idCard;
}
public void issueNewIDCard(String cardNumber) {
this.idCard = new IDCard(cardNumber);
}
public IDCard getIdCard() {
return idCard;
}
}
// IDカードクラス
class IDCard { // Employeeに所有される
private String cardNumber;
public IDCard(String cardNumber) {
this.cardNumber = cardNumber;
}
public String getCardNumber() {
return cardNumber;
}
}
さいごに
「集約をどこで区切るか」は機械的に判断できるものではありません。
ビジネスルールや、トランザクションの範囲、データの所有関係等の様々な要素を考慮して区切る必要があります。
これには正解がありませんが、上記の考え方を参考にしてみると理解しやすいでしょう。