HackToTech

Hack To Technology

SpringBootで@Repositoryがついたクラスのメソッドから投げられた例外を変換して投げ直す

個人的な備忘録

レイヤードアーキテクチャでアプリケーションを作っていて、
賛否両論あると思うがドメイン層のバリデーションを作成時とDBからの再構成時にチェックしていて、
作成時は例外をそのまま投げたいが、再構成時はそうしたくないと思っていたので、表題のようなことをしたかった
(変なデータが入ったりしなければこんなことをしなくても良いが、特殊なケースで起こったりする為、モヤモヤしていたので調べていた)

例えば、↓みたいなクラスをドメインとして持っていて、 init でバリデーションを行っているとする
バリデーションに失敗した場合は、 DomainException をスローする

data class UserId(val value: UUID) {
    companion object {
        fun generate(): UserId {
            return UserId(UUID.randomUUID())
        }
    }
}

data class User(
    val id: UserId,
    val name: UserName,
) {
    companion object {
        fun of(
            name: UserName,
        ): User {
            return User(
                id = UserId.generate(),
                name = name,
            )
        }
    }
}

data class UserName(val value: String) {
    companion object {
        private const val MIN_LENGTH = 1
        private const val MAX_LENGTH = 256

        fun of(value: String): UserName {
            return UserName(value)
        }
    }

    init {
        if (MIN_LENGTH > value.length ||
            MAX_LENGTH < value.length) {
            throw DomainException(
                message = "ユーザ名は${MIN_LENGTH}以上${MAX_LENGTH}以下の長さである必要があります",
            )
        }
    }
}

リポジトリのインターフェースはとりあえず保存とID検索だけ用意する

interface UserRepository {
    fun findById(id: UserId): User
    fun save(user: User)
}

実装は以下

@Repository
class UserRepositoryImpl: UserRepository {
    private val user1 = UserEntity(
        id = UUID.fromString("a687b0bc-0887-483c-8851-4604657162c1"),
        name = "", // なんかしらの理由で元々入っていたとする
    )

    private val user2 = UserEntity(
        id = UUID.fromString("bb1304ea-8d78-4b99-b4d1-ce4000c1c9ab"),
        name = "テストユーザ",
    )

    private val map = ConcurrentHashMap(
        mapOf(
            Pair(
                user1.id,
                user1,
            ),
            Pair(
                user2.id,
                user2,
            ),
        )
    )
    override fun findById(id: UserId): User {
        return map.getOrElse(id.value) { throw RuntimeException("ユーザが存在しません") }
            .toUser()
    }

    override fun save(user: User) {
        val entity = UserEntity(
            id = user.id.value,
            name = user.name.toString(),
        )

        if(map.putIfAbsent(user.id.value, entity) != null) {
            throw RuntimeException("ユーザがすでに存在します")
        }
    }
}

今回はDBを使ってないが、DBを使った場合を想定して、DB用のモデルを用意する
ここでDBのモデルからドメインモデルの変換をするが、バリデーションに失敗した場合は DomainException がスローされる

data class UserEntity (
    val id: UUID,
    val name: String,
) {
    fun toUser(): User {
        return User(
            id = UserId(value = id),
            name = UserName(value = name),
        )
    }
}

モデルの変換をするのは @Repository のアノテーションがついたクラスなので、
AOPを使って @Repository 内のメソッドで DomainException が投げられたら DomainMappingException に変換して投げ直すことにした
あとはそれぞれの例外に応じて、例外ハンドラーを設定すればいい感じに作成時と再構成時で振る舞いを変えられる

@Aspect
@Component
class RepositoryExceptionTranslator {
    @AfterThrowing(value = "@within(org.springframework.stereotype.Repository)", throwing = "e")
    fun translate(e: Throwable) {
        throw when(e) {
            is DomainException -> DomainMappingException(e)
            else -> e
        }
    }
}

こういうときはやっぱりAOPが便利だなといった感想
試していたソースのコードは↓

github.com