little hands' lab

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

DDDで複数集約間の整合性を確保する方法(サンプルコードあり)[ドメイン駆動設計]

株式会社ログラスの松岡です。

本記事では、DDDに関する疑問で頻出な、複数集約間の整合性を確保する方法について、具体的なコードを交えて紹介します。

実装方法は、主に以下の3つに分かれます。

  1. ユースケースで複数集約に更新をかける
  2. ドメインサービスを使用する
  3. ドメインイベントを使用する

目次

集約の定義について

集約自体の説明については、本記事では割愛します。詳しくは下記の書籍「集約」の章をご覧ください。

little-hands.booth.pm

もしくは、こちらのYouTube動画でも解説があります。

DDDオンライン勉強会 #2「集約」 - YouTube

題材とする事例

具体例をあげます。

とあるタスク管理アプリケーションで、タスクを作成したら、合わせて「(タスク名)が作成されました」という活動レポートを作成する、という実装を行うことになりました。
レポートに漏れがあるとまずいので、タスク作成時には確実に活動レポート(ActivityReport)を作成されるようにしたいです。つまり、タスク集約と活動レポート集約、という2つの集約間に確保したい整合性があるということです。どうしたらいいでしょうか。

なお、活動レポートはタスク集約以外のオブジェクトとも関連して生成されるため、タスクと別の集約するという設計判断をしたものとします。

実装方法1. ユースケースで複数集約を更新する

一番シンプルな実装です。(言語はKotlinです)

// Task関連
class Task(val taskName: String)

interface TaskRepository{
  fun insert(task: Task)
}

// ActivityReport関連
class ActivityReport private constructor(val detail: String) { // ①
  companion object {
    fun create(task: Task): ActivityReport { //②
      // 必要に応じてバリデーションを入れる
      return ActivityReport("${task.taskName}が作成されました"))
    }

    fun fromRepository(detail: String): ActivityReport {  //③
      // バリデーションはない
      return ActivityReport(detail)
    }
  }
}
interface ActivityReportRepository{
  fun insert(ActivityReport: ActivityReport)
}

ドメイン層の実装はこのようになります。

①のprivate constructor と書いてある部分は、Kotlinで必ず1つのみ定義するプライマリコンストラクタで、他のコンストラクタはプライマリコンストラクタを必ず経由しなければなりません。ここで書かれているval detailは、ActivityReportのプロパティとなります。

createはユースケースから使用される、ドメイン知識を表現したファクトリメソッド(インスタンス生成メソッド)です*1。ここでは文字列を直接指定するのは禁止し、Taskクラスを渡して生成させることにしました。このようなメソッドでは、バリデーションを実装することができます。

fromRepositoryは、リポジトリの実装クラスの中でDBの取得結果からエンティティを再構成するために必要なメソッドです。このメソッドは引数の値をバリデーションなしでセットするので、ユースケースから呼ばせたくありません。そのため、言語の機能で可視性を制御するか、fromRepositoryreconstructといった命名規則でユースケースから呼ばれることを避けます。(単体テストコードから使用することもあるので、個人的にはreconstructの方が好みです)

DBに永続化を行うアプリケーションでは、全てのエンティティで②③のようなメソッドが2種類必要になる一方、Kotlinではプライマリコンストラクタは1つ定義しないといけません。そのためプライマリコンストラクタの可視性をprivateにしてクラス内からしか呼べないようにしています。(この制限は言語によって異なるので、エッセンスとして参考にしてください)

なお、②はファクトリメソッドにせずにコンストラクタとして書いても良いですが、③のメソッドと粒度を合わせて可読性を上げるためにファクトリメソッドにしています。

このクラスを使用するユースケースクラスは以下のようになります。

class CreateTaskUseCase1(
    private val taskRepository: TaskRepository,
    private val activityReportRepository: ActivityReportRepository,
) {
  @Transactional
  fun execute(taskName: String) {
    // Taskの作成と保存
    val task = Task(taskName)
    taskRepository.insert(task)

    // ActivityReportの作成と保存
    val activityReport = ActivityReport(task) // 生成したTask経由でActivityReportを作成している
    activityReportRepository.insert(activityReport)
  }
}

非常にシンプルですが、このようにするとまずは整合性が確保されます。

「複数集約を1ユースケースで更新して良いのか?」という議論がされることがありますが、筆者はメリットデメリットを考慮した結果メリットの方が多ければ問題ないと考えています。
これはまた話が広がってしまうので、この記事では細かく言及せず、基本的にOKとして、また非同期ではなく同期的処理という前提で説明します。

メリット・デメリット

この方法のメリットは、とにかくシンプルで簡単だということです。そのため、まずはこの方法から入ることが多いかと思います。

しかし、デメリットがあります。以下のように、別のユースケースでうっかりと集約間の整合性を破壊してしまうことが可能だということです。

class EvilTaskUseCase1(private val taskRepository: TaskRepository) {
  @Transactional
  fun execute(taskName: String) {
    val task = Task(taskName)
    taskRepository.insert(task)
    // うっかりActivityReport作成を忘れてしまった
  }
}

また、「Taskが作成されたらActivityReportが作成される」ということはドメイン層の知識として重要なのに、その知識がドメイン層に書かれていない(=ドメイン層のコードを読んでも読み取れない)という欠点もあります。

そこで、別の手を考えます。

実装方法2. ドメインサービスを使用する

class CreateTaskUseCase2-1(
  private val createTaskDomainService: CreateTaskDomainService
) {
  @Transactional
  fun execute(taskName: String) {
    return createTaskDomainService.createTaskAndReport(taskname)
  }
}

このように、ユースケースからはドメインサービスを呼ぶだけにして、2つの集約間の整合性を保つ処理はそちらに委譲します。

class CreateTaskDomainService1(
    private val taskRepository: TaskRepository,
    private val activityReportRepository: ActivityReportRepository
) {
  fun createTaskAndReport(taskName: String) {
    val task = Task(taskName)
    taskRepository.insert(task)

    val activityReport = ActivityReport(task)
    activityReportRepository.insert(activityReport)
  }
}

ひとまず集約間の整合性の知識をドメイン層に委譲することができました。 実際のところ、割と現実的な案として採用されることが多い実装です。

しかし、まだ問題があります。これでも他のユースケースで整合性を破壊することを防げないのです。

class EvilTaskUseCase2(private val taskRepository: TaskRepository) {
  @Transactional
  fun execute(taskName: String) {
    val task = Task(taskName)
    taskRepository.insert(task)
    // やはり、うっかりActivityReport作成を忘れてしまった
  }
}

これを防ぐために一つ工夫をしてみます。(この工夫は、DDD一般的な技法ではなく筆者の個人的な案なので、その前提でお読みください)

class CreateTaskDomainService2(
    private val taskRepository: TaskRepository,
    private val activityReportRepository: ActivityReportRepository
) {
  fun createTaskAndReport(taskName: String) {
    val task = Task(DomainServiceTaskCreateParameter(taskName)) // ①
    taskRepository.insert(task)

    val activityReport = ActivityReport(task)
    activityReportRepository.insert(activityReport)
  }

  private data class DomainServiceTaskCreateParameter : TaskCreateParameter {
    override taskName: String
  }
}

①の部分で、Taskのコンストラクタに渡す引数が、タスク名ではなく謎のパラメーターオブジェクト DomainServiceTaskCreateParameter になっています。そして、そのクラスはドメインサービス内でprivateなクラスとして定義されています。これは一体何でしょう?

Taskエンティティの実装を追っていきます。

class Task private constructor(val taskName: String) {
  companion object {
    fun create(param: TaskCreateParameter): Task { // ①
      return Task(param.taskName)
    }
  }
}

/**
 * 特定のドメインサービスでだけタスク作成を許可するためのインターフェイス
 * ユースケースでこのインターフェイスを実装したクラスを作成しないでください
 */
interface TaskCreateParameter(val taskName: String)

①のファクトリメソッドの引数が、エンティティの近くで定義するインターフェイス TaskCreateParameterに変わりました。すると、ファクトリメソッドの呼び元ではこのインターフェイスを実装するクラスをインスタンス化できなければいけない、という制約をかけることができました。

これにより、以下のような実装をするとコンパイルエラーにできるのです。

class EvilTaskUseCase3() {
  @Transactional
  fun execute(taskName: String) {
    val task = Task(DomainServiceTaskCreateParameter(taskName)) // ① コンパイルエラー
    taskRepository.insert(task)
  }
}

①のDomainServiceTaskCreateParameterは、DomainServiceクラス内のプライベートクラスなので、他のクラスから使用することができません。この制約により、UseCaseで勝手にTaskを作成することが不可能になり、集約間の整合性確保しやすくなります。

ただし、TaskCreateParameter インターフェイスを実装するクラスをUseCaseで勝手に定義してしまうことは、この実装では防げません。しかし、このサンプルのようにコメントで制御する、というのは現実解としてはアリではないかと考えています。

この制御なしでうっかりユースケースから直接Taskのコンストラクタを呼んでしまうのと、制御ありで"うっかり"ユースケースで実装クラスを作ってしまうのであれば、後者の方が防ぎやすいのではないかと考えます。

なお、言語によってはもっと細かく制御することは可能(KotlinではSealedClassを使えば実装クラスを定義できる範囲を制御できる)なので、実装方法は各現場の言語ごとに検討してみてください。

メリット・デメリット

この方法のメリットは、実装方法1に比べて集約間の整合性の知識をドメイン層に寄せられる点、可視性の制御が可能になる点です。

一方のデメリットは、「ドメインサービスの責務が曖昧になり、低凝集になりがち」という点です*2。今回のドメインサービスは2つの集約なのでまだシンプルなようですが、徐々に記述が増えてどんどん従来のような手続き型になってしまう、というのは非常にありがちです。

改善案

凝集度について考える時、「このクラスの/このメソッドのは責務何か?」と考えることが重要です。責務とは「このクラス(メソッド)は何をするクラスか?」という問いに対する答えです。

先ほどのドメインサービスのメソッドの責務はなんでしょうか。 「タスク・活動レポートの作成保存」と、2つの責務があることがわかります。1つのメソッドに複数責務があるということは、凝集度が低いということです。

そのような場合は、「高凝集・低結合」という原則に則り、ドメインサービスの責務を減らすリファクタをしていくのが良いでしょう。

以下の例では、ドメインサービスからエンティティ永続化の責務をはがし、2つのエンティティ生成の責務のみにしました。合わせて、名前も「TaskFactory」と変更しました。「サービス」という名前は責務が曖昧になりがちなので、責務が明確になったタイミングで名前を合わせて変えるのは良いことです。

class CreateTaskUseCase2-3(
  private val taskFactory : TaskFactory,
  private val activityReportRepository: ActivityReportRepository
) {
  @Transactional
  fun execute(taskName: String) {
    val (task, activityReport) =
       taskFactory.createTaskAndReport(taskname) // ①
    taskRepository.insert(task)  // ②
    activityReportRepository.insert(activityReport) // ③
  }
}

class TaskFactory() {
  fun createTaskAndReport(taskName: String): Pair<Task, ActivityReport> {
    val task = Task(DomainServiceTaskCreateParameter(taskName))
    val activityReport = ActivityReport(task)  
    return Pair(task, activityReport)
  }

  private data class DomainServiceTaskCreateParameter : TaskCreateParameter {
    override taskName: String
  }
}

①で、TaskFactoryは生成した2つのエンティティを永続化せずに戻り値として返すようになりました。その後、ユースケース内で②③のように永続化しています。

もちろん、この実装にもデメリットがあります。TaskFactoryから返された2つのエンティティを永続化し忘れてしまうリスクがあるということです。 この点を考慮し、ドメインサービス内で永続化するという判断もあり得ます。

しかし、ドメインサービスがもっと複雑になった時、ひとつの選択肢としてこのように責務を減らすという選択肢は持っておいても良いでしょう。

実装方法3. ドメインイベントを使用する

3つ目の方法では、あまり見慣れないドメインイベントというものを使用します。

全体の流れは以下の3ステップになります。

  1. エンティティの何らかの処理のタイミングでドメインイベントを作成し、エンティティ内に蓄積する
  2. リポジトリのinsert/updateが成功したタイミングで、イベントを発火する
  3. 発火されたイベントをイベントリスナーの仕組みで拾い、他集約の処理を実施する

2,3のイベント発火と拾う仕組みに関しては、かなりフレームワーク依存の実装になります。また、本記事はJavaのフレームワークSpringの仕組みを使って実装したサンプルを示しますが、筆者の個人的な案になります。ここは様々な実装方法が考えられるので、一つの案としてお読みください。

まず、ユースケースからみていきます。

class CreateTaskUseCase3-1(
  private val taskRepository: TaskRepository
) {
  @Transactional
  fun execute(taskName: String) {
    val task = Task(taskName)  // 中でTaskCreatedEventが作成、蓄積される
    taskRepository.insert(task)  // insert成功時にTaskCreatedEventが発火される
  }
}

「え?中でやってるの?読み解くの無理じゃない?」って思いますよね。笑
はい、その点については改善点があるので後述します。まずは全体の流れを追っていきます。

発火されたTaskCreatedEventは以下のようなイベントリスナーで拾われます。

ここではSpringの仕組みを使っています。重要なポイントはイベントを投げるタイミングと拾ってから何をするかというところなので、投げる方法、拾う方法は適宜使用するフレームワークに合わせて実装して下さい。

@Component
class ActivityReportEventListener(
  private val activityReportRepository: ActivityReportRepository
) {
  @EventListener // ①
  internal fun createReport(event: TaskCreatedEvent) { // ② TaskCreatedEventを拾う、という定義をしている
    val activityReport = ActivityReport(event)
    activityReportRepository.insert(activityReport)
  }
}

Springの仕組みでは、①のように@EventListenerアノテーションを付与し、②のように引数で拾うイベントのパターンを定義すると、そのイベントが発火された時にこの処理が呼ばれるようになります。

そして、このメソッドが呼ばれるとcreateReportメソッド内で活動レポートが作成、保存されます。

ここで重要なのは、この仕組みを導入するとタスクが作成されたら「確実に」活動レポートが作成される ということです。
この確実さがドメインイベントを使った実装のメリットです。

一方、複雑度が実装方法1,2に比べて上がるというデメリットがあるので、そこはメリットと比較して導入検討することが必要になります。

例えば、お金に関するものや、今回のレポートの様に証跡を確実に残さなければならないような場合には、この確実さというメリットがデメリットを上回ることがあるでしょう。


さて、それでは実装の詳細を追っていきます。 まず、イベントの定義はこのようなものになります。

/** ドメインイベントの基底クラス */
abstract class DomainEvent() : ApplicationEvent("") // ①

/** タスクが作成されたことを示すイベント */
class TaskCreatedEvent(val taskName: String) : DomainEvent()  // ②

①は、全てのドメインイベントの基底クラスです。Springの定義する抽象クラスApplicationEventを継承することにより、Springのアプリケーションイベントを発火&拾う仕組みを使えるようにします。(ApplicationEventの引数に何か渡さないといけないので空文字を渡していますが、ライブラリの仕様なのであまり気にしないでください)

個別のドメインイベントは②のように、DomainEventを継承したクラスとして定義します。

class Task private constructor(val taskName: String) {
  private val domainEvents: MutableList<DomainEvent> = mutableListOf()

  constructor(taskName: String) : this(taskName = taskName) { // ①
    addDomainEvent(TaskCreatedEvent(taskName))
  }

  private fun addDomainEvent(domainEvent: DomainEvent) {  // ②
    this.domainEvents.add(domainEvent)
  }

  fun getDomainEvents(): List<DomainEvent> {
    return this.domainEvents.toList()
  }

  fun clearDomainEvents() { 
    this.domainEvents.clear()
  }
}

①は、コンストラクタで、引数のtaskNameをプライマリコンストラクタに渡してインスタンス生成します。それと同時に、②のaddDomainEventメソッドに作成したTaskCreatedEventを渡します。このタイミングではドメインイベントは変数domainEventsに蓄積されるだけで、発火はされせん。

発火するのはリポジトリで、以下のような実装になります。

// ドメイン層に定義するリポジトリのabstractクラス
abstract TaskRepository( // ①
  private val applicationEventPublisher: ApplicationEventPublisher
){  
  fun insert(task: Task) {
    insertImpl(entity)
    publishDomainEvent(entity)  // ②
  }

  protected abstract fun insertImpl(task: Task) // ③

  private fun publishDomainEvent(task: Task) {
    task.getDomainEvents()
        .forEach { applicationEventPublisher.publishEvent(it) }  // ④
    task.clearDomainEvents() // ⑤
  }
}

まず、①のように、TaskRepositoryがインターフェイスからabstractクラスに変わりました。その目的は、③のようにinsertの実際の具体的な処理はabstractにしてインフラ層の実装クラスに委譲して、②のようにinsertImplが終わった後にpublishDomainEventメソッドを呼び出してイベントを発火しています。

④では、Taskのイベントを取得してSpringの仕組みapplicationEventPublisherで発火し、⑤で投げ終わったイベントをクリアしています。

// インフラ層に定義するポジトリ実装クラス
class MockTaskRepository : TaskRepository {
  override fun insertImpl(entity: Task) {
    // 実際にDBにインサートする処理を書く
  }
}

インフラ層のリポジトリ実装クラスはこのように、データソースに対応した実装のみになります。

これで、一通りの実装は完了です。

実際はTaskやTaskRepositoryに親クラスを持たせ、そこにドメインイベントに関する処理を委譲します。(今回はコード量が多かったので割愛しました)

ドメインイベント作成に制約をつける

さて、前に少し触れた問題として、「ユースケースから、ドメインイベントが中で作成され、投げられていることが読み解けない」というものがあります。

そこで、ここでもひと工夫を入れてみます。ユースケースの実装が以下のように変わります。

class CreateTaskUseCase3-2(
    private val taskRepository: TaskRepository,
    private val domainEventSeedFactory: DomainEventSeedFactory
) {
  @Transactional
  fun execute(taskName: String) {
    val domainEventSeed = domainEventSeedFactory.createSeed() // ①
    val task = Task(taskName, domainEventSeed) // ②
    taskRepository.insert(task)
  }
}

DomainEventSeedというものを定義し、②のように「中でドメインイベントを作成するメソッドには必ずDomainEventSeedを渡さなければいけない」という制約をかけます。

DomainEventSeedは、①のようにdomainEventSeedFactoryから必ず取得するようにします。こうすることで、「DomainEventSeedが渡されているということは、中でドメインイベントが作成され、他の集約に影響があるんだな」ということを察知できます。

実際にどの集約に影響があるかは、内部で作成されているドメインイベントから参照を追っていけば把握できます。

次に、DomainEventなどの実装をみていきます。

abstract class DomainEvent(seed: DomainEventSeed) : ApplicationEvent(seed)

このように、DomainEventを作成する際にDomainEventSeedのインスタンスが必要になるように制約をかけます。(再度になりますが、ApplicationEventへの引数はあまり気にしないでください)

最後に、DomainEventSeedの実装です。

interface DomainEventSeed

class DomainEventSeedFactory {
  fun createSeed(): DomainEventSeed {
    return DomainEventSeedImpl()
  }

  private class DomainEventSeedImpl : DomainEventSeed
}

このように、DomainEventSeed をインターフェイスにして、実装クラスをDomainEventSeedFactory クラスのプライベートクラスにすることにより、Taskエンティティの中でDomainEventSeedを直接インスタンス生成できないように制約をかけることができます。

TaskCreatedEventも、以下のようにDomainEventSeedを受け取ってDomainEventクラスに渡すようにするだけで制限がかけられるようになります。

class TaskCreatedEvent(
  val taskName: String, 
  seed: DomainEventSeed  // ①
) : DomainEvent(seed)

Kotlinの文法の話になりますが、①のようにvalがついていない値は、コンストラクタには渡されますがTaskCreatedEventクラスの属性として保持せず、DomainEventに渡されるのみになります。

この行な制御を入れることで、「確実に整合性が確保できる」というメリットに加え、ユースケースでも「複数集約間の処理が発生する」ことを読み取れるようになります。

メリット・デメリット

この方法のメリットは、確実に複数集約の整合性を確保できること、集約の更新処理同士の結合度が下がるので個々では凝集度が高くなり、保守しやすいコードになることです。

デメリットは、なにより実装の複雑度が上がることです。導入に至る際、最初に超えるべきハードルが他の2つの方法に比べて高いです。

まとめ

本記事では、複数集約間の整合性を確保する実装方法を3つ紹介しました。

実装方法1から3に進むに連れて、実装コストが上がる一方、集約間の整合性を守る確実さが上がります。この3つはどれが正解というものはなく、実装コストと整合性を守る重要性のバランスを考えてどれを採用するか決定する必要があります。バランスを考慮した結果、1つのプロジェクトの中で複数の実装方法が混在しても問題ありません。

集約の定義について詳しく知りたい方は

本記事では省略した集約の定義やDDD自体の解説については、こちらを読んでいただけるとわかりやすいと思います。

little-hands.booth.pm

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


また、実践にあたって頻出の疑問に対してトピックごとに詳しく解説した書籍もあります。

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

little-hands.booth.pm

現場での導入で困ったら

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

little-hand-s.notion.site



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

*1:companion object内のメソッドは、staticメソッドになると理解してもらればOKです

*2:一般に、高凝集・低結合になるほど保守性は上がり、低凝集・高結合になると保守性が下がります。詳しくはこちらをご覧ください https://youtu.be/-JOravz-qoU