Scala
オブジェクト指向
DDD

DDDでエンティティ間の関連を「ロールオブジェクト」でスマートに扱う

はじめに

実践ScalaでDDD

実践ScalaでDDD で発表した中で、エンティティ間の関連を「ロールオブジェクト」として定義する ことをお話ししましたが、スライドでは要約になっています。

実際にプロダクトでやってみて有効なパターンだと感じているので、改めて突っ込んで解説したいと思います。

なお、内容的には Scala をターゲットとしていますが、他の言語にも考え方は応用できると思います。

サマリ

DDDで設計していると エンティティエンティティ の間に関連があり、その 関連に関するドメインの振る舞い と言うものが出てきます。

例えば 「ユーザー エンティティ」 と 「タスク エンティティ」 がある場合に、その間には 「タスクの作成者」「タスクの担当者」 と言う関連があったりします。
そしてそれらの関連は「タスクの作成者は、タスクを削除する」や「タスクの担当者は、タスクを完了する」のような振る舞いを持ちます。

role.png

このような振る舞いをどのコンポーネントの実装するか?が悩ましかったりするのですが、
ここで紹介する "ロールオブジェクト" を導入することでスマートに設計・実装することができます。

なお、ここで言う「スマート」とは、具体的にはアプリケーション層のコードをユースケース記述のように書けることを意図しています。
アプリケーション層のコードをユビキタス言語を使ってユースケース記述のように書くことができれば、ドメインの知識とコードをより近づけることができると思います。

関連を扱うときのアプローチ

A. ロールオブジェクトを使わないアプローチ

比較のために、まずは "ロールオブジェクト" を使わずに設計してみます。

A-1. 関連元のエンティティに実装する

関連元のエンティティ Task に、関連の振る舞いを定義します。

domain.user
case class User(...)
domain.task
case class Task(...) {
  // 関連元のエンティティに振る舞いを定義
  def closeBy(assignee: User): Task
}
application
val task: Task = ???
val assignee: User = ???

val closedTask = task.closeBy(assignee)  // アプリケーション層の表現が不自然

一見良さそうに思えますが、アプリケーション層での task.closeBy(assignee) と言うコードが不自然に感じます。
"手続き"として見れば「タスクが担当者によってクローズされる」で違和感はないですが、ユースケース記述として見るならあくまでも主語はアクターである担当者なので、ここは「担当者がタスクをクローズする」と書けたほうがより自然です。

A-2. 関連先のエンティティに実装する

今度は逆に、関連先のエンティティ User に振る舞いを定義してみます。

domain.user
case class User(...) {
  // 関連先のエンティティに振る舞いを定義
  def closeTask(task: Task): Task = ??? // ユーザーエンティティが肥大化し、重厚になっていく
}
domain.task
case class Task(...)
application
val task: Task = ???
val assignee: User = ???

val closedTask = assignee.closeTask(task)

アプリケーション層のコードとしては「担当者がタスクをクローズする」で自然に表現できているように思えます。

が、この場合は User エンティティに肥大化が懸念されます。

これまでの経験上、コンテキストには中心(あるいは起点と言っても良いかもしれません)となる 集約 が存在することが多く、その集約ルートである エンティティ に関連が集中することがよくあります。
そのため、このアプローチで設計すると特定の エンティティ に多くの振る舞いを集中して定義することになり、肥大化していく可能性があります。

また、これらの関連元/関連先エンティティに振る舞いを実装するアプローチでは、エンティティ から他の エンティティ に直接的に依存するため、集約 間の依存(=ドメイン層のパッケージ間の依存)が複雑かつ強くなってしまう恐れもあります。

A-3. ドメインサービスに実装する

では、あえて エンティティ に振る舞いを定義せずに、 ドメインサービス に定義するとどうでしょうか...

domain.user
case class User(...)
domain.task
case class Task(...)

object TaskService {
  // エンティティではなく、ドメインサービスに振る舞いを定義
  def closeTask(task: Task, assignee: User): Task = ???
}
application
val task: Task = ???
val assignee: User = ???

val closedTask = TaskService.closeTask(task, assignee)  // 完全に手続き的

完全に手続き型のプログラミングになってしまい、 エンティティ がドメインモデル貧血症を起こしてしまいます。
ドメイン層の表現力が貧弱になったことで、アプリケーション層のコードが読みづらいです。

B. ロールオブジェクトを使ったアプローチ

前置きが長くなりましたが、ここでようやく本題の "ロールオブジェクト" を導入してみます。

domain.user
case class User(...)
domain.task
case class Task(...)

// 関連元の集約にロールオブジェクトを定義
implicit class Assignee(user: User) {
  def closeTask(task: Task): Task = ???
}
application
val task: Task = ???
val assignee: User = ???

import domain.task._
val closedTask = assignee.closeTask(task)

"ロールオブジェクト" を導入することで、前出の問題をすべて解決できます。

  • アプリケーションサービスのコードが、「担当者がタスクをクローズする」と自然な表現になる。
  • 関連に関する振る舞いをロールオブジェクトに定義できるので、エンティティ が肥大化しない。
  • 集約 間の依存関係をロールオブジェクトに閉じ込めることができる。
  • ドメインモデル貧血症にならない。

ロールオブジェクトの実装パターン

"ロールオブジェクト" の実装では、 どうやって エンティティ を ロールオブジェクト に変換するか? がポイントになります。

この変換は 使用するプログラム言語 や 採用するフレームワーク によって異なってきますが、
ここでもやはり重視したいのは いかにアプリケーションサービスのコードを自然に書けるようにするか です。

暗黙的変換がサポートされている場合

Scala のような 暗黙的変換(implicit conversion) がサポートされている言語では、その機能を利用して「エンティティ」を "ロールオブジェクト" に変換できます。

暗黙的変換(implicit conversion)は、既存の型を修正することなく拡張(したかのように見せかけることが)できる機能です。より詳しくはドワンゴさんのScala研修テキストScala implicit修飾子 まとめ あたりを参照してください。

前出の例を再掲します。

domain.user
case class User(...)
domain.task
case class Task(...)

// ロールオブジェクトを、implicit classとして定義
implicit class Assignee(user: User) {
  def closeTask(task: Task): Task = ???
}
application
val task: Task = ???
val assignee: User = ???

// ロールオブジェクトをインポート
import domain.task._
// ロールオブジェクトに定義したメソッドを呼び出そうとすると、自動的にエンティティがロールオブジェクトに変換される
val closedTask = assignee.closeTask(task)

"ロールオブジェクト" は、関連元のパッケージに implicit class として定義します。
アプリケーションサービス でパッケージをインポートすることで、エンティティ"ロールオブジェクト"に自動的に変換されるようになります。

いちいちインポートしなければならないのがデメリットのように感じるかもしれませんが、
これは「その関連を扱うことを表明している」と言えるので、むしろメリットと言えるかなと思います。

なお、"ロールオブジェクト" のインポートは、その関連を扱うスコープを明示するためにファイルの先頭ではなくコードブロック内でインポートする(Scala はどこにでもインポート文が書ける)ことをおすすめします。

暗黙的変換がサポートされていない場合

暗黙的変換がサポートされていない言語の場合は、何らかの方法で変換する必要があります。

domain.user
public class User {
  // エンティティをロールオブジェクトに変換するメソッド
  public Assignee asAssignee() {
    return new Assignee(this);
  }
}
domain.task
public class Task { ... }

public class Assignee {
  private User user;
  public Assignee(User user) {
    this.user = user;
  }

  public Task closeTask(Task task) { ... }
}
application
Task task = ...;
User user = ...;

Task closedTask = user.asAssignee().closeTask(task)

これは実装の一例ですが...うーん、ちょっとしっくり来ませんね。
UserAssignee を知ってしまっているあたり、負けた気がします...(笑)

ファクトリとしてのロールオブジェクト

ここまでは 関連に関するドメインの振る舞い を定義する場所として "ロールオブジェクト" を使用してきましたが、"ロールオブジェクト"ファクトリ の役割を担うこともできます。

実践ScalaでDDD でも取り上げていますが、エンティティ の生成には2つのケースがあります。

  • 単独で生成される場合。
  • 他のエンティティに従属して生成される場合。

例えば、User エンティティは利用者がサインアップしたときに単独で生成されますが、
Task エンティティはタスクの作成者である User エンティティに従属して生成されます。

"ロールオブジェクト"は後者のケースで ファクトリ として使用できます。

domain.user
case class User(...)
domain.task
case class Task(...)

implicit class Author(user: User) {
  // Taskエンティティを生成するファクトリメソッド
  def createTask(...): Task = ???
}

implicit class Assignee(user: User) {
  def closeTask(task: Task): Task = ???
}
application
val author: User = ???

import domain.task._
val task = author.createTask(...)

"ロールオブジェクト"ファクトリ として使用することで、アプリケーションサービスでの エンティティ 生成を自然なコードで表現できます。

おわりに

"ロールオブジェクト" を導入することで、これまでどこに定義してもしっくりしなかった振る舞いを
自然に定義できるようになっているのではないか、と思います。

実装パターンについてはここで紹介した以外にも色々なやり方が考えられると思います。
特に暗黙的変換が使えない場合は少し模索が必要そうです。