little hands' lab

ドメイン駆動設計、アジャイルプラクティスを実践し、解説しています。

DDDのエンティティはイミュータブルな実装にしてもいいの?(サンプルコード有り)[ドメイン駆動設計 / DDD]

本記事はドメイン駆動設計(DDD) Advent Calendar 2021の13日目の記事です。

エンティティとイミュータブル性

オブジェクトをイミュータブル、つまり内部状態を変えない実装にすることで可読性やマルチスレッド対応性が向上することがあります。 エンティティはモデリング上の定義はミュータブルなものですが、実装方法をイミュータブルにすることは可能です。

(DDDでは、エンティティはミュータブルもしくはイミュータブル、値オブジェクトは必ずイミュータブルという定義です。詳しくはこちら)

DDD基礎解説:Entity、ValueObjectってなんなんだ - little hands' lab

本記事ではエンティティをイミュータブルな実装にするサンプルコードと合わせて、イミュータブルにした場合の旨みを感じられるコードを紹介します。

イミュータブルなエンティティ実装の例

エンティティをイミュータブルな実装にする場合は、次のようなコードになります。

class Task private constructor( // ①
  val id: TaskId, val name: TaskName, val status: TaskStatus
) {
  /** タスクを完了させます */
  fun done(): Task { // ②
    return Task(
      // 変更しない値
      id = this.id, 
      name = this.name, 
      // 変更する値
      status = TaskStatus.DONE 
    )
  }

  companion object {
    /** ②ユースケースなどで使用するドメインのルール/制約を表現したファクトリメソッド */
    fun create(name: TaskName): Task =
      Task(
        id = TaskId(),
        name = name,
        status = TaskStatus.UNDONE
      )

    /** ③ リポジトリなどで使用する任意のインスタンスを再構成するファクトリメソッド */
    fun reconstruct(id: TaskId, name: TaskName, status: TaskStatus): Task {
      return Task(id = id, name = name, status = status)
    }
}

①コンストラクタはプライベートにして外部から直接呼び出せないようにします。 また、各属性はval(Kotinの文法で不変=再代入不可な変数を表す)で定義します。

②タスクを完了させるメソッドは、内部状態を変更するのではなく新しい状態のインスタンスを返します。

このエンティティのユニットテストは次のようになります。

@Test
fun `タスク新規作成`() {
  // given:
  val newTask = Task.create(TaskName("新規タスク"))

  // when:
  val doneTask = newTask.done()

  // then:
  assertEquals(TaskStatus.UNDONE, newTask) // SUCCESS
  assertEquals(TaskStatus.DONE, doneTask) // SUCCESS
}

このクラスで、以下の実装をするとコンパイルエラーになります。

// コンストラクタはprivateのためコンパイルエラー
val task = Task.create(TaskId("1"), TaskName("新規タスク"), TaskStatus.UNDONE) 

イミュータブルなエンティティの呼び出し方法とメリット

このエンティティを使用するユースケースは次のようになります。あわせて、イミュータブルな実装にするメリットも解説します。

class DoneTaskUseCase(
  private val taskRepository: TaskRepository,
  private val someProcess: SomeProcess
) {
  fun execute(taskId: TaskId) {
    val foundTask: Task = taskRepository.findById(taskId) // ①
    val doneTask: Task = foundTask.done() // ②
    someProcess.doSomething(doneTask) // ③ 渡したTaskを使用して何かしている
    taskRepository.update(doneTask) // ④ doneTaskは元のままであることが明確
  }
}

①リポジトリからタスクインスタンスを取得します。
そこから②doneメソッドで完了状態のタスクインスタンスを新しく生成し、④で保存しています。

この時のメリットとして、③でタスクに関して何らかの処理が行われていますが、②のタスクの内部状態には影響を及ぼさないことが確実に読み取れるという点です。

このようなメリットを重視する場合は、エンティティをイミュータブルな実装にしてもよいでしょう。これは使用している言語の仕様や文化によっても最適解は異なるので、マストではありません。メリットデメリットを比較して判断しましょう。*1

実装の工夫

上記のTaskクラスのdoneメソッドでは、3つの属性のうち1つだけは新しい値、残りは既存の値を再度使用します。このようなメソッドが増えてくるとコードの重複が発生して可読性が若干低下します。

そのような場合には内部で一部の値のみ変更したインスタンスを返すメソッドを作成すると可読性をあげられます。

class Task( // コンストラクタ略 
){
  /** 指定した属性の値を変更したインスタンスを返します */
  private fun changeAttributes( // ①
    id: TaskId = this.id,
    name: TaskName = this.name,
    status: TaskStatus = this.status
  ): Task {
    return Task(id = id, name = name, status = status)
  }

  /** タスクを完了させます */
  fun done(): Task { 
    return this.changeAttributes(status = TaskStatus.DONE)  // ②
  }

  /** 名前を変更します */
  fun rename(taskName: TaskName): Task {
    return this.changeAttributes(name = taskName)  // ③
  }
}

①はメソッドのデフォルト引数を使用しており、引数に値が渡されたものはその値を、渡されなかった場合はデフォルト値として=の右側の値を使用します。このメソッドはprivateになっているのでクラス外からは呼び出せません。

これを使用すると②、③のように変更したい値のみ指定して新しいインスタンスを生成することができます。*2

もっと色々知りたくなったら

本記事の内容は2021年10月発売の解説書「ドメイン駆動設計FAQ&サンプルコード」から抜粋したものを、ブログ記事の趣旨に合わせて加筆したものです。

本書は重要トピック「モデリング」「集約」「テスト」について詳細に解説し、その他のトピックでは頻出の質問への回答と具体的なサンプルコードをふんだんに盛り込みました。現場で実践して、困っていることがある方はぜひこちらもご覧ください。

little-hands.booth.pm

また、DDD自体に関する解説はこちらもご覧ください。

初めてDDDを学ぶ方、もしくは実際に着手して難しさにぶつかっている方向けの書籍です。 迷子になりがちな「DDDの目的」や「モデル」の解説からはじめ、具体的なモデリングを行い実装まで落とす事例を元に、DDDの魅力や効果を体感することを目指します。

little-hands.booth.pm

現場での導入で困ったら

DDDを導入しようとすると結構試行錯誤に時間がかかります。
現場で導入してすぐに効果を発揮したい!!という方向けに、基礎解説とライブモデリング/コーディングを行う勉強会の開催や、設計相談を受付ております。
事例紹介もあるのでご関心あれば覗いてみてください。開催形式は柔軟に対応できるのでお気軽にご相談ください。

little-hand-s.notion.site




YouTubeで10分でわかるDDD動画シリーズをアップしています。概要を動画で理解したい方はこちらもどうぞ。チャンネル登録すると新しい動画の通知を受け取ることができます。

*1:なお、筆者は現在のプロジェクトではKotlinでイミュータブルな実装で統一しています

*2:Kotlin、Scalaユーザー向け: これはKotlinのdata class、Scalaのcase classでいうcopyメソッドと同じ挙動なので、名前をそのようにしても良いでしょう。なお、直接data classを使用してしまうと、2021年12月現在のKotlinの言語仕様ではcopyメソッドを外部から直接呼び出すのを防げず、不整合なインスタンスを作れてしまうという問題があるのでそのようにはしません。