skills/j5ik2o/okite-ai/cqrs-aggregate-modeling

cqrs-aggregate-modeling

SKILL.md

CQRSによる集約の境界再定義

CQRSを導入すると集約のモデリングが変わる。集約はコマンド実行に必要な最小限の状態のみ保持し、読み取り責務はリードモデルに委譲する。

問題: 肥大化した集約

典型例: Thread集約が1000件のメッセージを保持

// 従来型: 集約がすべてのデータを保持
case class Message(id: MessageId, text: MessageText, senderId: AccountId,
                   createdAt: Instant, updatedAt: Instant)
case class Messages(values: List[Message])

class Thread(id: ThreadId, members: Members, messages: Messages,
             createdAt: Instant)

更新時の問題

1. threadRepository.findById(threadId)
   → 1000件のメッセージを含むスレッド全体をDBから取得

2. thread.addMessage(...)
   → メッセージを1件追加

3. threadRepository.store(newThread)
   → 1001件全体をDBに更新
   → どのフィールドが更新されたか不明なため、全情報を更新する必要がある

1件のメッセージ追加のために1001件を更新する。 これは集約が「コマンドに必要なデータ」と「クエリに必要なデータ」を区別せずに保持していることが原因。

差分更新の誘惑

差分更新を実装しようとすると、集約の内部実装が複雑化する。どのフィールドが変更されたかを追跡する仕組みが必要になり、ドメインロジックとインフラの関心が混在する。

解決: CQRSによる集約の再設計

核心原則

CQRSを導入すると、集約はコマンド実行に必要な最小限の状態だけ持てばよい。

読み取り責務(クエリ)を集約から完全に除去し、リードモデルに委譲する。その結果、集約はコマンドの検証に必要な情報のみ保持する。

問い: このコマンドの検証に何が必要か?

Thread集約の場合、「メッセージ追加」コマンドの検証に必要なのは:

  • 送信者がスレッドのメンバーであること → メンバーIDのリストが必要
  • メッセージIDの重複がないこと → メッセージIDのリストが必要

メッセージの本文は不要。 本文は表示(クエリ)のために必要であり、コマンドの検証には関係ない。

再設計後の集約

// CQRS/ES: 集約はコマンド検証に必要な最小限の状態のみ保持
class Thread(id: ThreadId, memberIds: MemberIds, messageIds: MessageIds,
             createdAt: Instant) {

  def addMessage(messageId: MessageId, messageText: MessageText,
                 senderId: AccountId): Either[ThreadError, Thread] =
    if (memberIds.contains(senderId)) {
      // イベントを追記するだけ。1001件の更新は発生しない
      persistEvent(MessageAdded(id, messageId, messageText, senderId, Instant.now))
      Right(copy(messageIds = messageIds.add(messageId)))  // IDのみ追加
    } else {
      Left(new AddMessageError)
    }
}

メッセージ本文を持たないため、集約は大幅に軽量化される。

イベントの設計

sealed trait ThreadEvent

case class MemberAdded(threadId: ThreadId, accountId: AccountId,
                       occurredAt: Instant) extends ThreadEvent

case class MessageAdded(threadId: ThreadId, messageId: MessageId,
                       messageText: MessageText, senderId: AccountId,
                       occurredAt: Instant) extends ThreadEvent

case class MessageUpdated(threadId: ThreadId, messageId: MessageId,
                         messageText: MessageText, senderId: AccountId,
                         occurredAt: Instant) extends ThreadEvent

イベントにはメッセージ本文を含める(リードモデル構築に必要なため)。ただし、集約の状態復元時にはIDのみを反映する。

リードモデル(Q側)

// イベントを消費してリードモデルを構築
consumeEventsByThreadIdFromDDBStreams.foreach {
  case ev: MemberAdded   => insertMember(ev)
  case ev: MessageAdded  => insertMessage(ev)
  case ev: MessageUpdated => updateMessage(ev)
}

// リードモデルはクエリに最適化されたDTO
case class MessageDto(id: Long, threadId: Long, text: String,
                     senderId: Long, createdAt: Instant, updatedAt: Instant)

// 部分取得が可能(ページネーション等)
val messages: Seq[MessageDto] =
  MessageDao.findAllByThreadIdWithOffsetLimit(threadId, 0, 100)

Before / After 比較

観点 従来型(非CQRS) CQRS/ES
集約の状態 メッセージ全文を保持 メッセージIDのみ保持
メッセージ追加 全件更新 イベント1件追記
読み取り 集約から直接取得 リードモデルから取得
メモリ使用量 メッセージ数に比例して増大 ID数に比例(軽量)
ページネーション 集約内で実装(複雑) リードモデルのDAO(自然)

集約の境界再定義の考え方

判断基準: コマンドの検証に必要か?

集約が保持すべきデータを決めるには、各コマンドの検証ロジックを分析する。

集約が現在保持しているデータ
各フィールドについて:
    「このデータはコマンドの検証に使われるか?」
    ├─ YES → 集約に残す
    └─ NO → クエリ専用データ → リードモデルへ移動

具体例: Thread集約の分析

データ コマンド検証に必要か 判断
メンバーID一覧 YES(送信者がメンバーか確認) 集約に残す
メッセージID一覧 YES(重複チェック) 集約に残す
メッセージ本文 NO(表示のみ) リードモデルへ
送信者名 NO(表示のみ) リードモデルへ

強い整合性の再検討

CQRSを導入する際に問うべき:

スレッドとメッセージの関係性に強い整合性は必要か?

  • メッセージの追加・表示に「メッセージ本文の即時一貫性」は不要
  • メンバーシップの確認にのみ強い一貫性が必要
  • 振る舞いがイメージできれば集約の構造が明確になる

大きすぎる集約の兆候と対処

兆候

兆候 原因
集約の読み込みが遅い 不要なデータを大量に保持
更新時に全件SQLが発生 差分が追跡できない
集約内にページネーションロジック クエリ責務が混在
DTOと集約の構造が酷似 クエリ用データがそのまま集約に

対処フロー

集約が大きすぎる
1. 各フィールドを「コマンド検証用」と「クエリ用」に分類
2. クエリ用データをリードモデルへ移動(CQRSの導入)
3. 集約はIDリストや状態フラグなど最小限の状態のみ保持
4. イベントで状態変更を記録し、リードモデルはイベントから構築

関連スキルとの関係

スキル 関係
aggregate-design 集約の内部設計原則。本スキルはCQRSによる境界の再定義
cqrs-to-event-sourcing なぜESが必要か。本スキルはES前提のモデリング変革
cqrs-tradeoffs 一貫性・可用性のトレードオフ。本スキルはモデリングへの影響

レビューチェックリスト

集約の肥大化

  • 集約がクエリ専用データ(表示名、計算結果等)を保持していないか
  • 集約の読み込みにパフォーマンス問題がないか
  • 更新時に不要な全件更新が発生していないか

CQRS/ESによる再設計

  • 各フィールドが「コマンド検証に必要か」で分類されているか
  • クエリ専用データはリードモデルに委譲されているか
  • 集約はIDリスト等の最小限の状態のみ保持しているか
  • イベントにはリードモデル構築に必要な情報がすべて含まれているか

境界の妥当性

  • 集約内のデータすべてに強い整合性が本当に必要か再検討したか
  • 振る舞い(コマンド)に基づいて集約の境界を決めているか
  • 結果整合性で十分なデータを集約から分離しているか

関連スキル(併読推奨)

このスキルを使用する際は、以下のスキルも併せて参照すること:

  • cqrs-to-event-sourcing: イベントソーシングが集約モデリングを変える理由
  • aggregate-design: CQRS適用前の基本的な集約設計ルール
  • cqrs-tradeoffs: CQRS採用のトレードオフ分析
Weekly Installs
15
Repository
j5ik2o/okite-ai
GitHub Stars
73
First Seen
13 days ago
Installed on
opencode15
gemini-cli15
github-copilot15
codex15
amp15
cline15