Spring Boot 3 + Kotlin完全ガイド2026|コルーチン・WebFlux・GraalVM対応


Spring Boot 3 + Kotlinの魅力

Spring Boot 3はKotlinをファーストクラスサポートしています。Kotlinの簡潔な構文、null安全性、コルーチンと組み合わせることで、Javaよりも少ないコードで安全なアプリケーションを構築できます。

なぜKotlinか

比較項目KotlinJava
Null安全◎(言語レベル)△(Optional)
ボイラープレート少ない多い
Data Classdata classRecord(制限あり)
コルーチン◎(ネイティブ)Virtual Threads
Spring対応
拡張関数×
DSL

セットアップ

Spring Initializrでプロジェクト作成

# CLIで作成
curl https://start.spring.io/starter.tgz \
  -d type=gradle-project-kotlin \
  -d language=kotlin \
  -d bootVersion=3.3.0 \
  -d dependencies=web,webflux,data-jpa,security,validation,actuator \
  -d javaVersion=21 \
  -d name=my-app \
  | tar -xzvf -

build.gradle.kts

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

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")

    // コルーチン
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")

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

プロジェクト構成

src/main/kotlin/com/example/myapp/
├── MyAppApplication.kt
├── config/
│   └── SecurityConfig.kt
├── controller/
│   ├── AuthController.kt
│   └── UserController.kt
├── domain/
│   ├── entity/
│   │   └── User.kt
│   └── repository/
│       └── UserRepository.kt
├── dto/
│   ├── UserRequest.kt
│   └── UserResponse.kt
├── exception/
│   └── GlobalExceptionHandler.kt
└── service/
    └── UserService.kt

エンティティとリポジトリ

Kotlin Data Classで簡潔に

// domain/entity/User.kt
@Entity
@Table(name = "users")
data class User(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,

    @Column(nullable = false, length = 50)
    val name: String,

    @Column(nullable = false, unique = true)
    val email: String,

    @Column(nullable = false)
    val hashedPassword: String,

    val age: Int? = null,

    @Column(updatable = false)
    val createdAt: LocalDateTime = LocalDateTime.now(),
)

Spring Data JPA

// domain/repository/UserRepository.kt
interface UserRepository : JpaRepository<User, Long> {
    fun findByEmail(email: String): User?
    fun findByNameContainingIgnoreCase(name: String, pageable: Pageable): Page<User>
    fun existsByEmail(email: String): Boolean

    @Query("SELECT u FROM User u WHERE u.age BETWEEN :min AND :max")
    fun findByAgeRange(
        @Param("min") min: Int,
        @Param("max") max: Int,
    ): List<User>
}

DTO(リクエスト/レスポンス)

// dto/UserRequest.kt
data class CreateUserRequest(
    @field:NotBlank(message = "名前は必須です")
    @field:Size(min = 2, max = 50, message = "名前は250文字で入力してください")
    val name: String,

    @field:Email(message = "有効なメールアドレスを入力してください")
    val email: String,

    @field:Size(min = 8, message = "パスワードは8文字以上で入力してください")
    val password: String,

    @field:Min(0) @field:Max(150)
    val age: Int? = null,
)

data class UpdateUserRequest(
    @field:Size(min = 2, max = 50)
    val name: String? = null,

    @field:Min(0) @field:Max(150)
    val age: Int? = null,
)

// dto/UserResponse.kt
data class UserResponse(
    val id: Long,
    val name: String,
    val email: String,
    val age: Int?,
    val createdAt: LocalDateTime,
) {
    companion object {
        fun from(user: User) = UserResponse(
            id = user.id,
            name = user.name,
            email = user.email,
            age = user.age,
            createdAt = user.createdAt,
        )
    }
}

data class PagedResponse<T>(
    val items: List<T>,
    val total: Long,
    val page: Int,
    val perPage: Int,
    val totalPages: Int,
)

コントローラー

// controller/UserController.kt
@RestController
@RequestMapping("/api/users")
class UserController(
    private val userService: UserService,
) {
    @GetMapping
    fun listUsers(
        @RequestParam(defaultValue = "1") page: Int,
        @RequestParam(defaultValue = "20") perPage: Int,
        @RequestParam(required = false) search: String?,
    ): PagedResponse<UserResponse> {
        return userService.listUsers(page, perPage, search)
    }

    @GetMapping("/{id}")
    fun getUser(@PathVariable id: Long): UserResponse {
        return userService.getUser(id)
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun createUser(@Valid @RequestBody request: CreateUserRequest): UserResponse {
        return userService.createUser(request)
    }

    @PatchMapping("/{id}")
    fun updateUser(
        @PathVariable id: Long,
        @Valid @RequestBody request: UpdateUserRequest,
    ): UserResponse {
        return userService.updateUser(id, request)
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    fun deleteUser(@PathVariable id: Long) {
        userService.deleteUser(id)
    }
}

サービス層

// service/UserService.kt
@Service
@Transactional(readOnly = true)
class UserService(
    private val userRepository: UserRepository,
    private val passwordEncoder: PasswordEncoder,
) {
    fun listUsers(page: Int, perPage: Int, search: String?): PagedResponse<UserResponse> {
        val pageable = PageRequest.of(page - 1, perPage, Sort.by("createdAt").descending())

        val result = if (search != null) {
            userRepository.findByNameContainingIgnoreCase(search, pageable)
        } else {
            userRepository.findAll(pageable)
        }

        return PagedResponse(
            items = result.content.map { UserResponse.from(it) },
            total = result.totalElements,
            page = page,
            perPage = perPage,
            totalPages = result.totalPages,
        )
    }

    fun getUser(id: Long): UserResponse {
        val user = userRepository.findById(id)
            .orElseThrow { NotFoundException("ユーザーが見つかりません") }
        return UserResponse.from(user)
    }

    @Transactional
    fun createUser(request: CreateUserRequest): UserResponse {
        if (userRepository.existsByEmail(request.email)) {
            throw ConflictException("このメールアドレスは既に登録されています")
        }

        val user = User(
            name = request.name,
            email = request.email,
            hashedPassword = passwordEncoder.encode(request.password),
            age = request.age,
        )

        return UserResponse.from(userRepository.save(user))
    }

    @Transactional
    fun updateUser(id: Long, request: UpdateUserRequest): UserResponse {
        val user = userRepository.findById(id)
            .orElseThrow { NotFoundException("ユーザーが見つかりません") }

        val updated = user.copy(
            name = request.name ?: user.name,
            age = request.age ?: user.age,
        )

        return UserResponse.from(userRepository.save(updated))
    }

    @Transactional
    fun deleteUser(id: Long) {
        if (!userRepository.existsById(id)) {
            throw NotFoundException("ユーザーが見つかりません")
        }
        userRepository.deleteById(id)
    }
}

例外ハンドリング

// exception/GlobalExceptionHandler.kt
@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(NotFoundException::class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    fun handleNotFound(e: NotFoundException) = ErrorResponse(
        status = 404,
        message = e.message ?: "リソースが見つかりません",
    )

    @ExceptionHandler(ConflictException::class)
    @ResponseStatus(HttpStatus.CONFLICT)
    fun handleConflict(e: ConflictException) = ErrorResponse(
        status = 409,
        message = e.message ?: "リソースが競合しています",
    )

    @ExceptionHandler(MethodArgumentNotValidException::class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    fun handleValidation(e: MethodArgumentNotValidException): ErrorResponse {
        val errors = e.bindingResult.fieldErrors.associate {
            it.field to (it.defaultMessage ?: "入力値が不正です")
        }
        return ErrorResponse(status = 400, message = "バリデーションエラー", errors = errors)
    }
}

data class ErrorResponse(
    val status: Int,
    val message: String,
    val errors: Map<String, String>? = null,
)

class NotFoundException(message: String) : RuntimeException(message)
class ConflictException(message: String) : RuntimeException(message)

Spring Security

// config/SecurityConfig.kt
@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            csrf { disable() }
            authorizeHttpRequests {
                authorize("/api/auth/**", permitAll)
                authorize("/api/health", permitAll)
                authorize("/actuator/**", permitAll)
                authorize(anyRequest, authenticated)
            }
            sessionManagement {
                sessionCreationPolicy = SessionCreationPolicy.STATELESS
            }
            addFilterBefore<UsernamePasswordAuthenticationFilter>(jwtAuthFilter())
        }
        return http.build()
    }

    @Bean
    fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}

コルーチン対応(WebFlux)

// コルーチンベースのコントローラー
@RestController
@RequestMapping("/api/async")
class AsyncController(
    private val externalApiClient: ExternalApiClient,
) {
    @GetMapping("/aggregated")
    suspend fun getAggregatedData(): AggregatedData {
        // 複数のAPI呼び出しを並列実行
        return coroutineScope {
            val users = async { externalApiClient.fetchUsers() }
            val posts = async { externalApiClient.fetchPosts() }
            val stats = async { externalApiClient.fetchStats() }

            AggregatedData(
                users = users.await(),
                posts = posts.await(),
                stats = stats.await(),
            )
        }
    }
}

// リアクティブリポジトリ
interface UserReactiveRepository : CoroutineCrudRepository<User, Long> {
    fun findByEmail(email: String): Flow<User>
}

テスト

// テスト
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class UserControllerTest {

    @Autowired
    lateinit var mockMvc: MockMvc

    @Autowired
    lateinit var objectMapper: ObjectMapper

    @Test
    fun `ユーザーを作成できる`() {
        val request = CreateUserRequest(
            name = "田中太郎",
            email = "tanaka@example.com",
            password = "password123",
        )

        mockMvc.post("/api/users") {
            contentType = MediaType.APPLICATION_JSON
            content = objectMapper.writeValueAsString(request)
        }.andExpect {
            status { isCreated() }
            jsonPath("$.name") { value("田中太郎") }
            jsonPath("$.email") { value("tanaka@example.com") }
            jsonPath("$.id") { isNumber() }
        }
    }

    @Test
    fun `バリデーションエラーが返る`() {
        val request = mapOf(
            "name" to "A",  // 2文字未満
            "email" to "invalid",
            "password" to "short",
        )

        mockMvc.post("/api/users") {
            contentType = MediaType.APPLICATION_JSON
            content = objectMapper.writeValueAsString(request)
        }.andExpect {
            status { isBadRequest() }
            jsonPath("$.errors.name") { exists() }
            jsonPath("$.errors.email") { exists() }
        }
    }

    @Test
    fun `存在しないユーザーは404`() {
        mockMvc.get("/api/users/999")
            .andExpect { status { isNotFound() } }
    }
}

GraalVMネイティブイメージ

Spring Boot 3はGraalVMネイティブイメージを公式サポートしています。

# ネイティブイメージのビルド
./gradlew nativeCompile

# 起動時間の比較
# JVM起動:           ████████████████ 2.5秒
# ネイティブイメージ: ██              0.05秒
// build.gradle.kts
plugins {
    id("org.graalvm.buildtools.native") version "0.10.1"
}

graalvmNative {
    binaries {
        named("main") {
            buildArgs.add("--enable-preview")
            javaLauncher.set(javaToolchains.launcherFor {
                languageVersion.set(JavaLanguageVersion.of(21))
                vendor.set(JvmVendorSpec.GRAALVM_CE)
            })
        }
    }
}

まとめ

機能Spring Boot 3 + Kotlin
Null安全Kotlin言語レベル
非同期コルーチン + WebFlux
DISpring標準(コンストラクタインジェクション)
バリデーションBean Validation
ORMSpring Data JPA
セキュリティSpring Security
テストMockMvc + JUnit 5
ネイティブGraalVM対応

Spring Boot + Kotlinは、エンタープライズグレードの堅牢性とKotlinの簡潔さ・安全性を両立する組み合わせです。Java資産をそのまま活かしつつ、モダンな開発体験を得られます。

関連記事