🐼

集約の実装について考えてみた

2021/09/20に公開
6

はじめに

DDD の集約の実装について考えたことをまとめます。

題材

料理のレシピ作成を題材としてまとめていきたいと思います。

概要

概要は以下の通りです。

  • レシピには材料と作り方がある。
  • 材料には食材や調味料などの名前と分量が必要である。
  • 材料はメインとなる材料や合わせダレなどのカテゴリごとにグルーピングできるとよい。
  • 作り方は具体的な手順を示すものである。

ドメインモデル

上記をドメインモデルで表現するとこのようなイメージです。
各種値の範囲はドメインとして決まっているわけではないですが、システム化する上で決めなければならないことだと思いますので、ドメインエキスパートとすり合わせながら運用に支障をきたさない範囲で決定すると良いのかなと思います。
今回は決定した値の範囲をドメインモデルに補足する形で記載しています。

ユースケース

システムに対するユースケースは以下の通りとし、末端のユースケースごとに編集・保存できるイメージとします。(画面のラフ画があると分かりやすいとは思いますが割愛します。)

本題

それでは本題の集約の実装について考えたことをまとめていきたいと思います。
まずは今回のドメインモデルを単純にクラスに落とし込んでいくとこんな感じになるでしょうか。

この設計だと集約ルートが「レシピ」になりますが、集約の範囲が広すぎると感じます。
ここで一度、集約について私の理解を整理したいと思います。

集約に対する理解

集約で大切な考え方として以下の点が挙げられると考えています。

  • 整合性の確保が必要な境界である。
  • 集約内部の変更は必ず集約ルートを経由することで集約内を常に整合性が確保された状態にする。
  • 集約ルートの単位でデータの取得・永続化を行う。

もし集約ルート以外の変更を許可してしまうと、整合性を確保するための処理が利用側に委ねられてしまい、整合性が崩された状態で永続化されてしまう恐れがあります。集約ルート以外のドメインオブジェクト単体での永続化を許可してしまった場合も同様です。

集約の分割

上記の考え方でいくと、確かにレシピを集約ルートにすれば整合性を確保する境界としては十分だと思いますが、例えば 1 つの材料カテゴリの内容を変更したいだけなのにレシピ全体を DB から取得して、変更後のレシピ全体の情報を DB に保存しなければならず非効率に感じます。

そこで、整合性を確保しつつ効率を高めるために集約を分割していきたいと思います。
例えばこのようにできるでしょうか。

材料作り方には整合性を確保するための関係性がないため別集約に分けられましたが、材料カテゴリ手順については、10 個以下、100 個以下というルールがあるため、別集約とすることは難しそうです。
ただ、1 つの材料カテゴリの変更に対して全ての材料カテゴリを更新しなければならず非効率であるという問題は残ってしまいます。
やはり材料カテゴリ手順は別集約にしたいところです。
では、最大個数の整合性を確保しつつ別集約にするにはどのようにすれば良いでしょうか?

例えば材料材料カテゴリ作り方の個数を管理させるのはどうでしょうか。

以下にこの設計に対する実装例を示していきたいと思います。(kotlin を使用します。)

ドメインオブジェクトの実装

ドメインオブジェクトの実装は以下の通りです。(作り方に関する実装は割愛します。)

// 材料(集約ルート)
class Ingredient private constructor(
        val recipeId: RecipeId,
        val categories: List<IngredientCategoryId>
) {
    companion object {
        private const val CATEGORIES_COUNT_MAX = 10

        // 材料を永続領域から復元する
        fun reconstruct(recipeId: RecipeId, categories: List<IngredientCategoryId>): Ingredient {
            return Ingredient(recipeId, categories)
        }
    }

    // 材料カテゴリを追加する
    fun addCategory(categoryId: IngredientCategoryId): Ingredient {
        return categories.plus(categoryId).let {
            require(it.size <= CATEGORIES_COUNT_MAX) {
                "材料カテゴリは $CATEGORIES_COUNT_MAX 個以下でなければなりません。"
            }
            Ingredient(recipeId, it)
        }
    }

    // 材料カテゴリを並び替える
    fun reorderCategories(orderings: List<Pair<IngredientCategoryId, Int>>): Ingredient {
        val reorderedCategories = // 並び替えの実装は省略
        return Ingredient(recipeId, reorderedCategories)
    }

    // 材料カテゴリを削除する
    fun deleteCategory(categoryId: IngredientCategoryId): Ingredient {
        return Ingredient(recipeId, categories.minus(categoryId))
    }
    
    override fun equals(other: Any?): Boolean { /** 省略(recipeId を同一性として実装する。) **/ }
    override fun hashCode(): Int { /** 省略 **/ }
}

// 材料カテゴリ(集約ルート)
class IngredientCategory private constructor(
        val id: IngredientCategoryId,
        val title: IngredientCategoryTitle,
        val ingredientItems: List<IngredientItem>
) {
    companion object {
        private const val INGREDIENT_ITEM_COUNT_MAX = 100

        // 材料カテゴリを新規作成する
        fun create(title: IngredientCategoryTitle, ingredientItems: List<IngredientItem>): IngredientCategory {
            validateIngredientItems(ingredientItems)
            return IngredientCategory(IngredientCategoryId.newId(), title, ingredientItems)
        }

        // 材料カテゴリを永続領域から復元する
        fun reconstruct(
                id: IngredientCategoryId,
                title: IngredientCategoryTitle,
                ingredientItems: List<IngredientItem>
        ): IngredientCategory {
            return IngredientCategory(id, title, ingredientItems)
        }

        private fun validateIngredientItems(ingredientItems: List<IngredientItem>) {
            require(ingredientItems.size <= INGREDIENT_ITEM_COUNT_MAX) {
                "材料は $INGREDIENT_ITEM_COUNT_MAX 個以下でなければなりません。"
            }
        }
    }

    // 材料カテゴリを変更する
    fun change(title: IngredientCategoryTitle, ingredientItems: List<IngredientItem>): IngredientCategory {
        validateIngredientItems(ingredientItems)
        return IngredientCategory(id, title, ingredientItems)
    }

    override fun equals(other: Any?): Boolean { /** 省略(id を同一性として実装する。) **/ }
    override fun hashCode(): Int { /** 省略 **/ }
}

// その他の実装は省略

ユースケースの実装

上記ドメインオブジェクトを利用してユースケースを実装すると以下のようになります。

class RecipeCommandService(
        private val ingredientRepository: IngredientRepository,
        private val ingredientCategoryRepository: IngredientCategoryRepository
) {
    // 材料カテゴリを追加する
    @Transactional
    fun handle(command: AddIngredientCategoryCommand) {
        val ingredient = ingredientRepository.findBy(command.recipeId)
                ?: throw IngredientNotFound()

        val ingredientCategory = IngredientCategory.create(command.title, command.ingredientItems)
        val newIngredient = ingredient.addCategory(ingredientCategory.id)

        ingredientCategoryRepository.save(ingredientCategory)
        ingredientRepository.save(newIngredient)
    }

    // 材料カテゴリを変更する
    @Transactional
    fun handle(command: ChangeIngredientCategoryCommand) {
        val ingredientCategory = ingredientCategoryRepository.findBy(command.id)
                ?: throw IngredientCategoryNotFound()

        val newIngredientCategory = ingredientCategory.change(command.title, command.ingredientItems)

        ingredientCategoryRepository.save(newIngredientCategory)
    }

    // 材料カテゴリを並び替える
    @Transactional
    fun handle(command: ReorderIngredientCategoriesCommand) {
        val ingredient = ingredientRepository.findBy(command.recipeId)
                ?: throw IngredientNotFound()

        val newIngredient = ingredient.reorderCategories(command.orderings)

        ingredientRepository.save(newIngredient)
    }

    // 材料カテゴリを削除する
    @Transactional
    fun handle(command: DeleteIngredientCategoryCommand) {
        val ingredient = ingredientRepository.findBy(command.recipeId)
                ?: throw IngredientNotFound()

        val ingredientCategory = ingredientCategoryRepository.findBy(command.id)
                ?: throw IngredientCategoryNotFound()

        val newIngredient = ingredient.deleteCategory(ingredientCategory.id)

        ingredientRepository.save(newIngredient)
        ingredientCategoryRepository.delete(ingredientCategory)
    }

    // その他のユースケースは省略
}

問題点

「材料カテゴリを変更する」のユースケースでは、対象の材料カテゴリのみ DB からの取得と保存を行うようになっていますし、「材料カテゴリを並び替える」のユースケースでは、材料カテゴリの ID のみ DB からの取得と保存を行うようになっていますので、非効率と問題視していたことについては解消できているように感じます。
しかし、材料カテゴリの個数管理を材料に持たせた結果、「材料カテゴリを追加する」と「材料カテゴリを削除する」のユースケースでは、材料材料カテゴリ両方に対してリポジトリでの保存が必要になってしまい、整合性を確保するための処理が利用側に委ねられてしましました。

ドメインイベントを利用した集約の永続化

整合性を確保しつつ効率的に処理するにはどうすれば良いか試行錯誤した結果、ドメインイベントが利用できるのではないかと考えました。
ドメインイベントといっても Event Sourcing を利用するのではなく、State Sourcing で実装します。
集約ルートをリポジトリに渡して集約全体を DB に保存するのではなく、ドメインイベントをリポジトリに渡して変更点のみを DB に保存するというやり方です。
実装例を以下に示します。

ドメインオブジェクトの実装

集約ルートのメソッドは、整合性を確保するためのバリデーションを行い、問題なければ変更後の集約ルートとドメインイベントのペアを返却します。
ドメインイベントには集約内で変更された情報のみを格納するようにします。

// 材料(集約ルート)
class Ingredient private constructor(
        val recipeId: RecipeId,
        val categories: List<IngredientCategoryId>,
        val version: OptimisticLockingVersion
) {
    companion object {
        private const val CATEGORIES_COUNT_MAX = 10

        // 材料を永続領域から復元する
        fun reconstruct(
                recipeId: RecipeId,
                categories: List<IngredientCategoryId>,
                version: OptimisticLockingVersion
        ): Ingredient {
            return Ingredient(recipeId, categories, version)
        }
    }

    // 材料カテゴリを追加する
    fun addCategory(category: IngredientCategory): Pair<Ingredient, IngredientCategoryAdded> {
        return categories.plus(category.id).let {
            require(it.size <= CATEGORIES_COUNT_MAX) {
                "材料カテゴリは $CATEGORIES_COUNT_MAX 個以下でなければなりません。"
            }
            Pair(Ingredient(recipeId, it, version), IngredientCategoryAdded(recipeId, category))
        }
    }

    // 材料カテゴリを並び替える
    fun reorderCategories(orderings: List<Pair<IngredientCategoryId, Int>>): Pair<Ingredient, IngredientCategoriesReordered> {
        val reorderedCategories = // 並び替えの実装は省略
        return Pair(
                Ingredient(recipeId, reorderedCategories, version),
                IngredientCategoriesReordered(recipeId, reorderedCategories)
        )
    }

    // 材料カテゴリを削除する
    fun deleteCategory(categoryId: IngredientCategoryId): Pair<Ingredient, IngredientCategoryDeleted> {
        return categories.minus(categoryId).let {
            Pair(Ingredient(recipeId, it, version), IngredientCategoryDeleted(recipeId, categoryId, it))
        }
    }

    // 楽観的ロックのバージョンを上げる
    fun updateVersion(nextVersion: OptimisticLockingVersion): Ingredient {
        require(nextVersion.isNext(version))
        return Ingredient(recipeId, categories, nextVersion)
    }

    override fun equals(other: Any?): Boolean { /** 省略(recipeId を同一性として実装する。) **/ }
    override fun hashCode(): Int { /** 省略 **/ }
}

// 材料カテゴリ(集約ルート)
class IngredientCategory private constructor(
        val id: IngredientCategoryId,
        val title: IngredientCategoryTitle,
        val ingredientItems: List<IngredientItem>,
        val version: OptimisticLockingVersion
) {
    companion object {
        private const val INGREDIENT_ITEM_COUNT_MAX = 100

        // 材料カテゴリを新規作成する
        fun create(title: IngredientCategoryTitle, ingredientItems: List<IngredientItem>): IngredientCategory {
            validateIngredientItems(ingredientItems)
            return IngredientCategory(
                    IngredientCategoryId.newId(),
                    title,
                    ingredientItems,
                    OptimisticLockingVersion.initial()
            )
        }

        // 材料カテゴリを永続領域から復元する
        fun reconstruct(
                id: IngredientCategoryId,
                title: IngredientCategoryTitle,
                ingredientItems: List<IngredientItem>,
                version: OptimisticLockingVersion
        ): IngredientCategory {
            return IngredientCategory(id, title, ingredientItems, version)
        }

        private fun validateIngredientItems(ingredientItems: List<IngredientItem>) {
            require(ingredientItems.size <= INGREDIENT_ITEM_COUNT_MAX) {
                "材料は $INGREDIENT_ITEM_COUNT_MAX 個以下でなければなりません。"
            }
        }
    }

    // 材料カテゴリを変更する
    fun change(
            title: IngredientCategoryTitle,
            ingredientItems: List<IngredientItem>
    ): Pair<IngredientCategory, IngredientCategoryChanged> {

        validateIngredientItems(ingredientItems)
        return IngredientCategory(id, title, ingredientItems, version)
                .let { Pair(it, IngredientCategoryChanged(it)) }
    }

    // 楽観的ロックのバージョンを上げる
    fun updateVersion(nextVersion: OptimisticLockingVersion): IngredientCategory {
        require(nextVersion.isNext(version))
        return IngredientCategory(id, title, ingredientItems, nextVersion)
    }

    override fun equals(other: Any?): Boolean { /** 省略(id を同一性として実装する。) **/ }
    override fun hashCode(): Int { /** 省略 **/ }
}

ユースケースの実装

ユースケースの実装では、リポジトリから集約ルートを取得し、集約ルートに対する操作の結果として受け取った変更後の集約ルートとドメインイベントをリポジトリに渡して永続化します。

class RecipeCommandService(
        private val ingredientRepository: IngredientRepository,
        private val ingredientCategoryRepository: IngredientCategoryRepository
) {
    // 材料カテゴリを追加する
    @Transactional
    fun handle(command: AddIngredientCategoryCommand) {
        val ingredient = ingredientRepository.findBy(command.recipeId)
                ?: throw IngredientNotFound()

        val ingredientCategory = IngredientCategory.create(command.title, command.ingredientItems)

        val (newIngredient, event) = ingredient.addCategory(ingredientCategory)

        ingredientRepository.save(newIngredient, event)
    }

    // 材料カテゴリを変更する
    @Transactional
    fun handle(command: ChangeIngredientCategoryCommand) {
        val ingredientCategory = ingredientCategoryRepository.findBy(command.id)
                ?: throw IngredientCategoryNotFound()

        val (newIngredientCategory, event) = ingredientCategory.change(command.title, command.ingredientItems)

        ingredientCategoryRepository.save(newIngredientCategory, event)
    }

    // 材料カテゴリを並び替える
    @Transactional
    fun handle(command: ReorderIngredientCategoriesCommand) {
        val ingredient = ingredientRepository.findBy(command.recipeId)
                ?: throw IngredientNotFound()

        val (newIngredient, event) = ingredient.reorderCategories(command.orderings)

        ingredientRepository.save(newIngredient, event)
    }

    // 材料カテゴリを削除する
    @Transactional
    fun handle(command: DeleteIngredientCategoryCommand) {
        val ingredient = ingredientRepository.findBy(command.recipeId)
                ?: throw IngredientNotFound()

        val ingredientCategory = ingredientCategoryRepository.findBy(command.id)
                ?: throw IngredientCategoryNotFound()

        val (newIngredient, event) = ingredient.deleteCategory(ingredientCategory.id)

        ingredientRepository.save(newIngredient, event)
    }

    // その他のユースケースは省略
}

リポジトリの実装

リポジトリは集約ルートと 1 対 1 で作成します。
インターフェースとしては、DB から集約ルートを復元するためのメソッドと、ドメインイベントの内容を DB に保存するためのメソッドを用意します。
DB については、Event Sourcing や CQRS を利用してコマンドとクエリで DB を分けるということはせず、コマンドとクエリ両方で利用する 1 つの DB を RDB で構築します。

具体的な実装は以下の通りです。

  • 復元処理
    • DB から集約に関係するテーブルを結合してデータを取得し、集約ルートのオブジェクトを生成し、返却します。
  • 保存処理
    • 受け取ったドメインイベントごとに変更内容をのみを DB に保存する処理を実装します。
    • DB に保存する際には、集約ルートが持つ version を利用して楽観的ロックを行い、save() の戻り値としては新たなバージョンに更新した集約ルートを返却します。(戻り値の集約ルートはあまり利用されることはないとは思いますが、アプリケーションサービスの後続処理で利用されることを考慮して返却しておきます。)
    • 受け取れるドメインイベントの型は、対になる集約ルートで生成されるドメインイベントのみとします。(例えば今回の例では、材料で生成するドメインイベントの interface として IngredientEvent を定義しており、材料のリポジトリでは IngredientEvent の実装しか受け取れないようにしています。)
// 材料集約のリポジトリ
class IngredientRepositoryImpl : IngredientRepository {
    override fun findBy(recipeId: RecipeId): Ingredient? {
        val ingredient = // 省略(DB から復元する。)
        return ingredient
    }

    /**
     * イベントの内容を用いて DB 更新する。
     * @throws OptimisticLockingError 楽観的ロックエラー
     */
    override fun save(ingredient: Ingredient, event: IngredientEvent): Ingredient {
        // イベントごとに DB への保存処理を分ける。
        return when (event) {
            is IngredientCategoryAdded -> save(ingredient, event)
            is IngredientCategoriesReordered -> save(ingredient, event)
            is IngredientCategoryDeleted -> save(ingredient, event)
            else -> throw IllegalArgumentException("イベントがサポートされていません。event: ${event.javaClass.name}")
        }
    }

    // 材料カテゴリの追加を保存する。
    private fun save(ingredient: Ingredient, event: IngredientCategoryAdded): Ingredient {
        val currentVersion = ingredient.version
        val nextVersion = ingredient.version.next()

        // 省略
        //   version、nextVersion を用いて楽観的ロックをしつつ、
        //   event が持つ情報を利用して材料カテゴリのテーブルに INSERT する。

        // 新たなバージョンで更新した集約を返却する。
        return ingredient.updateVersion(nextVersion)
    }

    // 材料カテゴリの順番変更を保存する。
    private fun save(ingredient: Ingredient, event: IngredientCategoriesReordered): Ingredient {
        val currentVersion = ingredient.version
        val nextVersion = ingredient.version.next()

        // 省略
        //   version、nextVersion を用いて楽観的ロックをしつつ、
        //   event が持つ情報を利用して材料カテゴリのテーブルの順番カラムを UPDATE する。

        // 新たなバージョンで更新した集約を返却する。
        return ingredient.updateVersion(nextVersion)
    }

    // 材料カテゴリの削除を保存する。
    private fun save(ingredient: Ingredient, event: IngredientCategoryDeleted): Ingredient {
        val currentVersion = ingredient.version
        val nextVersion = ingredient.version.next()

        // 省略
        //   version、nextVersion を用いて楽観的ロックをしつつ、
        //   event が持つ情報を利用して材料カテゴリのテーブルから DELETE し、
        //   材料カテゴリのテーブルの順番カラムを UPDATE する。

        // 新たなバージョンで更新した集約を返却する。
        return ingredient.updateVersion(nextVersion)
    }
}

// 材料カテゴリ集約のリポジトリ
class IngredientCategoryRepositoryImpl : IngredientCategoryRepository {
    override fun findBy(id: IngredientCategoryId): IngredientCategory? {
        val ingredientCategory = // 省略(DB から復元する)
        return ingredientCategory
    }

    /**
     * イベントの内容を用いて DB 更新する。
     * @throws OptimisticLockingError 楽観的ロックエラー
     */
    override fun save(ingredientCategory: IngredientCategory, event: IngredientCategoryEvent): IngredientCategory {
        // イベントごとに DB への保存処理を分ける。
        return when (event) {
            is IngredientCategoryChanged -> save(ingredientCategory, event)
            else -> throw IllegalArgumentException("イベントがサポートされていません。event: ${event.javaClass.name}")
        }
    }

    // 材料カテゴリの変更を保存する。
    private fun save(ingredientCategory: IngredientCategory, event: IngredientCategoryChanged): IngredientCategory {
        val version = ingredientCategory.version
        val nextVersion = ingredientCategory.version.next()

        // 省略
        //   version、nextVersion を用いて楽観的ロックをしつつ、
        //   event が持つ情報を利用して対象の材料カテゴリのテーブルを UPDATE する。

        // 新たなバージョンで更新した集約を返却する。
        return ingredientCategory.updateVersion(nextVersion)
    }
}

まとめ

リポジトリに集約ルートを渡して DB に保存するのではなく、ドメインイベントを渡して DB に保存するようにすることで、ユースケースの実装に整合性を確保するための処理が委ねられることがなく、リポジトリで最小限の DB 更新で済むようになったのではないかと思います。

また、今回の実装方法の注意点として以下の点が挙げられると思っています。

  • リポジトリに渡すドメインイベントは、必ず集約ルートで返却されたものを利用すること。
    • 個別に生成したドメインイベントを渡すことを許可してしまうと、整合性が崩されてしまう恐れがあるためです。
  • 最低でも楽観的ロックを行う必要がある。
    • 変更点のみを DB に保存するため、他からの操作とバッティングした場合、整合性が崩されてしまう恐れがあるためです。

ちなみに今回の実装例は GitHub にありますので、よろしければご覧いただければと思います。

2021-10-01 追記

「1 つの材料カテゴリを更新したいだけなのに材料全体を DB から取得して、材料全体を DB に保存しなければならず非効率である。」という問題を解決したいと思い、集約を分割しつつ集約間の整合性を利用側に委ねることなく確保するためにはどうすれば良いかを考えた末、今回の実装方法に至りましたが、結果として、リポジトリの実装で他の集約に関するテーブル更新が必要になってしまいました。(材料リポジトリで材料カテゴリ集約のテーブル更新をしている。)

コメントいただいた結果、やはり強い整合性を確保したいのであれば同じ集約にするのが良いという結論になりました。
同じ集約にした場合の実装例は下記 v4 パッケージにあります。
https://github.com/TakashiOnawa/ddd-aggregate-sample/tree/main/src/main/kotlin/com/example/dddaggregatesample/v4

材料材料カテゴリを同じ集約にした場合、元々の「1 つの材料カテゴリを更新したいだけなのに材料全体を DB から取得して、材料全体を DB に保存しなければならず非効率である。」という問題が解決できるか、と考えると、DB への保存についてはドメインイベントを利用して差分だけ DB 更新することで解決可能ですが、DB からの取得についてはやはり材料全体の取得が必要であるため解決できません。

ただ今回のケースだと、最大個数を、材料カテゴリは 10 個、材料要素は 100 個に設定してはいるものの、実際にはもっと少ない数になるとは思いますので、材料全体を取得することは許容するという判断も可能だと思います。

それでもメモリやパフォーマンスを気にしなければならないような場合は、集約を分割し、ユースケースの実装で集約間の整合性を確保するのもやむなしになるのかなと思います。

参考

Discussion

j5ik2oj5ik2o

こんにちはー。興味深い記事をありがとうございます。

しかし、材料カテゴリの個数管理を材料に持たせた結果、「材料カテゴリを追加する」と「材料カテゴリを削除する」のユースケースでは、材料と材料カテゴリ両方に対してリポジトリでの保存が必要になってしまい、整合性を確保するための処理が利用側に委ねられてしましました。

これを読んだとき、おや?と思いました。集約として分けたときそもそも強い整合性は保てなくなるのに、なぜまた強い?整合性を確保しようとしているのか?仕様に矛盾があると思いました。これを問題提起するなら、材料と材料カテゴリは一つの集約にしたいというように聞こえます。

たぶん、一つの集約にしたくないけど、強い整合性のルールも課したいというのはパラドックスです。これはどこかでバランスを取らざるを得ないと思います。別々の集約にするなら、弱い整合性(結果整合性)を使うしかありません。

ユースケースレベルで、複数の集約を期待する状態に変更する方法に不満があり、なんらかのドメイン上の制約を与えたいなら、以下のようなドメインサービス(サービスという名前には混乱があるのでファンクションと呼んでます)を使う方法もあります。

// 材料カテゴリを追加する場合
val (ingredientCategory, newIngredient) = IngredientCategoryFunctions.createToIngredient(command.title, command.ingredientItems, ingredient)

とはいっても、開発者がこのドメインファンクションを利用しなかったら同様の問題は起きます。完全な対策のために、余計な複雑さが取り込まれないようにしないと、本末転倒ですね…。

※「材料カテゴリを削除する」では、複数の集約に跨がる操作がないので、ドメインファンクションは不要だと思います。

あと、IngredientRepositoryImplの実装で外部の集約のテーブルを更新するのは集約の原則を破っていると思います。責務上、材料リポジトリは材料集約の境界に対応づくテーブル(材料テーブル)にしかI/Oできないはずです。越境してよその集約を更新するのは問題だと思いました(もちろん、諸問題の兼ね合いで原則をやぶるときはありますが、いつもは原則は破らないのでこういう設計にはしないと思います)

https://github.com/TakashiOnawa/ddd-aggregate-sample/blob/main/src/main/kotlin/com/example/dddaggregatesample/v3/infrastructure/IngredientRepositoryImpl.kt#L36

https://github.com/TakashiOnawa/ddd-aggregate-sample/blob/main/src/main/kotlin/com/example/dddaggregatesample/v3/infrastructure/IngredientRepositoryImpl.kt#L49

https://github.com/TakashiOnawa/ddd-aggregate-sample/blob/main/src/main/kotlin/com/example/dddaggregatesample/v3/infrastructure/IngredientRepositoryImpl.kt#L62

Takashi OnawaTakashi Onawa

コメントありがとうございます。

「1 つの材料カテゴリを更新したいだけなのに材料全体を DB から取得して、材料全体を DB に保存しなければならず、非効率である。」という問題を解決したいと思い、集約を分けつつ強い整合性のルールも課すにはどうしたら良いかを考えた末、今回の実装方法に至りました。
(集約が分けられるのでは?と思った理由は、材料が管理したいことは、材料カテゴリの個数と順番だけであり、材料カテゴリの中身について知る必要はないのではと考えたためです。)

結果として、リポジトリの実装で他の集約に関するテーブルへの更新が必要になってしまったことについては私自身も確かに気になっていたところではあります。
しかし、集約をどのテーブルから構築して、どのテーブルに書き込むかはインフラ層で自由に実装してよいのでは?と考え、集約の境界とテーブルの境界が異なる状態でも良しとしました。

ただ、リポジトリの実装をしていく中で、材料と材料カテゴリの両リポジトリを気にしながら実装している自分もいました。
そう考えると、今回コメントいただいたこともあり、やはり集約の境界とテーブルの境界は同じにした方が良いのかなという思いにもなりました。

では、材料と材料カテゴリを同じ集約にしたときに元々問題視していたことは解決できるか、と考えると、集約の変更についてはドメインイベントを用いて差分だけ DB 更新することで解決できると思いますが、DB からの取得についてはやはり材料全体の構築が必要になるので解決できないと思いました。

ただ、最大個数を 10 個や 100 個に設定しているものの、実際はもっと少ない数になると思うので、材料全体を読み込むのも許容できるのかなと感じました。

同じ集約にした場合のコードを下記 v4 パッケージに実装してみましたので、もしよろしければ見ていただけると嬉しいです。
https://github.com/TakashiOnawa/ddd-aggregate-sample/tree/main/src/main/kotlin/com/example/dddaggregatesample/v4

j5ik2oj5ik2o

実装の追加 ありがとうございます。拝見しました。

結果として、リポジトリの実装で他の集約に関するテーブルへの更新が必要になってしまったことについては私自身も確かに気になっていたところではあります。
しかし、集約をどのテーブルから構築して、どのテーブルに書き込むかはインフラ層で自由に実装してよいのでは?と考え、集約の境界とテーブルの境界が異なる状態でも良しとしました。

当該集約の状態を変えられるのは当該集約のみで、外部の他のオブジェクトが自由にできないですよね。この原則を守らないと集約の存在意義がないと思います。おっしゃるような設計が絶対ないかというとそういうことはないと思いますが、一般的には極力避けて原則を守ることになると思います。

では、材料と材料カテゴリを同じ集約にしたときに元々問題視していたことは解決できるか、と考えると、集約の変更についてはドメインイベントを用いて差分だけ DB 更新することで解決できると思いますが、DB からの取得についてはやはり材料全体の構築が必要になるので解決できないと思いました。

確か実践ドメイン駆動設計にも書いてあったと思いますが、一般的には、強いルールを強制したければ強い整合性境界を持つ同じ集約内に含めるしかなく、一方でメモリサイズが許容できないのであれば分割するしかないですね。その代わり強いルールを強制できなくなります。

ただ、最大個数を 10 個や 100 個に設定しているものの、実際はもっと少ない数になると思うので、材料全体を読み込むのも許容できるのかなと感じました。

そうですね。集約内に含めるのであれば、無限個というわけにはいかないと思いますので、上限を設定することになりますね。

同じ集約にした場合のコードを下記 v4 パッケージに実装してみましたので、もしよろしければ見ていただけると嬉しいです。

こっちのがわかいやすいと思いました。性能上の問題は実際に負荷試験しないとわからないですし。

ですが、気になったのは

TODO("省略(DB から復元する。)")

というところですね。

イベントを扱うのはよいのですが、それをどのようにI/Oするのかですね。イベントは更新操作の差分だけを表しているので保存時のオーバーヘッドは少ないのでまるっと集約の状態をそのまま書き込むより有利ですね。ですが、イベントを保存したところでそれをどう使うのでしょうか。たぶん、それが”DBから復元する”に関係すると思います。保存したイベント列をすべて読み込んで…どうすればいいか。リポジトリで返したいのはイベントではなく状態ですよね。

もうお気づきだと思いますが、この話は Event Sourcingの話と同じです。イベント列から状態を作り出せないとリポジトリが機能を提供できませんよね。イベントをうまく使おうとするならば、結局Event Sourcingすることになると思います。

Event Sourcingまでやりたくないなら、このイベントは使わないで普通にCRUDするしかないと思います。

僕はこれがCRUDの限界だと思っています。つまり”効率的にI/Oしたい”が発展すると自然とEvent Sourcingになってしまうと思います。


と、ここまでが今までのステートソーシングというかCRUD前提での設計論です。

「1 つの材料カテゴリを更新したいだけなのに材料全体を DB から取得して、材料全体を DB に保存しなければならず、非効率である。」はアクターモデル(でかつEvent Sourcingを使う前提)を使えばほぼ解決できます。

集約をアクターとして実装して、リクエストがあった初回だけDBから集約のイベント列を読み込み、ランタイム上に再生(リプレイ)します。この集約アクターはワークロードがあるうちは起動したままになります。つまり、上記のCRUD設計だと毎回読み込みますが、読み込みが最初の一回のみです。

次にリクエストが来た場合は起動している状態の集約アクターが処理します。DBからの読み込みは発生せずにコマンドが受理されれば、差分のイベントだけがDBに保存されます。集約アクターとDBは完全に同期されているので、DBからの読み込みがありません。DBから書き込みのみになります。真の状態はアプリケーションが握っていて、DBはアプリケーションのバックアップになります(CRUDではアプリケーション状態はDBの中にあります)

リプレイコストが小さくなったとしても、集約内部のオブジェクトを無限個保持するわけにはいかないというのはありますね…。たとえば、内部に保持するオブジェクトをページ単位で管理に、最も良く使う直近のページだけをリプレイしてメモリに保持するという方法もできなくはないです。複雑になりますが。

と、理想に近いと思いますが、かなりのパラダイムシフトですし、これを実現したいなら Scala/Akka or Erlang/Elixir を使わないとできません。

あ、他にもEvent Storeを使う方がありますが、実績の乏しい方法になりますね。

Akkaならイベントのストレージは既存のDBが使えます。僕は以下のプラグインを自作して使っています。他にもいろいろあります。

Takashi OnawaTakashi Onawa

実装を見ていただきありがとうございます。

イベントを扱うのはよいのですが、それをどのようにI/Oするのかですね。イベントは更新操作の差分だけを表しているので保存時のオーバーヘッドは少ないのでまるっと集約の状態をそのまま書き込むより有利ですね。ですが、イベントを保存したところでそれをどう使うのでしょうか。たぶん、それが”DBから復元する”に関係すると思います。保存したイベント列をすべて読み込んで…どうすればいいか。リポジトリで返したいのはイベントではなく状態ですよね。

保存の際にはイベントをそのままイベント列として保存するのではなく、イベントが持つ情報を利用して各テーブルを更新するイメージで、
復元の際には各テーブルを結合してデータを取得し、集約を生成するイメージでした。
下記コードに SQL のイメージを記載して見ました。(実際には ORM を利用しているのですが、ニュアンスが分かっていただければ幸いです。)

https://github.com/TakashiOnawa/ddd-aggregate-sample/blob/main/src/main/kotlin/com/example/dddaggregatesample/v4/infrastructure/IngredientRepositoryImpl.kt

リポジトリに集約ルートのみを渡して保存するやり方だと集約全体に対応するテーブルを保存しなければならないので、変更点だけ保存するやり方がないかを模索していました。
変更点だけを保存する場合、リポジトリに何が変更されたかを伝える手段が必要となりますが、例えばリポジトリに集約内のオブジェクトを単体で渡せるように I/F を用意してしまうと、集約でバリデーションされていないオブジェクトも渡せてしまうので整合性が崩されてしまう恐れがありますし、ユースケースごとにメソッドを生やす必要も出てきてしまいます。

そこで着目したのがドメインイベントで、集約でしか発行されないドメインイベントをリポジトリに渡すことで、整合性が崩されてしまう恐れはなくなりますし、ドメインイベントごとに更新すべきテーブルが決まりますので変更点だけを保存することができると考えました。

この方法は、かとじゅんさんの記事 に書かれている Read Model Updater の実装例をヒントにさせていただきました。
そちらでは、ドメインイベントごとにリード DB を更新する SQL を実行するようなコードとなっており、このやり方を State Sourcing で応用できないかと思った次第です。


アクターモデルに関するご説明、プラグインの紹介につきましても、どうもありがとうございます。
かとじゅんさんの記事を色々と拝見させていただき、アクターモデルで Event Sourcing を使えば解決できるんだろうなーとは感じてたりしたのですが、私自身 Event Sourcing で開発したことがなく、手を出すことにためらいがあったため、State Sourcing で効率的な I/O のやり方を模索していた感じではあります。
今回を機に Akka や Event Sourcing について学びたいなという気持ちになれました。

j5ik2oj5ik2o

保存の際にはイベントをそのままイベント列として保存するのではなく、イベントが持つ情報を利用して各テーブルを更新するイメージで、
復元の際には各テーブルを結合してデータを取得し、集約を生成するイメージでした。
下記コードに SQL のイメージを記載して見ました。(実際には ORM を利用しているのですが、ニュアンスが分かっていただければ幸いです。)

この方法は、かとじゅんさんの記事 に書かれている Read Model Updater の実装例をヒントにさせていただきました。
そちらでは、ドメインイベントごとにリード DB を更新する SQL を実行するようなコードとなっており、このやり方を State Sourcing で応用できないかと思った次第です。

読み込みはすべてのデータを読み込むが、更新はドメインイベントの差分情報を元に差分更新を行いたいということですね。把握です。

もともとの課題が「1 つの材料カテゴリを更新したいだけなのに材料全体を DB から取得して、材料全体を DB に保存しなければならず、非効率である。」だったので、
差分更新だけできればいいは”材料全体を DB に保存しなければならずに対応してますが、”材料全体を DB から取得して”はどうしますかね?ということが気になりました。もちろん解決しないという選択肢もありますね。

それこそアクターモデルじゃないと難しいかもです。

今回を機に Akka や Event Sourcing について学びたいなという気持ちになれました。

パラダイムシフトしてしまうので学ぶ負荷はあがりますね…。

もし 学ぶなら、Lightbend Academy で概念から先に学ぶとよいです。認定証ももらえます。勝手和訳は僕のほうで作っているので必要なら twitter などでご連絡ください。

https://note.com/j5ik2o/n/n513d028fd7b8

Takashi OnawaTakashi Onawa

もともとの課題が「1 つの材料カテゴリを更新したいだけなのに材料全体を DB から取得して、材料全体を DB に保存しなければならず、非効率である。」だったので、
差分更新だけできればいいは”材料全体を DB に保存しなければならずに対応してますが、”材料全体を DB から取得して”はどうしますかね?ということが気になりました。もちろん解決しないという選択肢もありますね。

はい、読み込みについては解決していないですね。
最大個数を、カテゴリは 10 個、材料要素は 100 個に設定してはいるものの、実際にはもっと少ない数になるとは思いますので今回のケースでは DB からの取得については「解決しない」という選択もありかと思いました。

もし 学ぶなら、Lightbend Academy で概念から先に学ぶとよいです。認定証ももらえます。勝手和訳は僕のほうで作っているので必要なら twitter などでご連絡ください。

ありがとうございます。
実は Lightbend Academy のリアクティブ・アーキテクチャのコースは私も受講済みで認定証もらっていたりします。(それなのに、というのはご勘弁をww)
学ぶ際には見直したりしようと思います。