Spring で例外を処理するより良い方法

# はじめに
こんにちは、Kopherと申します。今回は Spring で例外を処理する方法を説明したいと思います。
小さな個人ブログを開発していると仮定して説明したいと思います。

### 使われる技術スタック
Kotlinを使用していますがJavaを使っても同様です。
- Spring Boot 3.4.4
- Kotlin 1.9.25

# まずはテスト
存在しないPostを照会すると例外を出すようにしたいです。

```kotlin
@Test
fun getPost() {
    // given
    val post = Post(
        _title = "123456789012345",
        _content = "bar",
    )
    postRepository.save(post)

    // expected
    assertThatThrownBy { postService.get(2L) }
        .isInstanceOf(IllegalArgumentException::class.java)
        .hasMessage("Post not found")
}
```

テストを実行しテストの失敗（レッド）をみてから実装します。

```kotlin
@Service
class PostService(
    private val postRepository: PostRepository,
) {

    fun get(id: Long): PostResponse {
        val post = postRepository.findByIdOrNull(id)
            ?: throw IllegalArgumentException("Post not found")

        return PostResponse(
            id = post.id!!,
            title = post.title,
            content = post.content
        )
    }
}
```

はい、実装が終わりまたテストを実行するとテストが成功（Green）します。

![](https://storage.googleapis.com/zenn-user-upload/fde438fa44e8-20250329.png)

これでも良いかもしれないですが、もっとベター(Better)な方法があります。

# もっとベターな方法
`IllegalArgumentException` や `NullPointerException` など、デフォルトで定義されている例外を使用するのも良いですが、サービスに適した、ドメインに特化した、つまりそのビジネスをより適切に表現できる独自の例外を定義して使用する方が、より良い設計と言えます。
例外を見るだけで、どのようなエラーか予測でき、どのドメインで例外が発生したのかをすぐに把握できるためです。

```bash
java.lang.IllegalArgumentException: Post not found
	at jp.co.kopher.kopherlog.service.PostService.get(PostService.kt:31)
...
```


```bash
jp.co.kopher.kopherlog.exception.PostNotFound: Post not found
	at jp.co.kopher.kopherlog.service.PostService.get(PostService.kt:32)
...
```

では、ビジネスに特化した例外を作りましょう！

## ビジネスに特化した例外を作る

```kotlin
class PostNotFound : RuntimeException("Post not found")
```

この例外を使うように修正しましょう

```kotlin
@Test
fun getPost() {
    // given
    val post = Post(
        _title = "123456789012345",
        _content = "bar",
    )
    postRepository.save(post)

    // expected
    assertThatThrownBy { postService.get(2L) }
        .isInstanceOf(IllegalArgumentException::class.java)
        .hasMessage("Post not found")
}
```

```kotlin
@Service
class PostService(
    private val postRepository: PostRepository,
) {

    fun get(id: Long): PostResponse {
        val post = postRepository.findByIdOrNull(id) ?: throw PostNotFound()

        return PostResponse(
            id = post.id!!,
            title = post.title,
            content = post.content
        )
    }
}
```

これでどういう例外が発生してどういう意味なのかが明確になったと思います。

# 例外をどうフロントに返すかも大事

何もかもSpringが基本定義してくれたError Responseで良いのでしょうか
その Error Responseはビジネスをよく表現できていますでしょうか

私はフロントにこういうふうに返したいと思います。
```json
{
      "code": "400",
      "message": "必須です。"
      "validation" : {
          "title": "タイトルを入力してください。"
      }
}
```

この要件をかなうためのErrorResponseを実装します。

```kotlin
data class ErrorResponse(
    private var _code: String,
    private var _message: String,
    private val _validation: MutableMap<String, String> = mutableMapOf()
) {
    fun addValidation(fieldName: String, errorMessage: String) {
        this._validation[fieldName] = errorMessage
    }

    val code: String
        get() = _code

    val message: String
        get() = _message

    val validation: Map<String, String>
        get() = _validation
}
```

後はExceptionを処理するExceptionHandlerを作成します。
なぜ既存のControllerではなく別途ExcepotionControllerを作るのか気になる方がいらっしゃるかもしれないですので説明すると
1. 関心事の分離（Separation of Concerns）の原則に反する
コントローラーは主にリクエストの処理とレスポンスの生成を担当するべきであり、例外処理のロジックを同じクラスに含めると、役割が混在し、コードの可読性や保守性が低下します。

2. コードの再利用性が低下する
例外処理を個別のハンドラーに分けることで、複数のコントローラーで共通の例外処理を適用しやすくなります。コントローラーごとに例外処理を定義すると、重複したコードが増え、メンテナンスが困難になります。

3. テストやデバッグの難易度が上がる
例外処理がコントローラーと一緒に実装されていると、単体テストやデバッグ時に影響範囲が広がり、不具合の原因を特定しにくくなります。例外ハンドラーを独立させることで、テストがしやすくなり、システムの安定性を向上させることができます。

```kotlin
@RestControllerAdvice
class ExceptionController {

    val log = LoggerFactory.getLogger(ExceptionController::class.java)

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler
    fun invalidRequestHandler(e: MethodArgumentNotValidException): ErrorResponse? {
        log.info("invalidRequestHandler : ${e.message}")
        val response = ErrorResponse(
            _code = "400",
            _message = "リクエストに誤りがあります。"
        )
        e.fieldErrors.forEach { fieldError ->
            response.addValidation(fieldError.field, fieldError.defaultMessage!!)
        }

        return response
    }

    @ResponseStatus(HttpStatus.NOT_FOUND)
    @ExceptionHandler(PostNotFound::class)
    fun postNotFound(ex: PostNotFound): ErrorResponse? {
        log.info("postNotFound : ${ex.message}")
        val response = ErrorResponse(
            _code = "404",
            _message = ex.message!!
        )

        return response
    }
}
```

ここで疑問が一つ思い付くかもしれません、ビジネスにあう例外を作成するのはいいが、この例外が１０個２０個になったらそれに合わしてハンドラーも１０個、２０個作るの？

抽象化を利用して今の疑問は解決できます。
サービス最上位のExceptionを一つ作りそれを継承するようにすればOKです。

ブログプロジェクトの最上位例外を作ります。
```kotlin
abstract class KopherlogException(
    message: String,
): RuntimeException(message)
```

それを継承します。
```kotlin
class PostNotFound : KopherlogException("Post not found")

...

class InvalidRequest: KopherlogException("Invalid request")
```

`ExceptionController`も修正しましょう

```kotlin
@ResponseStatus(HttpStatus.NOT_FOUND)
@ExceptionHandler(KopherlogException::class)
fun postNotFound(ex: KopherlogException): ErrorResponse? {
    log.info("postNotFound : ${ex.message}")
    val response = ErrorResponse(
        _code = "404",
        _message = ex.message!!
    )

    return response
}
```

ここでまた疑問が思い付きますね、
- `PostNotFound`は Statusで`404`を返してほしい
- `InvalidException`は `400`して欲しいけど

こうなるとアノテーションでは動的に処理できなくなりますね。
これも解決してみましょう。

プログラミングでは有名な`Tell, Don't ask`法則を使ってみましょう。

まずは、例外を修正します。
```kotln:KopherlogException
abstract class KopherlogException(
    message: String,
): RuntimeException(message) {

    abstract val code: Int

}
```
継承クラスも修正します。

InvalidRequestは400で
```kotlin:InvalidRequest
class InvalidRequest: KopherlogException("Invalid request") {
    override val code: Int
        get() = 400
}
```
PostNotFoundは404でしたね
```kotlin:PostNotFound
class PostNotFound : KopherlogException("Post not found") {
    override val code: Int
        get() = 404
}
```
最後にException Handlerも修正します
```kotlin: ExceptionController
@ExceptionHandler(KopherlogException::class)
fun postNotFound(ex: KopherlogException): ResponseEntity<ErrorResponse> {
    log.info("postNotFound : ${ex.message}")
    val response = ErrorResponse(
        _code = ex.code.toString(),
        _message = ex.message!!
    )

    return ResponseEntity.status(ex.code).body(response)
}
```

確認！

```bash
http -v :8080/posts/3
GET /posts/3 HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: localhost:8080
User-Agent: HTTPie/3.2.4



HTTP/1.1 404 
Connection: keep-alive
Content-Type: application/json
Date: Sat, 29 Mar 2025 08:19:44 GMT
Keep-Alive: timeout=60
Transfer-Encoding: chunked

{
    "code": "404",
    "message": "Post not found",
    "validation": {}
}
```

これでバッチリだと思います！

# 結論
- ExceptionHandlerは別途Controllerを作って処理しましょう
- ドメイン、ビジネスをよりよく表現できる方法を使いましょう