Docker × Kotlin × Spring Boot:リクエストのバリデーションと例外ハンドリングまで“ひとまとめ”で作ってみました

こんにちは、さるまりんです。

今回はKotlinで作ったAPIのバリデーションについてです。

Kotlin × Spring Boot の API を作ります。
まずは JSON を受け取る → チェックする → 正常 or エラーを返す という API の基本の流れを作りたいところです。

フレームワークが全部やってくれるとはいえ、
「どう組み合わせたら ちゃんとした形 になるのか?」
は最初に向き合うところです。

そこで今回は Docker 上で最小構成の Spring Boot API を作り、
入力 → バリデーション → 例外 → 整形された JSON
の流れを全部動かしてみました。

1. Docker で Kotlin / Spring Boot を動かす環境を作る

まず、この2ファイルを同じフォルダに置きます。

project/
├── docker-compose.yml
└── Dockerfile

docker-compose.yml

services:
  app:
    build: .
    ports:
      - "8080:8080"
    volumes:
      - ./:/workspace
    tty: true

2. Spring Boot の Dockerfile を作成

同じディレクトリに Dockerfile を作ります。

Dockerfile

FROM gradle:8.7-jdk17 AS builder
WORKDIR /workspace
COPY . .
RUN gradle bootJar --no-daemon

FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /workspace/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

ポイント:

  • builder ステージで bootJar を作る
  • 本番ステージは JRE だけなので軽い

3. Spring Boot プロジェクトのファイルを配置する

最小構成はこれ👇です

src/main/kotlin/com/example/demo/
│── DemoApplication.kt
│── api/
│    ├── CreateComposerRequest.kt
│    ├── ComposerController.kt
│    └── GlobalExceptionHandler.kt
build.gradle.kts
settings.gradle.kts

3-1. build.gradle.kts

plugins {
    id("org.springframework.boot") version "3.3.2"
    id("io.spring.dependency-management") version "1.1.5"
    kotlin("jvm") version "1.9.23"
    kotlin("plugin.spring") version "1.9.23"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"

java {
    sourceCompatibility = JavaVersion.VERSION_17
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-validation")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

3-2. settings.gradle.kts

rootProject.name = "kotlin-validation-api"

3-3. DemoApplication.kt

package com.example.demo

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class DemoApplication

fun main(args: Array<String>) {
    runApplication<DemoApplication>(*args)
}

4. JSON リクエストモデル + バリデーション

CreateComposerRequest.kt

package com.example.demo.api

import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size

data class CreateComposerRequest(
    @field:NotBlank(message = "name は必須です")
    @field:Size(max = 100, message = "name は100文字以内です")
    val name: String?,

    @field:Size(max = 50, message = "nationality は50文字以内です")
    val nationality: String?
)
  • Kotlin の場合 @field: が必要 (Javaでは@NotBlankとか@NotNullでした)
  • null 許容で “来なかったとき” もエラーにできる

5. API コントローラ

ComposerController.kt

package com.example.demo.api

import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import jakarta.validation.Valid

@RestController
@RequestMapping("/api/composers")
class ComposerController {

    @PostMapping
    fun create(
        @Valid @RequestBody body: CreateComposerRequest
    ): ResponseEntity<Any> {

        val response = mapOf(
            "id" to 1,
            "name" to body.name,
            "nationality" to body.nationality
        )
        return ResponseEntity.ok(response)
    }
}

@Valid が入ることで入力エラー時に例外が飛ぶようになります。

6. 例外ハンドリングでエラー JSON を整形

GlobalExceptionHandler.kt

package com.example.demo.api

import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.MethodArgumentNotValidException

@ControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidationError(ex: MethodArgumentNotValidException): ResponseEntity<Map<String, Any>> {

        val errors = ex.bindingResult.fieldErrors.map {
            mapOf(
                "field" to it.field,
                "message" to (it.defaultMessage ?: "invalid value")
            )
        }

        val body = mapOf(
            "error" to "validation_error",
            "details" to errors
        )

        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body)
    }
}

7. Docker でビルド & 起動

さあ動かしてみます。僕はやりながら“つまずいた”ので手順も載せておきます。

1. ビルド

docker compose build

成功すると、こんなログが流れます(抜粋):

[+] Building 20.0s (11/11)
 > [builder 4/4] RUN gradle bootJar --no-daemon:
 ...

2. 起動

docker compose up

起動ログ:

Tomcat initialized with port 8080 (http)
Tomcat started on port 8080 (http) with context path '/'
Started DemoApplicationKt in 2.139 seconds

ここまで来たら、もう API が動いています。

8. 動作確認(curl)

動いているAPIをcurlで叩いてみます。

OKパターン

curl -X POST http://localhost:8080/api/composers \
  -H "Content-Type: application/json" \
  -d '{"name": "Beethoven", "nationality": "German"}'

レスポンス:

{"id":1,"name":"Beethoven","nationality":"German"}

NGパターン(バリデーションエラー)

curl -X POST http://localhost:8080/api/composers \
  -H "Content-Type: application/json" \
  -d '{"name": "", "nationality": "German"}'

レスポンス:

{
  "error": "validation_error",
  "details": [
    {
      "field": "name",
      "message": "name は必須です"
    }
  ]
}

入力 → バリデーション → 例外 → 整形された JSON
この流れがバッチリ確認できました。

9. おわりに

今回は、

  • Dockerで最小の Kotlin × Spring Boot API を構築
  • JSON を受け取る仕組み
  • バリデーション(Jakarta)
  • 例外ハンドリング(@ControllerAdvice)
  • 一貫したエラーレスポンス設計

までを まとめて動く形 にしました。

API が「大丈夫な時も、ダメな時も、ちゃんと返す」ことは

とても大事なポイントです。

次は ビジネスロジック上の NG(例:登録済みなど)をどう返すか?
という“アプリケーションエラー設計”もやっていきたいと思います。

読んでくださってありがとうございました。
それでは、また!