🐚

削除のビジネスロジックをドメイン層に閉じ込める簡単で強力な「DeletableIDパターンの紹介」

2023/12/13に公開

株式会社ログラス Productチーム Advent Calendar 2023 13日目「削除のビジネスロジックをドメイン層に閉じ込める簡単で強力な『DeletableIDパターンの紹介』」

はじめに

〇〇を削除できるかどうかのビジネス処理、皆さんはどう実装していますか?

同僚の話題になった記事でも削除の認可処理をどこに記述すべきか?は難しいと説明されています。今回はお題は認可っぽいもので書きますが広範に「削除ができるかどうか?」のビジネスロジックをドメイン層にどう閉じ込めるかの便利な実装パターンを紹介します。

https://zenn.dev/loglass/articles/76e559f1a13776

削除処理のビジネスロジックの取り扱いは難しい

削除処理のビジネスロジックの実装はシンプルだけど更新処理や作成処理と比べて意外と難しいです。
それはなぜかというとドメインオブジェクト内の実装に削除処理を書くことができないからです。

例えば権限に管理者と一般ユーザーの二つの権限があるとします。

enum class Role {
    ADMIN, // 管理者
    NORMAL, // 一般ユーザー
}

以降はTodo管理アプリで管理者だけがCRUD処理できるという認可処理を考えていきます。

class Todo private constructor(
    val id: TodoId,
    val content: String,
) {
    fun update(content: String, role: Role): Todo { ... }

    companion object {
        fun create(content: String, role: Role): Todo { ... }
    }	
}

data class TodoId(
    val value: String,
)
class TodoRepository {
    fun create(todo: Todo) { ... }

    fun save(todo: Todo) { ... }

    fun delete(id: TodoId) { ... }
    
    fun find(id: TodoId): Todo? { ... }
}

作成処理の場合

管理者のみがTodoを作成できる場合は以下のようにドメインロジックに認可処理を閉じ込めることができます。

data class Todo(
    val id: TodoId,
    val content: String,
) {

    companion object {
        fun create(content: String, role: Role): Todo {
            val isAllowed = role == Role.ADMIN

            if (!isAllowed) throw Exception("permission error")

            return Todo(
                id = TodoId("1"),
                content = content,
            )
        }
    }
}
fun main() {
    val todoRepository = TodoRepository()
    
    // 認証情報から取得されたRoleだとする
    val role = Role.ADMIN
    
    val todo = Todo.create("content", role)
    todoRepository.create(todo)
}

更新処理の場合

更新処理も同様に管理者のみがTodoを作成できる場合はドメインロジックに認可処理を閉じ込めることができます。

class Todo private constructor(
    val id: TodoId,
    val content: String,
) {

    fun update(content: String, role: Role): Todo {
        val isAllowed = role == Role.ADMIN

        if (!isAllowed) throw Exception("permission error")

        return Todo(
            id = id,
            content = content,
        )
    }
}
fun main() {
    val todoRepository = TodoRepository()
    
    // 認証情報から取得されたRoleだとする
    val role = Role.ADMIN
    
    val todo = todoRepository.find(TodoId("1")) ?: throw Exception("not found")
    val updatedTodo = todo.update("updated content", role)
    
    todoRepository.save(updatedTodo)
}

削除処理の場合

しかし削除処理はうまくいきません。なぜなら削除はIDの呼び出しだけでいいので特段ドメイン層に書くべきものがないからです。

fun main() {
    val todoRepository = TodoRepository()
    
    // 認証情報から取得されたRoleだとする
    val role = Role.ADMIN

    val todo = todoRepository.find(TodoId("1")) ?: throw Exception("not found")
    
    // 削除認可処理をユースケースやコントローラで書かざるを得なくなる
    if (role != Role.ADMIN) {
        throw Exception("permission error")
    }
    
    // ID指定だけで削除できてしまう
    todoRepository.delete(todo.id)
}

ドメインロジックとして削除処理を書けないと何がまずいのか?

問題1: 別経路の削除処理を実装した時に認可処理を実装し忘れる

例えば一括でTodoを消したいとなったときに認可処理の実装を忘れてしまう可能性があります。

fun bulkDeleteTodos(todoIds: List<TodoId>) {
  // 認可処理をわすれてしまう
  todoIds.forEach { todoRepository.delete(it) }
}

問題2: ドメインオブジェクトの単体テストでテストを実装できず、ユースケースの単体テストか結合テストになる

当たり前ですがドメインロジックではないので依存関係が少ないドメイン側の単体テストで実装することができなくなります。

DeletableIDパターンの紹介

ここでDeletableIDパターンを紹介します。インスタンス生成の可視性をコントロールし、型の力で特定の処理をしないとRepositoryのdelete処理をできないようにします。

ステップは以下の2つです。

  1. ドメインロジック内からしかインスタンスを作れないDeletableIDという型を作成する
  2. リポジトリの削除メソッドはDeletableIDの型しか受け取らないようにする

これをすることで Todo.delete という認可処理を含むドメインのメソッドが返却するTodoのIDしか削除できなくなり、削除のビジネスロジックをドメイン層に閉じ込めることができます。

class Todo private constructor(
    val id: TodoId,
    val content: String,
) {
    fun delete(role: Role): DeletableTodoId {
        val isAllowed = role == Role.ADMIN

        if (!isAllowed) throw Exception("permission error")

        return DeletableTodoIdImpl(id.value)
    }
}

sealed interface DeletableTodoId {
    val value: String
}
private data class DeletableTodoIdImpl(override val value: String) : DeletableTodoId
class TodoRepository {
    fun delete(id: DeletableTodoId) { ... }
}
fun main() {
    val todoRepository = TodoRepository()
    
    // 認証情報から取得されたRoleだとする
    val role = Role.ADMIN

    val todo = todoRepository.find(TodoId("1")) ?: throw Exception("not found")

    val deletableTodoId = todo.delete(role)
    todoRepository.delete(deletableTodoId)
}    

ステップ1: ドメインロジック上からしかインスタンスを作れないDeletableIDという型を作成する

ドメインロジック上からしかインスタンスを作れないDeletableIDという型を作成します。
Kotlinでは、package privateなどの柔軟な可視性の制御ができないのでコンストラクタを持たないinterfaceを定義して、その実装クラスをprivateクラスで実装します。
ある特定の箇所からしかインスタンスが作成できない というのが達成できればどんな言語でも大丈夫です。

class Todo private constructor(
    val id: TodoId,
    val content: String,
) {
    fun delete(role: Role): DeletableTodoId {
        val isAllowed = role == Role.ADMIN

        if (!isAllowed) throw Exception("permission error")

        return DeletableTodoIdImpl(id.value)
    }
}

sealed interface DeletableTodoId {
    val value: String
}
private data class DeletableTodoIdImpl(override val value: String) : DeletableTodoId

ステップ2: リポジトリの削除メソッドはDeletableIDの型しか受け取らないようにする

リポジトリの削除メソッドはDeletableIDの型しか受け取らないようします。
今回の例だとDeletableTodoIdは認可処理をもつドメインロジックをもつdeleteメソッドしかインスタンスが作れず、さらにDeletableTodoIdしかリポジトリの削除メソッドは受け付けないので認可処理を通さずに削除することができなくなります。

class TodoRepository {
    fun delete(id: DeletableTodoId) { ... }
}
  • OKパターン
fun main() {
    val todoRepository = TodoRepository()
    
    // 認証情報から取得されたRoleだとする
    val role = Role.ADMIN

    val todo = todoRepository.find(TodoId("1")) ?: throw Exception("not found")

    val deletableTodoId = todo.delete(role)
    todoRepository.delete(deletableTodoId)
}    
  • NGパターン
fun main() {
    val todoRepository = TodoRepository()

    val todo = todoRepository.find(TodoId("1")) ?: throw Exception("not found")

    // コンパイルエラー
    todoRepository.delete(todo.id)
}    

どう良いか?

問題1に対して: 別経路の削除処理を実装した時に認可処理を実装し忘れない

DeletableTodoIdを発行しないと削除処理が呼び出せないのでどの経路から削除しようとも認可処理の呼び出しを強制できるようになりました。

// NG
fun bulkDeleteTodos(todoIds: List<TodoId>) {
  // DeletableTodoIdではないのでコンパイルエラーに
  todoIds.forEach { todoRepository.delete(it) }
}

// OK
fun bulkDeleteTodos(todos: List<Todo>, role: Role) {
  // 認可処理が強制される
  val deletableTodoIds = todos.map { it.delete(role) }
  deletableTodoIds.forEach { todoRepository.delete(it) }
}

問題2に対して: ドメインオブジェクトの単体テストが実装可能になる

認可のチェックに関してはDeletableTodoIdを発行できるか?というテストで事足りるのでドメインオブジェクトの単体テストで簡単に品質が保証できるようになりました。

class TodoTest {

    @Test
    fun `管理者ロールのみTodoを削除できること`() {
        val todo = Todo.reconstruct(
            id = TodoId("1"),
            content = "content",
        )

        val actual = todo.delete(Role.ADMIN)

        assertEquals("1", actual.value)
    }

    @Test
    fun `一般ユーザーはTodoを削除できないこと`() {
        val todo = Todo.reconstruct(
            id = TodoId("1"),
            content = "content",
        )

        assertThrows<Exception> { todo.delete(Role.NORMAL) }
    }
}

もちろん認可処理でなくても使える

ここでは認可処理を実現するための例として挙げましたが、それ以外の「削除できるかどうか」のロジックにも応用することができます。
例えばステータスが進行中のTodoのみ削除できる(=完了したTodoは削除できない)などできるようになります。

class Todo private constructor(
    val id: TodoId,
    val content: String,
    val status: Status,
) {
    fun delete(): DeletableTodoId? {
        val isAllowed = status == Status.IN_PROGRESS
	
	if (!isAllowed) throw Exception("Only in-progress tasks can be deleted")

        return DeletableTodoIdImpl(id.value)
    }
}

sealed interface DeletableTodoId {
    val value: String
}
private data class DeletableTodoIdImpl(override val value: String) : DeletableTodoId

enum class Status {
    IN_PROGRESS,
    COMPLETED,
}

まとめ

プロダクトの根本から変えずとも簡単なコードの変更で削除のビジネスロジックをドメイン層に閉じ込めることができました。
削除処理(物理削除)はやり直し、復元が難しく業務としてもリスクのある業務です。そのためビジネスロジックの担保が重要になりますが今回の記事をきっかけにドメイン層に閉じ込める実装パターンを知ってもらえれば幸いです!

株式会社ログラス テックブログ

Discussion