cqrs-aggregate-modeling

CQRS/ESが集約の境界定義とモデリングに与える影響を解説する。CQRSを導入すると集約は コマンド実行に必要な最小限の状態のみ保持すればよくなり、読み取り責務はリードモデルに 委譲できる。大きすぎる集約の軽量化、集約境界の再定義、イベントによる状態管理を支援する。 集約設計、CQRS導入時のモデリング見直し、パフォーマンス問題の解決時に使用。 対象言語: 言語非依存。 トリガー:「CQRSで集約が変わる」「集約が大きすぎる」「集約にメッセージ1000件」 「集約の更新が重い」「CQRS導入で集約を見直す」「集約を軽量化したい」 「集約にクエリ用データが混ざっている」「集約の境界を再定義」 といったCQRS/モデリング関連リクエストで起動。

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "cqrs-aggregate-modeling" with this command: npx skills add j5ik2o/okite-ai/j5ik2o-okite-ai-cqrs-aggregate-modeling

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採用のトレードオフ分析

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

cross-aggregate-constraints

No summary provided by upstream source.

Repository SourceNeeds Review
General

domain-model-extractor

No summary provided by upstream source.

Repository SourceNeeds Review
General

tell-dont-ask

No summary provided by upstream source.

Repository SourceNeeds Review
General

first-class-collection

No summary provided by upstream source.

Repository SourceNeeds Review
cqrs-aggregate-modeling | V50.AI