コンテンツにスキップ

エンティティがドメインサービスに依存!?ダブルディスパッチとは?

今回の記事ではエンティティがドメインサービスに一時的に依存するダブルディスパッチという手法について解説していきたいと思います。

DDDのドメインサービスとエンティティの関係、ダブルディスパッチの関係を具体的なコードを見ながら解説していきたいと思います。

DDDにおけるドメイン層の基本的な依存関係

Section titled “DDDにおけるドメイン層の基本的な依存関係”

まずはDDDにおけるドメイン層の基本的な依存関係を見ていきましょう。

1. エンティティ → 値オブジェクト

Section titled “1. エンティティ → 値オブジェクト”

まずは最も基本的な部分です。 エンティティは属性を表現するために値オブジェクトを持つことがあります。 そのためエンティティは値オブジェクトに依存することになります。

逆に値オブジェクトはドメインの最小単位的な役割を持っており他の何者にも依存しないのが理想です。

2. ドメインサービス → エンティティ / 値オブジェクト

Section titled “2. ドメインサービス → エンティティ / 値オブジェクト”

ドメインサービスはドメインの調整役と言われるだけあってエンティティや、値オブジェクトを扱います。 そのため依存関係としてはエンティティと値オブジェクトに依存する形となります。

3. リポジトリ(IF) → エンティティ / 値オブジェクト

Section titled “3. リポジトリ(IF) → エンティティ / 値オブジェクト”

リポジトリのインターフェースもエンティティや値オブジェクトに依存します。 リポジトリは引数としてエンティティや値オブジェクトを受け取って、それをDBに保存すると考えると当然ですね。

ドメイン層における基本的な依存関係を図にまとめると、次のようになります。

flowchart LR
    Entity["エンティティ / 集約ルート"] --> VO["値オブジェクト"]
    DS["ドメインサービス"] --> Entity
    DS --> VO
    Repo["リポジトリ IF"] --> Entity
    Repo --> VO

ではここから本題のダブルディスパッチについて見ていきたいと思います。

ダブルディスパッチとは、「ある集約ルートのメソッドが、ドメインサービスを引数として受け取り、サービスの知識を用いて処理を完成させる」という手法です。

つまり、通常は「ドメインサービス → 集約ルート」である依存関係が、メソッド呼び出しの瞬間だけ「集約ルート → ドメインサービス」に逆転します。

flowchart TB
    subgraph compile["通常(コード上の依存関係)"]
        direction TB
        App1["アプリケーションサービス"]
        Party1["集約ルート"]
        DS1["ドメインサービス"]
        App1 --> Party1
        App1 --> DS1
        DS1 -.->|コード上の依存| Party1
    end

    subgraph runtime["ダブルディスパッチ実行時(一時的な呼び出しの流れ)"]
        direction TB
        App2["アプリケーションサービス"]
        Party2["集約ルート"]
        DS2["ドメインサービス"]
        App2 -->|1回目の呼び出し| Party2
        Party2 -->|2回目の呼び出し<br/>一時的な依存| DS2
        DS2 -.->|コード上の依存| Party2
    end

「ダブルディスパッチ(二重ディスパッチ)」という名称は、集約ルートへのメソッド呼び出し(1回目) と、集約ルート内部からドメインサービスへのメソッド呼び出し(2回目) という、2段階のメソッド呼び出しから来ています。上段はアプリケーションサービスが集約ルートとドメインサービスを別々に呼び出す通常の流れ、下段は集約ルート経由でドメインサービスを呼ぶダブルディスパッチの流れです。破線はコード上の依存関係(ソースコードを書いた時点での参照の向き)、下段の「2回目の呼び出し」と「一時的な依存」はメソッド実行中だけ生じる依存の逆転を表します。

エンティティの生成・変更にエンティティの外部の値が必要で、かつ絶対にルールを守ってもらう必要がある場合に使用します。

エンティティの外部の値が必要というのはドメインサービスと集約ルートの使い分けに関わってきますが、その説明は長くなってしまうため今回は割愛します。

ということでルールを確実に守るとはどういうことなのかを見ていきます。

基本的な依存関係の場合、実装者によっては致命的なルールの漏れが発生してしまう場合があります。

「アプリケーションサービスからドメインサービスのメソッドを使用し、エンティティを生成・変更し、リポジトリ経由でDBに保存する」と言った流れになります。

ただエンティティの実装内容によってはドメインサービスの処理をバイパスできてしまいます。

実装者が誤って「アプリケーションサービスからエンティティを生成・変更し、リポジトリ経由でDBに保存する」というようにドメインサービスの処理を飛ばしてしまった際、もちろんですがドメインサービスの処理は飛ばされ、そこに記載されていたルールは完全に無視されてしまうのです。

依存関係や呼び出しの有無は、時系列よりも「どのコンポーネントがどこに依存しているか」が重要なので、ここではフローチャートで表現します。矢印上の説明は縦方向に配置し、横方向の窮屈さを避けています。

以降の図およびその説明では、まだコードは示していませんが、後述の「具体的なコード例」で登場するメソッド名を、説明の便宜上先に使っています。

%%{init: {'flowchart': {'rankSpacing': 45, 'nodeSpacing': 35, 'padding': 14}}}%%
flowchart TB
    App["アプリケーションサービス"]
    Party["集約ルート"]
    DS["ドメインサービス"]

    App -->|addMember| Party
    App -.->|検証省略可能| DS
    DS -.->|通常の依存関係| Party

図のうち、アプリケーションサービスからドメインサービスへ伸びる破線は、基本的な依存関係のコード例にある verifyCanJoin の呼び出しが省略されうることを表しています。ドメインサービスから集約ルートへ伸びる破線は、コード上の依存関係(ドメインサービスが集約ルートの型を参照する)を表しています。アプリケーションサービスが検証を飛ばしても、集約ルートへの変更はそのまま実行できてしまうのが問題です。

エンティティのファクトリメソッドや、変更メソッドの引数にドメインサービスを渡しその中でドメインサービスのメソッド(チェックメソッドなど)を実行するように実装します。

すると、エンティティを生成・変更するたびにドメインサービスのメソッドが実行されることとなります。 結果、アプリケーションサービス側でドメインサービスの呼び出しを忘れる、というミスを構造的に防げるようになります。

%%{init: {'flowchart': {'rankSpacing': 45, 'nodeSpacing': 35, 'padding': 14}}}%%
flowchart TB
    App["アプリケーションサービス"]
    Party["集約ルート"]
    DS["ドメインサービス"]

    App -->|1回目の呼び出し| Party
    Party -->|2回目の呼び出し<br/>一時的な依存| DS
    DS -.->|コード上の依存| Party

コード上は引き続き「ドメインサービス → 集約ルート」の依存関係ですが、実行時の呼び出しは「集約ルート → ドメインサービス」と逆転します(1回目は ダブルディスパッチのコード例joinMember、2回目は verifyCanJoin)。検証は joinMember の内部で必ず実行されるため、破線のような省略は起こりません。

ダブルディスパッチを使用すべきケースと避けるべきケース

Section titled “ダブルディスパッチを使用すべきケースと避けるべきケース”

ここまでの説明を踏まえ、ダブルディスパッチを採用すべき場面と、避けた方がよい場面を整理します。

  • 集約の外部にある情報が必要なとき 例:プレイヤーが既に別のパーティーに所属していないか、在庫数が足りるか、など。単一の集約内だけでは判断できないルールです。
  • ルールの適用漏れが致命的なとき 実装者がドメインサービスの呼び出しを忘れると、ドメインの不変条件が破られるようなケースです。
  • 生成・変更のたびに同じチェックが必須なとき ファクトリメソッドや変更メソッドを経由する限り、必ず同じ検証が走るようにしたい場合に有効です。
  • 集約内だけで完結するルール 値オブジェクトのバリデーションや、集約ルート自身の状態遷移で表現できるルールは、エンティティ側に置くべきです。
  • アプリケーションサービスでの調整で十分なとき チーム全体がドメインサービスの呼び出し順序を守れる運用ができており、ルール漏れのリスクが低い場合は、わざわざ依存関係を逆転させる必要はありません。
  • サービスを渡す入口が増えすぎるとき ファクトリメソッドや変更メソッドの引数にドメインサービスを渡す箇所が増えすぎると、集約ルートがサービスの存在を意識しすぎてしまいます。複数集約にまたがるロジックが増えてきたら、ポリシーオブジェクトや別のモデリングを検討しましょう。

ここでは「パーティーにプレイヤーを参加させる」という例で、基本的な依存関係とダブルディスパッチの違いを Kotlin で見ていきます。

基本的な依存関係の場合(問題のある実装)

Section titled “基本的な依存関係の場合(問題のある実装)”

アプリケーションサービスがドメインサービスを呼び出し、その後エンティティを変更する流れです。

// --- ドメインサービス ---
class PartyMembershipService(
private val partyRepository: PartyRepository
) {
fun verifyCanJoin(party: Party, playerId: PlayerId) {
val existingParty = partyRepository.findByMember(playerId)
require(existingParty == null || existingParty.id == party.id) {
"プレイヤーは既に別のパーティーに所属しています。"
}
}
}
// --- 集約ルート ---
class Party(
val id: PartyId,
private val memberIds: MutableList<PlayerId>
) {
// ドメインサービスを経由せずに呼べてしまう
fun addMember(playerId: PlayerId) {
memberIds.add(playerId)
}
}
// --- アプリケーションサービス ---
class JoinPartyUseCase(
private val partyRepository: PartyRepository,
private val membershipService: PartyMembershipService
) {
fun execute(partyId: PartyId, playerId: PlayerId) {
val party = partyRepository.find(partyId)
membershipService.verifyCanJoin(party, playerId) // ここを忘れるとルールが無視される
party.addMember(playerId)
partyRepository.save(party)
}
}

Party.addMember が public のままだと、別のユースケースやテストコードから直接呼び出され、ドメインサービスの検証をすっ飛ばす実装が混入する余地があります。

エンティティの変更メソッドの引数にドメインサービスを渡し、メソッド内部で必ず検証を実行します。

// --- ドメインサービス ---
class PartyMembershipService(
private val partyRepository: PartyRepository
) {
fun verifyCanJoin(party: Party, playerId: PlayerId) {
val existingParty = partyRepository.findByMember(playerId)
require(existingParty == null || existingParty.id == party.id) {
"プレイヤーは既に別のパーティーに所属しています。"
}
}
}
// --- 集約ルート ---
class Party private constructor(
val id: PartyId,
private val memberIds: MutableList<PlayerId>
) {
fun joinMember(playerId: PlayerId, membershipService: PartyMembershipService) {
membershipService.verifyCanJoin(this, playerId)
memberIds.add(playerId)
}
companion object {
fun create(
id: PartyId,
ownerId: PlayerId,
membershipService: PartyMembershipService
): Party {
val party = Party(id, mutableListOf())
party.joinMember(ownerId, membershipService)
return party
}
}
}
// --- アプリケーションサービス ---
class JoinPartyUseCase(
private val partyRepository: PartyRepository,
private val membershipService: PartyMembershipService
) {
fun execute(partyId: PartyId, playerId: PlayerId) {
val party = partyRepository.find(partyId)
party.joinMember(playerId, membershipService) // 変更のたびに検証が走る
partyRepository.save(party)
}
}

コンストラクタを private にし、メンバー追加は joinMember 経由に限定しています。生成時も create ファクトリメソッド内で同じ joinMember を呼ぶため、検証ロジックが一箇所に集約されます。

一時的な依存の逆転であることを忘れない

Section titled “一時的な依存の逆転であることを忘れない”

ダブルディスパッチは恒久的な設計方針ではなく、「この操作を行う瞬間だけ」エンティティがドメインサービスに依存する手法です。常にエンティティからサービスを呼ぶ設計に広げてはいけません。

ドメインサービスは状態を持たない

Section titled “ドメインサービスは状態を持たない”

渡されるドメインサービスは、リポジトリなどを使って外部情報を参照しルールを検証するだけの、ステートレスなオブジェクトであるべきです。エンティティ側にサービスのインスタンスを保持し続ける必要はありません。

引数として渡すことで意図を明示する

Section titled “引数として渡すことで意図を明示する”

メソッドシグネチャに PartyMembershipService が含まれることで、「この操作には外部の検証が必要だ」という意図がコード上に現れます。実装者がサービスの存在に気づきやすくなるのも、ダブルディスパッチの副次的なメリットです。

DB からの再構築(リハイドレーション)とは別問題

Section titled “DB からの再構築(リハイドレーション)とは別問題”

ダブルディスパッチは「新たな生成・変更操作」に対するルール適用を保証するための手法です。DB から既存データを読み込んで集約を再構築する際は、当時すでに検証済みのデータを復元するため、通常は同じ検証を再度走らせる必要はありません。再構築用のファクトリを別途用意するのが一般的です。

DDD では通常、ドメインサービスがエンティティに依存するのが基本です。しかし、エンティティの生成・変更時に外部の情報を参照しなければならず、かつそのルールを確実に適用したい場合は、ダブルディスパッチによって一時的に依存関係を逆転させる選択肢があります。

ポイントは次の3つです。

  1. ルール漏れを構造的に防ぐ — エンティティのファクトリ・変更メソッド内でドメインサービスを呼ぶ
  2. 使いどころを見極める — 集約内で完結するルールや、チーム運用で十分な場合は無理に使わない
  3. 一時的な逆転である — 恒久的な依存関係の変更ではない

ダブルディスパッチは万能のパターンではありませんが、「どうしてもルールを守らせたい」場面では非常に強力な手段になります。自分のドメインモデルの中で、外部情報が必要な生成・変更処理がないか振り返ってみてください。