DDD 集約の復習と限界 ── 何を守り、何に苦しんでいるか

集約とは何か
Eric Evans の “Domain-Driven Design”(2003)での定義:
集約(Aggregate)とは、データ変更の単位として扱われる関連オブジェクトのまとまりである。
集約は以下の2つの責務を持つ。
- 一貫性の境界:集約の内部で不変条件(ビジネスルール)が常に満たされることを保証する
- トランザクションの境界:1つのトランザクションで変更されるのは1つの集約のみ
注文(Order)集約の例:
Order (集約ルート)
├── OrderLine (値オブジェクト)
│ ├── product_id
│ ├── quantity
│ └── unit_price
├── OrderLine
└── OrderLine
不変条件:
「注文の合計金額は100万円を超えてはならない」
→ OrderLine を追加するたびに Order 集約内でチェックされる
→ トランザクション内で完結する
集約がうまく機能するケース
集約は単一エンティティに閉じた不変条件を守るのに非常にうまく機能する。
class Order:
MAX_TOTAL = 1_000_000
def add_line(self, product_id: str, quantity: int, unit_price: int):
new_total = self.total + quantity * unit_price
if new_total > self.MAX_TOTAL:
raise DomainError("注文の合計が100万円を超えます")
self.lines.append(OrderLine(product_id, quantity, unit_price))
この場合、集約の境界設計は自然だ。Order の中で全ての判断が完結する。
集約が苦しむケース:クロス集約の不変条件
問題は、不変条件が2つ以上の集約にまたがる場合だ。
ケース1: 講座予約システム
集約A: Course(講座)
不変条件: 受講者は最大30名
集約B: Student(受講者)
不変条件: 同時登録は最大10講座
「受講者 X が講座 Y に登録する」操作は
両方の不変条件を同時に満たす必要がある
ケース2: 銀行口座間の送金
集約A: CheckingAccount(普通預金)
不変条件: 残高が0未満にならない
集約B: SavingsAccount(定期預金)
不変条件: 残高が0未満にならない
「普通預金から定期預金へ10万円を移動」は
両方の集約を1トランザクションで更新したい
ケース3: 一意性制約
集約: User
不変条件: メールアドレスはシステム全体で一意
これは「1つのUser集約」の中では判断できない
→ 全ユーザーのメールアドレスを知っている必要がある
従来のアプローチとその問題
アプローチ1: 集約を大きくする
「両方の不変条件を1つの集約に入れてしまう」。
class CourseRegistrationSystem: # 巨大な集約
courses: list[Course]
students: list[Student]
def register(self, student_id, course_id):
course = self.find_course(course_id)
student = self.find_student(student_id)
if len(course.students) >= 30:
raise DomainError("講座が満員です")
if len(student.courses) >= 10:
raise DomainError("受講上限に達しています")
# ... 登録処理
問題:
- 集約が肥大化し、あらゆる操作がシリアライズされる(並行性の喪失)
- God Object になり、変更の影響が全体に波及する
- イベントソーシングでは全イベントを1つのストリームに集約 → パフォーマンス劣化
アプローチ2: Saga / プロセスマネージャー
「結果整合性を受け入れ、補償トランザクションでリカバリする」。
1. Course に登録リクエスト → 30名以下なら仮登録
2. Student に登録リクエスト → 10講座以下なら確定
3. Student の制約違反なら → Course の仮登録を取り消す(補償)
問題:
- 一時的に不整合が存在する(30名を超えた状態が瞬間的に起きうる)
- 補償トランザクションのロジック自体が複雑
- ビジネスルールとして「一瞬でも30名を超えてはならない」場合は使えない
アプローチ3: ドメインサービスでロック
「両方の集約をロックして同時に更新する」。
class RegistrationService:
def register(self, student_id, course_id):
with lock(f"course:{course_id}"), lock(f"student:{student_id}"):
course = self.course_repo.get(course_id)
student = self.student_repo.get(student_id)
# 両方の不変条件をチェック
course.add_student(student_id)
student.add_course(course_id)
self.course_repo.save(course)
self.student_repo.save(student)
問題:
- 分散ロックの複雑さ(デッドロック、パフォーマンス)
- 集約の「1トランザクション1集約」原則を破っている
- スケーラビリティが犠牲になる
問題の本質
これらの苦しみの根本原因は、集約の境界が設計時に固定されることにある。
設計時に決めた境界:
Course は Course のストリーム
Student は Student のストリーム
しかし「登録」というユースケースは:
Course のストリームと Student のストリームの両方にまたがる
→ 固定された境界と動的なユースケースのミスマッチ
DCB はこの「設計時の固定」を「実行時の動的決定」に変えることで、問題を解決する。
次章でその仕組みを見ていこう。