SpringBoot with Kotlin

Spring Boot with Kotlin 完全ガイド

目次

  1. はじめに
  2. Kotlin と Spring Boot の親和性
  3. プロジェクトのセットアップと構成
  4. Gradle (Kotlin DSL) によるビルド設定
  5. アプリケーション設定と構成管理
  6. Spring Boot の自動構成(Auto-Configuration)
  7. 依存性注入(Dependency Injection)と Bean 管理
  8. Web レイヤー: REST API の構築
  9. データアクセスレイヤー: Spring Data JPA と Kotlin
  10. データアクセスレイヤー: Spring Data R2DBC(リアクティブ)
  11. セキュリティ: Spring Security の統合
  12. テスト戦略と実装
  13. コルーチンと WebFlux によるリアクティブプログラミング
  14. メッセージングとイベント駆動アーキテクチャ
  15. キャッシュとパフォーマンス最適化
  16. 監視・可観測性(Observability)
  17. Docker とクラウドネイティブデプロイ
  18. GraalVM ネイティブイメージ対応
  19. マイクロサービスアーキテクチャの実践
  20. ベストプラクティスとアンチパターン
  21. まとめ

1. はじめに

1.1 本書の目的

本書は、Spring Boot と Kotlin を組み合わせた開発について、アーキテクチャの全体像から具体的な設定例、実装パターンまでを網羅的に解説する技術資料である。Java エコシステムにおける Kotlin の台頭と、Spring Framework が公式に Kotlin をファーストクラスサポートしている背景を踏まえ、実務に即した知識体系を提供することを目的とする。

1.2 Spring Boot の概要

Spring Boot は、Spring Framework をベースとした「意見付きの(opinionated)」フレームワークであり、以下の特徴を持つ。

  • 自動構成(Auto-Configuration): クラスパス上のライブラリを検出し、適切なデフォルト設定を自動適用する
  • スターター依存関係: 関連する依存関係をまとめたスターターPOMにより、依存関係管理を簡素化する
  • 組み込みサーバー: Tomcat、Jetty、Netty 等の組み込みサーバーにより、WAR デプロイを不要にする
  • 本番レディ機能: Actuator によるヘルスチェック、メトリクス、監視機能を標準搭載する
  • 外部化設定: プロファイルベースの設定管理により、環境ごとの構成を柔軟に切り替える

1.3 Kotlin の概要

Kotlin は JetBrains が開発した静的型付けプログラミング言語であり、JVM 上で動作する。Java との 100% 互換性を保ちながら、以下の近代的な言語機能を提供する。

  • Null 安全: 型システムレベルでの null 参照の排除
  • データクラス: ボイラープレートコードの大幅削減
  • 拡張関数: 既存クラスへのメソッド追加
  • コルーチン: 軽量な非同期プログラミングモデル
  • DSL 構築能力: 型安全なドメイン固有言語の構築
  • スマートキャスト: 型チェック後の自動型変換
  • シールドクラス: 代数的データ型の表現

1.4 Spring Boot + Kotlin の歴史的背景

Spring Framework 5.0(2017年)で Kotlin の公式サポートが導入され、以下の進化を遂げてきた。

バージョンリリース年Kotlin 関連の主な機能
Spring Framework 5.02017Kotlin 拡張関数、Null 安全対応
Spring Boot 2.02018Kotlin DSL for Bean Definition
Spring Boot 2.22019コルーチンサポート強化
Spring Boot 2.42020Kotlin 1.4 対応
Spring Boot 3.02022Jakarta EE 移行、GraalVM 対応
Spring Boot 3.22023Virtual Threads、CRaC 対応
Spring Boot 3.32024Kotlin 2.0 対応強化

2. Kotlin と Spring Boot の親和性

2.1 ファーストクラスサポートの内容

Spring Framework は Kotlin を「ファーストクラス言語」として公式サポートしている。これは単なる互換性の保証ではなく、以下の積極的な統合を意味する。

Null 安全の統合

Spring Framework の API には @Nullable および @NonNull アノテーションが付与されており、Kotlin コンパイラがこれらを認識して適切な型(nullable / non-null)にマッピングする。

// Spring の API が non-null を返す場合、Kotlin では String 型として扱われる
val name: String = environment.getRequiredProperty("app.name")

// nullable を返す場合、Kotlin では String? 型として扱われる
val optional: String? = environment.getProperty("app.optional")

Kotlin 拡張関数

Spring Framework は Kotlin 専用の拡張関数を提供し、より自然な Kotlin コードの記述を可能にする。

// Java スタイル
val context = GenericApplicationContext()
context.registerBean(MyService::class.java)

// Kotlin 拡張関数による記述
val context = GenericApplicationContext()
context.registerBean<MyService>()

// RestTemplate の拡張関数
val result: List<User> = restTemplate.getForObject("/api/users")

// WebClient の拡張関数
val user: User = webClient.get()
    .uri("/api/users/{id}", id)
    .retrieve()
    .awaitBody()

Reified 型パラメータの活用

Kotlin の reified 型パラメータにより、Java では実現できない型安全な API を提供する。

// Java では Class<T> パラメータが必要
// restTemplate.getForObject("/api/users", List.class)

// Kotlin では reified により型推論が効く
val users: List<User> = restTemplate.getForObject("/api/users")

2.2 Kotlin コンパイラプラグインの必要性

Spring Boot で Kotlin を使用する際、以下のコンパイラプラグインが必須またはほぼ必須となる。

kotlin-spring プラグイン(all-open)

Spring の多くの機能は CGLIB プロキシに依存するが、Kotlin のクラスはデフォルトで final である。kotlin-spring プラグインは、特定のアノテーションが付与されたクラスを自動的に open にする。

// プラグインなしでは明示的に open が必要
@Service
open class UserService {
    open fun findUser(id: Long): User { ... }
}

// kotlin-spring プラグインにより open は不要
@Service
class UserService {
    fun findUser(id: Long): User { ... }
}

対象となるアノテーション:

  • @Component(および派生: @Service, @Repository, @Controller
  • @Configuration
  • @Async
  • @Transactional
  • @Cacheable

kotlin-jpa プラグイン(no-arg)

JPA エンティティにはデフォルトコンストラクタ(引数なしコンストラクタ)が必要だが、Kotlin のデータクラスには通常これがない。kotlin-jpa プラグインは JPA 関連アノテーションが付与されたクラスに合成的なデフォルトコンストラクタを生成する。

// kotlin-jpa プラグインにより、デフォルトコンストラクタが自動生成される
@Entity
@Table(name = "users")
data class User(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,

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

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

    @Column(name = "created_at")
    val createdAt: LocalDateTime = LocalDateTime.now()
)

2.3 Java との相互運用性

Kotlin と Java は完全な相互運用性を持つため、既存の Java ライブラリやフレームワークをシームレスに利用できる。

// Java ライブラリの直接使用
import com.fasterxml.jackson.databind.ObjectMapper
import org.apache.commons.lang3.StringUtils

@Service
class DataProcessingService(
    private val objectMapper: ObjectMapper
) {
    fun processData(input: String): ProcessedData {
        // Java ライブラリのメソッドを直接呼び出し
        val trimmed = StringUtils.trimToEmpty(input)
        return objectMapper.readValue(trimmed, ProcessedData::class.java)
    }
}

@JvmStatic, @JvmField, @JvmOverloads 等のアノテーションにより、Java 側からの呼び出しも最適化できる。

@Configuration
class AppConfig {
    companion object {
        @JvmStatic
        fun defaultTimeout(): Duration = Duration.ofSeconds(30)
    }
}

3. プロジェクトのセットアップと構成

3.1 Spring Initializr によるプロジェクト生成

Spring Boot プロジェクトの作成には Spring Initializr(https://start.spring.io)を使用するのが標準的である。Kotlin プロジェクトの場合、以下の設定を選択する。

  • Project: Gradle - Kotlin
  • Language: Kotlin
  • Spring Boot: 3.3.x(最新安定版)
  • Packaging: Jar
  • Java: 21(LTS)

3.2 プロジェクトディレクトリ構成

標準的な Spring Boot + Kotlin プロジェクトのディレクトリ構成は以下の通りである。

project-root/
├── build.gradle.kts                  # Gradle ビルドスクリプト(Kotlin DSL)
├── settings.gradle.kts               # Gradle 設定
├── gradle.properties                 # Gradle プロパティ
├── gradle/
│   └── wrapper/
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── src/
│   ├── main/
│   │   ├── kotlin/
│   │   │   └── com/example/demo/
│   │   │       ├── DemoApplication.kt        # メインクラス
│   │   │       ├── config/                   # 設定クラス
│   │   │       │   ├── SecurityConfig.kt
│   │   │       │   ├── WebConfig.kt
│   │   │       │   └── DatabaseConfig.kt
│   │   │       ├── controller/               # コントローラー
│   │   │       │   ├── UserController.kt
│   │   │       │   └── OrderController.kt
│   │   │       ├── service/                  # サービス層
│   │   │       │   ├── UserService.kt
│   │   │       │   └── OrderService.kt
│   │   │       ├── repository/               # リポジトリ層
│   │   │       │   ├── UserRepository.kt
│   │   │       │   └── OrderRepository.kt
│   │   │       ├── domain/                   # ドメインモデル
│   │   │       │   ├── entity/
│   │   │       │   │   ├── User.kt
│   │   │       │   │   └── Order.kt
│   │   │       │   ├── dto/
│   │   │       │   │   ├── UserDto.kt
│   │   │       │   │   └── OrderDto.kt
│   │   │       │   └── enum/
│   │   │       │       └── OrderStatus.kt
│   │   │       ├── exception/                # 例外処理
│   │   │       │   ├── GlobalExceptionHandler.kt
│   │   │       │   └── BusinessException.kt
│   │   │       └── infrastructure/           # インフラストラクチャ
│   │   │           ├── messaging/
│   │   │           └── external/
│   │   └── resources/
│   │       ├── application.yml               # メイン設定
│   │       ├── application-dev.yml           # 開発環境設定
│   │       ├── application-prod.yml          # 本番環境設定
│   │       ├── db/migration/                 # Flyway マイグレーション
│   │       │   ├── V1__create_users.sql
│   │       │   └── V2__create_orders.sql
│   │       ├── static/                       # 静的リソース
│   │       └── templates/                    # テンプレート
│   └── test/
│       ├── kotlin/
│       │   └── com/example/demo/
│       │       ├── controller/
│       │       │   └── UserControllerTest.kt
│       │       ├── service/
│       │       │   └── UserServiceTest.kt
│       │       ├── repository/
│       │       │   └── UserRepositoryTest.kt
│       │       └── integration/
│       │           └── UserIntegrationTest.kt
│       └── resources/
│           └── application-test.yml
├── docker-compose.yml
└── Dockerfile

3.3 メインアプリケーションクラス

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)
}

runApplication は Spring が提供する Kotlin 拡張関数であり、Java における SpringApplication.run() に相当する。reified 型パラメータにより、クラス参照の受け渡しが不要になっている。

カスタマイズが必要な場合は以下のように記述する。

fun main(args: Array<String>) {
    runApplication<DemoApplication>(*args) {
        setBannerMode(Banner.Mode.OFF)
        setAdditionalProfiles("custom")
        setDefaultProperties(mapOf(
            "spring.main.allow-bean-definition-overriding" to "true"
        ))
    }
}

4. Gradle (Kotlin DSL) によるビルド設定

4.1 settings.gradle.kts

rootProject.name = "demo-application"

pluginManagement {
    val kotlinVersion: String by settings
    val springBootVersion: String by settings
    val dependencyManagementVersion: String by settings

    repositories {
        mavenCentral()
        gradlePluginPortal()
    }

    plugins {
        kotlin("jvm") version kotlinVersion
        kotlin("plugin.spring") version kotlinVersion
        kotlin("plugin.jpa") version kotlinVersion
        kotlin("plugin.serialization") version kotlinVersion
        id("org.springframework.boot") version springBootVersion
        id("io.spring.dependency-management") version dependencyManagementVersion
    }
}

4.2 gradle.properties

kotlinVersion=2.0.21
springBootVersion=3.3.5
dependencyManagementVersion=1.1.6

# Kotlin コンパイラオプション
kotlin.code.style=official
kotlin.jvm.target=21

# Gradle パフォーマンス設定
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.daemon=true
org.gradle.jvmargs=-Xmx2048m -XX:+HeapDumpOnOutOfMemoryError

4.3 build.gradle.kts(完全版)

import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    kotlin("jvm")
    kotlin("plugin.spring")
    kotlin("plugin.jpa")
    kotlin("plugin.serialization")
    id("org.springframework.boot")
    id("io.spring.dependency-management")
    jacoco
}

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

java {
    sourceCompatibility = JavaVersion.VERSION_21
    targetCompatibility = JavaVersion.VERSION_21
}

repositories {
    mavenCentral()
}

dependencies {
    // Spring Boot スターター
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.springframework.boot:spring-boot-starter-cache")
    implementation("org.springframework.boot:spring-boot-starter-mail")

    // Kotlin 固有
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

    // データベース
    runtimeOnly("org.postgresql:postgresql")
    runtimeOnly("com.h2database:h2")
    implementation("org.flywaydb:flyway-core")
    implementation("org.flywaydb:flyway-database-postgresql")

    // 監視・可観測性
    implementation("io.micrometer:micrometer-registry-prometheus")
    implementation("io.micrometer:micrometer-tracing-bridge-brave")

    // ユーティリティ
    implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")

    // 開発ツール
    developmentOnly("org.springframework.boot:spring-boot-devtools")
    annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")

    // テスト
    testImplementation("org.springframework.boot:spring-boot-starter-test") {
        exclude(module = "mockito-core")
    }
    testImplementation("org.springframework.security:spring-security-test")
    testImplementation("io.mockk:mockk:1.13.13")
    testImplementation("com.ninja-squad:springmockk:4.0.2")
    testImplementation("org.testcontainers:testcontainers")
    testImplementation("org.testcontainers:junit-jupiter")
    testImplementation("org.testcontainers:postgresql")
    testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
    testImplementation("io.kotest:kotest-assertions-core:5.9.1")
    testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
}

tasks.withType<KotlinCompile> {
    compilerOptions {
        jvmTarget.set(JvmTarget.JVM_21)
        freeCompilerArgs.addAll(
            "-Xjsr305=strict",          // JSR-305 null 安全アノテーションの厳格モード
            "-Xjvm-default=all",         // インターフェースのデフォルトメソッド生成
            "-Xcontext-receivers",       // コンテキストレシーバー有効化
        )
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
    jvmArgs("-XX:+EnableDynamicAgentLoading")
}

// JaCoCo 設定
jacoco {
    toolVersion = "0.8.12"
}

tasks.jacocoTestReport {
    dependsOn(tasks.test)
    reports {
        xml.required.set(true)
        html.required.set(true)
    }
    classDirectories.setFrom(
        files(classDirectories.files.map {
            fileTree(it) {
                exclude(
                    "**/config/**",
                    "**/dto/**",
                    "**/entity/**",
                    "**/*Application*"
                )
            }
        })
    )
}

// allOpen 追加設定(Spring デフォルト以外)
allOpen {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.Embeddable")
    annotation("jakarta.persistence.MappedSuperclass")
}

4.4 コンパイラオプションの解説

-Xjsr305=strict は Spring Boot + Kotlin において最も重要なコンパイラオプションである。これにより、Spring の @Nullable/@NonNull アノテーションが Kotlin の型システムに正確にマッピングされる。

オプション説明
-Xjsr305=strictJSR-305 アノテーションを厳格に解釈(推奨)
-Xjvm-default=allKotlin インターフェースの default メソッドを JVM default メソッドとして生成
-Xcontext-receiversContext Receivers 機能を有効化(実験的)

5. アプリケーション設定と構成管理

5.1 application.yml(メイン設定)

spring:
  application:
    name: demo-application

  # Jackson 設定(Kotlin モジュール自動登録)
  jackson:
    serialization:
      write-dates-as-timestamps: false
      indent-output: false
    deserialization:
      fail-on-unknown-properties: false
    default-property-inclusion: non_null
    mapper:
      accept-case-insensitive-enums: true

  # データソース設定
  datasource:
    url: jdbc:postgresql://localhost:5432/demo_db
    username: ${DB_USERNAME:demo_user}
    password: ${DB_PASSWORD:demo_password}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      idle-timeout: 300000
      connection-timeout: 20000
      max-lifetime: 1200000
      leak-detection-threshold: 60000

  # JPA 設定
  jpa:
    hibernate:
      ddl-auto: validate
    open-in-view: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect
        format_sql: true
        default_batch_fetch_size: 100
        jdbc:
          batch_size: 50
        order_inserts: true
        order_updates: true
        generate_statistics: false

  # Flyway 設定
  flyway:
    enabled: true
    locations: classpath:db/migration
    baseline-on-migrate: true
    validate-on-migrate: true

  # キャッシュ設定
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=1000,expireAfterWrite=600s

# サーバー設定
server:
  port: 8080
  shutdown: graceful
  compression:
    enabled: true
    mime-types: application/json,application/xml,text/html,text/plain
    min-response-size: 1024
  tomcat:
    threads:
      max: 200
      min-spare: 10
    connection-timeout: 5s
    keep-alive-timeout: 30s
    max-connections: 8192
    accept-count: 100

# Actuator 設定
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus,env,loggers
      base-path: /actuator
  endpoint:
    health:
      show-details: when_authorized
      show-components: when_authorized
      probes:
        enabled: true
  metrics:
    tags:
      application: ${spring.application.name}
    distribution:
      percentiles-histogram:
        http.server.requests: true

# ログ設定
logging:
  level:
    root: INFO
    com.example.demo: DEBUG
    org.springframework.web: INFO
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
  file:
    name: logs/application.log
    max-size: 100MB
    max-history: 30

5.2 プロファイル別設定

application-dev.yml

spring:
  devtools:
    restart:
      enabled: true
    livereload:
      enabled: true
  h2:
    console:
      enabled: true
      path: /h2-console
  datasource:
    url: jdbc:h2:mem:devdb;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true

logging:
  level:
    root: DEBUG
    org.springframework.web: DEBUG

application-prod.yml

spring:
  datasource:
    url: jdbc:postgresql://${DB_HOST}:${DB_PORT:5432}/${DB_NAME}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 30
  jpa:
    hibernate:
      ddl-auto: none
    show-sql: false

server:
  port: ${SERVER_PORT:8080}
  tomcat:
    threads:
      max: 400

logging:
  level:
    root: WARN
    com.example.demo: INFO

5.3 型安全な設定プロパティ(@ConfigurationProperties)

Kotlin の data class と @ConfigurationProperties を組み合わせることで、型安全な設定管理を実現する。

@ConfigurationProperties(prefix = "app")
data class AppProperties(
    val name: String = "Demo Application",
    val version: String = "1.0.0",
    val security: SecurityProperties = SecurityProperties(),
    val cache: CacheProperties = CacheProperties(),
    val external: ExternalProperties = ExternalProperties()
) {
    data class SecurityProperties(
        val jwtSecret: String = "",
        val jwtExpiration: Duration = Duration.ofHours(24),
        val corsAllowedOrigins: List<String> = emptyList(),
        val rateLimitPerMinute: Int = 60
    )

    data class CacheProperties(
        val defaultTtl: Duration = Duration.ofMinutes(10),
        val maxSize: Long = 1000,
        val userCacheTtl: Duration = Duration.ofMinutes(5)
    )

    data class ExternalProperties(
        val apiBaseUrl: String = "",
        val apiKey: String = "",
        val timeout: Duration = Duration.ofSeconds(30),
        val retryAttempts: Int = 3
    )
}

対応する YAML 設定:

app:
  name: My Application
  version: 2.0.0
  security:
    jwt-secret: ${JWT_SECRET}
    jwt-expiration: 12h
    cors-allowed-origins:
      - https://example.com
      - https://app.example.com
    rate-limit-per-minute: 100
  cache:
    default-ttl: 15m
    max-size: 5000
    user-cache-ttl: 10m
  external:
    api-base-url: https://api.external-service.com
    api-key: ${EXTERNAL_API_KEY}
    timeout: 15s
    retry-attempts: 3

有効化のための設定クラス:

@Configuration
@EnableConfigurationProperties(AppProperties::class)
class AppConfig

使用例:

@Service
class NotificationService(
    private val appProperties: AppProperties
) {
    fun getJwtExpiration(): Duration = appProperties.security.jwtExpiration

    fun isOriginAllowed(origin: String): Boolean =
        origin in appProperties.security.corsAllowedOrigins
}

6. Spring Boot の自動構成(Auto-Configuration)

6.1 自動構成の仕組み

Spring Boot の自動構成は、クラスパス上の依存関係、既存の Bean 定義、各種プロパティ設定を条件として、適切な Bean を自動登録するメカニズムである。

// @SpringBootApplication は以下の3つのアノテーションを含む
@SpringBootConfiguration    // @Configuration の特殊形
@EnableAutoConfiguration    // 自動構成の有効化
@ComponentScan              // コンポーネントスキャン
class DemoApplication

自動構成の動作原理:

  1. spring-boot-autoconfigure JAR 内の META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports を読み込む
  2. 各自動構成クラスの条件アノテーション(@Conditional*)を評価する
  3. 条件を満たす設定クラスの Bean 定義を登録する

6.2 条件付き構成アノテーション

@Configuration
@ConditionalOnClass(DataSource::class)
@ConditionalOnProperty(
    prefix = "app.datasource",
    name = ["enabled"],
    havingValue = "true",
    matchIfMissing = true
)
class CustomDataSourceAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    fun dataSourceHealthIndicator(dataSource: DataSource): DataSourceHealthIndicator {
        return DataSourceHealthIndicator(dataSource)
    }

    @Bean
    @ConditionalOnBean(DataSource::class)
    @ConditionalOnProperty("app.datasource.monitoring.enabled", havingValue = "true")
    fun dataSourceMonitor(dataSource: DataSource): DataSourceMonitor {
        return DataSourceMonitor(dataSource)
    }
}

主な条件アノテーション:

アノテーション条件
@ConditionalOnClass指定クラスがクラスパスに存在する場合
@ConditionalOnMissingClass指定クラスがクラスパスに存在しない場合
@ConditionalOnBean指定型の Bean が存在する場合
@ConditionalOnMissingBean指定型の Bean が存在しない場合
@ConditionalOnProperty指定プロパティが条件を満たす場合
@ConditionalOnResource指定リソースが存在する場合
@ConditionalOnWebApplicationWeb アプリケーションの場合
@ConditionalOnExpressionSpEL 式が true の場合

6.3 カスタム自動構成の作成

// src/main/kotlin/com/example/autoconfigure/NotificationAutoConfiguration.kt
@AutoConfiguration
@ConditionalOnClass(NotificationService::class)
@EnableConfigurationProperties(NotificationProperties::class)
class NotificationAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(
        prefix = "app.notification",
        name = ["provider"],
        havingValue = "email"
    )
    fun emailNotificationService(
        properties: NotificationProperties,
        mailSender: JavaMailSender
    ): NotificationService {
        return EmailNotificationService(mailSender, properties)
    }

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(
        prefix = "app.notification",
        name = ["provider"],
        havingValue = "slack"
    )
    fun slackNotificationService(
        properties: NotificationProperties,
        restTemplate: RestTemplate
    ): NotificationService {
        return SlackNotificationService(restTemplate, properties)
    }
}

@ConfigurationProperties(prefix = "app.notification")
data class NotificationProperties(
    val provider: String = "email",
    val enabled: Boolean = true,
    val retryCount: Int = 3,
    val email: EmailProperties = EmailProperties(),
    val slack: SlackProperties = SlackProperties()
) {
    data class EmailProperties(
        val from: String = "noreply@example.com",
        val replyTo: String = ""
    )
    data class SlackProperties(
        val webhookUrl: String = "",
        val channel: String = "#notifications"
    )
}

自動構成の登録:

# src/main/resources/META-INF/spring/
# org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.example.autoconfigure.NotificationAutoConfiguration

6.4 自動構成の除外

特定の自動構成を無効化する方法:

@SpringBootApplication(
    exclude = [
        DataSourceAutoConfiguration::class,
        SecurityAutoConfiguration::class
    ]
)
class DemoApplication

または application.yml で:

spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
      - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration

7. 依存性注入(Dependency Injection)と Bean 管理

7.1 コンストラクタインジェクション

Kotlin では、コンストラクタインジェクションが最も推奨されるDIパターンである。Spring Boot は単一コンストラクタを持つクラスに対して @Autowired を省略できる。

@Service
class OrderService(
    private val orderRepository: OrderRepository,
    private val userService: UserService,
    private val notificationService: NotificationService,
    private val appProperties: AppProperties
) {
    fun createOrder(request: CreateOrderRequest): Order {
        val user = userService.findById(request.userId)
            ?: throw UserNotFoundException(request.userId)

        val order = Order(
            userId = user.id,
            items = request.items.map { it.toOrderItem() },
            totalAmount = calculateTotal(request.items)
        )

        val savedOrder = orderRepository.save(order)
        notificationService.sendOrderConfirmation(user, savedOrder)

        return savedOrder
    }
}

7.2 Bean 定義 DSL(Kotlin 関数型アプローチ)

Spring Framework は Kotlin DSL による Bean 定義をサポートしている。これはアノテーションベースに代わる関数型のアプローチである。

// Bean 定義 DSL
val beans = beans {
    // シンプルな Bean 登録
    bean<UserService>()
    bean<OrderService>()

    // ファクトリ関数による登録
    bean {
        EmailService(
            host = env["mail.host"] ?: "localhost",
            port = env["mail.port"]?.toInt() ?: 25
        )
    }

    // 条件付き Bean 登録
    profile("production") {
        bean<ProductionDataSource>()
    }
    profile("development") {
        bean<EmbeddedDataSource>()
    }

    // ラムダによるカスタム初期化
    bean {
        RestTemplate().apply {
            messageConverters.add(0, MappingJackson2HttpMessageConverter(ref()))
            interceptors.add(LoggingInterceptor())
        }
    }
}

// メインクラスでの登録
fun main(args: Array<String>) {
    runApplication<DemoApplication>(*args) {
        addInitializers(beans)
    }
}

7.3 スコープと ライフサイクル

@Configuration
class BeanScopeConfig {

    // シングルトン(デフォルト)
    @Bean
    @Scope(ConfigurableBeanFactory.SCOPE_SINGLETON)
    fun singletonService(): SingletonService = SingletonService()

    // プロトタイプ(毎回新規作成)
    @Bean
    @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
    fun prototypeService(): PrototypeService = PrototypeService()

    // リクエストスコープ
    @Bean
    @RequestScope
    fun requestScopedService(): RequestScopedService = RequestScopedService()

    // セッションスコープ
    @Bean
    @SessionScope
    fun sessionScopedService(): SessionScopedService = SessionScopedService()
}

// ライフサイクルコールバック
@Service
class CacheService : InitializingBean, DisposableBean {

    private val cache = ConcurrentHashMap<String, Any>()

    // Bean 初期化後に呼ばれる
    override fun afterPropertiesSet() {
        logger.info("CacheService initialized, warming up cache...")
        warmUpCache()
    }

    // Bean 破棄前に呼ばれる
    override fun destroy() {
        logger.info("CacheService shutting down, clearing cache...")
        cache.clear()
    }

    // または @PostConstruct / @PreDestroy を使用
    @PostConstruct
    fun init() {
        logger.info("Alternative: PostConstruct initialization")
    }

    @PreDestroy
    fun cleanup() {
        logger.info("Alternative: PreDestroy cleanup")
    }
}

7.4 遅延初期化(Lazy Initialization)

// 個別 Bean の遅延初期化
@Service
@Lazy
class HeavyComputationService {
    init {
        // 初回アクセス時にのみ実行される
        loadLargeDataset()
    }
}

// グローバル遅延初期化(application.yml)
// spring.main.lazy-initialization=true

// 遅延注入
@Service
class ReportService(
    @Lazy private val heavyService: HeavyComputationService
) {
    fun generateReport(): Report {
        // ここで初めて heavyService が初期化される
        return heavyService.compute()
    }
}

7.5 Qualifier とカスタムアノテーション

// Qualifier による識別
@Configuration
class DataSourceConfig {

    @Bean
    @Qualifier("primary")
    fun primaryDataSource(): DataSource =
        HikariDataSource().apply {
            jdbcUrl = "jdbc:postgresql://primary-host:5432/db"
        }

    @Bean
    @Qualifier("replica")
    fun replicaDataSource(): DataSource =
        HikariDataSource().apply {
            jdbcUrl = "jdbc:postgresql://replica-host:5432/db"
        }
}

// カスタム Qualifier アノテーション
@Target(
    AnnotationTarget.FIELD,
    AnnotationTarget.VALUE_PARAMETER,
    AnnotationTarget.FUNCTION
)
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class PrimaryDb

@Target(
    AnnotationTarget.FIELD,
    AnnotationTarget.VALUE_PARAMETER,
    AnnotationTarget.FUNCTION
)
@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class ReplicaDb

// 使用側
@Service
class UserService(
    @PrimaryDb private val writeDataSource: DataSource,
    @ReplicaDb private val readDataSource: DataSource
) {
    fun readUser(id: Long): User {
        // 読み取りはレプリカから
        return readDataSource.connection.use { conn ->
            // ...
        }
    }
}

8. Web レイヤー: REST API の構築

8.1 コントローラーの基本

@RestController
@RequestMapping("/api/v1/users")
class UserController(
    private val userService: UserService
) {
    private val logger = KotlinLogging.logger {}

    @GetMapping
    fun getUsers(
        @RequestParam(defaultValue = "0") page: Int,
        @RequestParam(defaultValue = "20") size: Int,
        @RequestParam(required = false) search: String?
    ): ResponseEntity<Page<UserResponse>> {
        logger.info { "Fetching users: page=$page, size=$size, search=$search" }
        val pageable = PageRequest.of(page, size, Sort.by("createdAt").descending())
        val users = userService.findAll(search, pageable)
        return ResponseEntity.ok(users.map { it.toResponse() })
    }

    @GetMapping("/{id}")
    fun getUser(@PathVariable id: Long): ResponseEntity<UserResponse> {
        val user = userService.findById(id)
            ?: throw UserNotFoundException(id)
        return ResponseEntity.ok(user.toResponse())
    }

    @PostMapping
    fun createUser(
        @Valid @RequestBody request: CreateUserRequest
    ): ResponseEntity<UserResponse> {
        logger.info { "Creating user: ${request.email}" }
        val user = userService.create(request)
        val location = URI.create("/api/v1/users/${user.id}")
        return ResponseEntity.created(location).body(user.toResponse())
    }

    @PutMapping("/{id}")
    fun updateUser(
        @PathVariable id: Long,
        @Valid @RequestBody request: UpdateUserRequest
    ): ResponseEntity<UserResponse> {
        val user = userService.update(id, request)
        return ResponseEntity.ok(user.toResponse())
    }

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

    @PatchMapping("/{id}/status")
    fun updateStatus(
        @PathVariable id: Long,
        @Valid @RequestBody request: UpdateStatusRequest
    ): ResponseEntity<UserResponse> {
        val user = userService.updateStatus(id, request.status)
        return ResponseEntity.ok(user.toResponse())
    }
}

8.2 リクエスト/レスポンス DTO

Kotlin の data class を活用した DTO 定義:

// リクエスト DTO
data class CreateUserRequest(
    @field:NotBlank(message = "名前は必須です")
    @field:Size(min = 1, max = 100, message = "名前は1〜100文字で入力してください")
    val name: String,

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

    @field:NotBlank(message = "パスワードは必須です")
    @field:Size(min = 8, max = 128, message = "パスワードは8〜128文字で入力してください")
    @field:Pattern(
        regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@\$!%*?&])[A-Za-z\\d@\$!%*?&]+$",
        message = "パスワードは大文字・小文字・数字・特殊文字を含む必要があります"
    )
    val password: String,

    val role: UserRole = UserRole.USER,

    @field:Size(max = 500)
    val bio: String? = null
)

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

    @field:Email
    val email: String? = null,

    @field:Size(max = 500)
    val bio: String? = null
)

data class UpdateStatusRequest(
    @field:NotNull
    val status: UserStatus
)

// レスポンス DTO
data class UserResponse(
    val id: Long,
    val name: String,
    val email: String,
    val role: UserRole,
    val status: UserStatus,
    val bio: String?,
    val createdAt: LocalDateTime,
    val updatedAt: LocalDateTime
)

// 拡張関数によるマッピング
fun User.toResponse() = UserResponse(
    id = this.id,
    name = this.name,
    email = this.email,
    role = this.role,
    status = this.status,
    bio = this.bio,
    createdAt = this.createdAt,
    updatedAt = this.updatedAt
)

8.3 Router Function DSL(関数型ルーティング)

アノテーションベースに代わる関数型ルーティングアプローチ:

@Configuration
class RouterConfig {

    @Bean
    fun userRouter(handler: UserHandler) = coRouter {
        "/api/v1/users".nest {
            GET("", handler::getUsers)
            GET("/{id}", handler::getUser)
            POST("", handler::createUser)
            PUT("/{id}", handler::updateUser)
            DELETE("/{id}", handler::deleteUser)
        }
    }

    @Bean
    fun orderRouter(handler: OrderHandler) = router {
        "/api/v1/orders".nest {
            GET("") { request ->
                val orders = handler.getOrders(request)
                ServerResponse.ok().body(orders)
            }
            GET("/{id}") { request ->
                val id = request.pathVariable("id").toLong()
                handler.getOrder(id)?.let {
                    ServerResponse.ok().body(it)
                } ?: ServerResponse.notFound().build()
            }
            POST("") { request ->
                val order = request.body<CreateOrderRequest>()
                val created = handler.createOrder(order)
                ServerResponse.created(URI("/api/v1/orders/${created.id}")).body(created)
            }
        }
    }
}

@Component
class UserHandler(
    private val userService: UserService
) {
    suspend fun getUsers(request: ServerRequest): ServerResponse {
        val page = request.queryParam("page").orElse("0").toInt()
        val size = request.queryParam("size").orElse("20").toInt()
        val users = userService.findAll(PageRequest.of(page, size))
        return ServerResponse.ok().bodyValueAndAwait(users)
    }

    suspend fun getUser(request: ServerRequest): ServerResponse {
        val id = request.pathVariable("id").toLong()
        val user = userService.findById(id)
            ?: return ServerResponse.notFound().buildAndAwait()
        return ServerResponse.ok().bodyValueAndAwait(user.toResponse())
    }

    suspend fun createUser(request: ServerRequest): ServerResponse {
        val body = request.awaitBody<CreateUserRequest>()
        val user = userService.create(body)
        return ServerResponse
            .created(URI("/api/v1/users/${user.id}"))
            .bodyValueAndAwait(user.toResponse())
    }

    suspend fun updateUser(request: ServerRequest): ServerResponse {
        val id = request.pathVariable("id").toLong()
        val body = request.awaitBody<UpdateUserRequest>()
        val user = userService.update(id, body)
        return ServerResponse.ok().bodyValueAndAwait(user.toResponse())
    }

    suspend fun deleteUser(request: ServerRequest): ServerResponse {
        val id = request.pathVariable("id").toLong()
        userService.delete(id)
        return ServerResponse.noContent().buildAndAwait()
    }
}

8.4 グローバル例外ハンドリング

@RestControllerAdvice
class GlobalExceptionHandler {

    private val logger = KotlinLogging.logger {}

    // ビジネスロジック例外
    @ExceptionHandler(BusinessException::class)
    fun handleBusinessException(ex: BusinessException): ResponseEntity<ErrorResponse> {
        logger.warn { "Business exception: ${ex.message}" }
        return ResponseEntity
            .status(ex.status)
            .body(ErrorResponse(
                code = ex.errorCode,
                message = ex.message ?: "Business error occurred",
                timestamp = Instant.now()
            ))
    }

    // リソース未発見
    @ExceptionHandler(ResourceNotFoundException::class)
    fun handleNotFound(ex: ResourceNotFoundException): ResponseEntity<ErrorResponse> {
        logger.warn { "Resource not found: ${ex.message}" }
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse(
                code = "RESOURCE_NOT_FOUND",
                message = ex.message ?: "Resource not found",
                timestamp = Instant.now()
            ))
    }

    // バリデーションエラー
    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ErrorResponse> {
        val errors = ex.bindingResult.fieldErrors.map { fieldError ->
            FieldError(
                field = fieldError.field,
                message = fieldError.defaultMessage ?: "Invalid value",
                rejectedValue = fieldError.rejectedValue?.toString()
            )
        }
        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(ErrorResponse(
                code = "VALIDATION_ERROR",
                message = "Validation failed",
                timestamp = Instant.now(),
                errors = errors
            ))
    }

    // 未処理の例外
    @ExceptionHandler(Exception::class)
    fun handleGeneral(ex: Exception): ResponseEntity<ErrorResponse> {
        logger.error(ex) { "Unexpected error occurred" }
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse(
                code = "INTERNAL_SERVER_ERROR",
                message = "An unexpected error occurred",
                timestamp = Instant.now()
            ))
    }
}

// エラーレスポンスモデル
data class ErrorResponse(
    val code: String,
    val message: String,
    val timestamp: Instant,
    val errors: List<FieldError> = emptyList()
)

data class FieldError(
    val field: String,
    val message: String,
    val rejectedValue: String? = null
)

// カスタム例外クラス(sealed class 活用)
sealed class BusinessException(
    message: String,
    val errorCode: String,
    val status: HttpStatus
) : RuntimeException(message)

class UserNotFoundException(id: Long) :
    BusinessException("User not found: $id", "USER_NOT_FOUND", HttpStatus.NOT_FOUND)

class DuplicateEmailException(email: String) :
    BusinessException("Email already exists: $email", "DUPLICATE_EMAIL", HttpStatus.CONFLICT)

class InsufficientBalanceException(userId: Long, required: BigDecimal) :
    BusinessException(
        "Insufficient balance for user $userId. Required: $required",
        "INSUFFICIENT_BALANCE",
        HttpStatus.UNPROCESSABLE_ENTITY
    )

// sealed class は when 式で網羅的なパターンマッチが可能
fun handleBusinessError(ex: BusinessException): String = when (ex) {
    is UserNotFoundException -> "ユーザーが見つかりません"
    is DuplicateEmailException -> "メールアドレスが重複しています"
    is InsufficientBalanceException -> "残高が不足しています"
}

8.5 インターセプターとフィルター

// HTTP リクエストロギングフィルター
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class RequestLoggingFilter : OncePerRequestFilter() {

    private val logger = KotlinLogging.logger {}

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val startTime = System.currentTimeMillis()
        val requestId = UUID.randomUUID().toString().substring(0, 8)

        // MDC にリクエスト ID を設定
        MDC.put("requestId", requestId)
        response.setHeader("X-Request-Id", requestId)

        try {
            logger.info {
                ">>> ${request.method} ${request.requestURI}" +
                    request.queryString?.let { "?$it" }.orEmpty()
            }
            filterChain.doFilter(request, response)
        } finally {
            val duration = System.currentTimeMillis() - startTime
            logger.info {
                "<<< ${response.status} ${request.method} ${request.requestURI} " +
                    "[${duration}ms]"
            }
            MDC.clear()
        }
    }
}

// WebMvc インターセプター
@Component
class RateLimitInterceptor(
    private val appProperties: AppProperties
) : HandlerInterceptor {

    private val rateLimiter = ConcurrentHashMap<String, AtomicInteger>()

    override fun preHandle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any
    ): Boolean {
        val clientIp = request.remoteAddr
        val counter = rateLimiter.computeIfAbsent(clientIp) { AtomicInteger(0) }

        return if (counter.incrementAndGet() > appProperties.security.rateLimitPerMinute) {
            response.status = HttpStatus.TOO_MANY_REQUESTS.value()
            response.writer.write("""{"error": "Rate limit exceeded"}""")
            false
        } else {
            true
        }
    }
}

// WebMvc 設定でインターセプターを登録
@Configuration
class WebMvcConfig(
    private val rateLimitInterceptor: RateLimitInterceptor
) : WebMvcConfigurer {

    override fun addInterceptors(registry: InterceptorRegistry) {
        registry.addInterceptor(rateLimitInterceptor)
            .addPathPatterns("/api/**")
            .excludePathPatterns("/api/v1/health")
    }

    override fun addCorsMappings(registry: CorsRegistry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://example.com")
            .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH")
            .allowedHeaders("*")
            .allowCredentials(true)
            .maxAge(3600)
    }

    override fun configureContentNegotiation(configurer: ContentNegotiationConfigurer) {
        configurer
            .defaultContentType(MediaType.APPLICATION_JSON)
            .favorParameter(false)
            .favorPathExtension(false)
    }
}

9. データアクセスレイヤー: Spring Data JPA と Kotlin

9.1 エンティティ定義

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

    @Column(nullable = false, length = 100)
    var name: String,

    @Column(nullable = false, unique = true, length = 255)
    var email: String,

    @Column(nullable = false)
    var passwordHash: String,

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    var role: UserRole = UserRole.USER,

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    var status: UserStatus = UserStatus.ACTIVE,

    @Column(length = 500)
    var bio: String? = null,

    @Column(name = "created_at", nullable = false, updatable = false)
    val createdAt: LocalDateTime = LocalDateTime.now(),

    @Column(name = "updated_at", nullable = false)
    var updatedAt: LocalDateTime = LocalDateTime.now(),

    @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
    val orders: MutableList<Order> = mutableListOf(),

    @Version
    val version: Long = 0
) {
    @PreUpdate
    fun onUpdate() {
        updatedAt = LocalDateTime.now()
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is User) return false
        return id != 0L && id == other.id
    }

    override fun hashCode(): Int = javaClass.hashCode()

    override fun toString(): String =
        "User(id=$id, name='$name', email='$email', role=$role, status=$status)"
}

@Entity
@Table(name = "orders")
class Order(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,

    @Column(name = "order_number", nullable = false, unique = true, length = 50)
    val orderNumber: String = generateOrderNumber(),

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    var user: User? = null,

    @Column(name = "user_id", insertable = false, updatable = false)
    val userId: Long = 0,

    @OneToMany(mappedBy = "order", cascade = [CascadeType.ALL], orphanRemoval = true)
    val items: MutableList<OrderItem> = mutableListOf(),

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 30)
    var status: OrderStatus = OrderStatus.PENDING,

    @Column(name = "total_amount", nullable = false, precision = 12, scale = 2)
    var totalAmount: BigDecimal = BigDecimal.ZERO,

    @Column(name = "created_at", nullable = false, updatable = false)
    val createdAt: LocalDateTime = LocalDateTime.now(),

    @Column(name = "updated_at", nullable = false)
    var updatedAt: LocalDateTime = LocalDateTime.now(),

    @Version
    val version: Long = 0
) {
    companion object {
        private fun generateOrderNumber(): String =
            "ORD-${LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)}-${
                UUID.randomUUID().toString().substring(0, 8).uppercase()
            }"
    }

    fun addItem(item: OrderItem) {
        items.add(item)
        item.order = this
        recalculateTotal()
    }

    fun removeItem(item: OrderItem) {
        items.remove(item)
        item.order = null
        recalculateTotal()
    }

    private fun recalculateTotal() {
        totalAmount = items.sumOf { it.subtotal }
    }
}

// 列挙型
enum class UserRole { USER, ADMIN, MODERATOR }
enum class UserStatus { ACTIVE, INACTIVE, SUSPENDED }
enum class OrderStatus { PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED }

9.2 リポジトリの定義

interface UserRepository : JpaRepository<User, Long>, JpaSpecificationExecutor<User> {

    fun findByEmail(email: String): User?

    fun findByStatus(status: UserStatus, pageable: Pageable): Page<User>

    fun findByNameContainingIgnoreCase(name: String): List<User>

    @Query("SELECT u FROM User u WHERE u.role = :role AND u.status = :status")
    fun findByRoleAndStatus(
        @Param("role") role: UserRole,
        @Param("status") status: UserStatus
    ): List<User>

    @Query(
        """
        SELECT u FROM User u 
        LEFT JOIN FETCH u.orders o 
        WHERE u.id = :id
        """
    )
    fun findByIdWithOrders(@Param("id") id: Long): User?

    @Query(
        value = """
            SELECT u.* FROM users u 
            WHERE u.created_at >= :since 
            AND u.status = 'ACTIVE'
            ORDER BY u.created_at DESC
        """,
        nativeQuery = true
    )
    fun findRecentActiveUsers(@Param("since") since: LocalDateTime): List<User>

    @Modifying
    @Query("UPDATE User u SET u.status = :status WHERE u.id IN :ids")
    fun updateStatusBatch(
        @Param("ids") ids: List<Long>,
        @Param("status") status: UserStatus
    ): Int

    fun existsByEmail(email: String): Boolean

    @Query("SELECT COUNT(u) FROM User u WHERE u.role = :role")
    fun countByRole(@Param("role") role: UserRole): Long
}

interface OrderRepository : JpaRepository<Order, Long> {

    fun findByUserId(userId: Long, pageable: Pageable): Page<Order>

    fun findByStatus(status: OrderStatus): List<Order>

    @Query(
        """
        SELECT o FROM Order o 
        JOIN FETCH o.items 
        WHERE o.orderNumber = :orderNumber
        """
    )
    fun findByOrderNumberWithItems(@Param("orderNumber") orderNumber: String): Order?

    @Query(
        """
        SELECT new com.example.demo.domain.dto.OrderSummary(
            o.userId, COUNT(o), SUM(o.totalAmount)
        )
        FROM Order o 
        WHERE o.createdAt >= :since
        GROUP BY o.userId
        """
    )
    fun getOrderSummaryByUser(@Param("since") since: LocalDateTime): List<OrderSummary>
}

9.3 Specification による動的クエリ

object UserSpecifications {

    fun hasName(name: String?): Specification<User> =
        Specification { root, _, cb ->
            name?.let {
                cb.like(
                    cb.lower(root.get("name")),
                    "%${it.lowercase()}%"
                )
            }
        }

    fun hasEmail(email: String?): Specification<User> =
        Specification { root, _, cb ->
            email?.let {
                cb.equal(root.get<String>("email"), it)
            }
        }

    fun hasStatus(status: UserStatus?): Specification<User> =
        Specification { root, _, cb ->
            status?.let {
                cb.equal(root.get<UserStatus>("status"), it)
            }
        }

    fun hasRole(role: UserRole?): Specification<User> =
        Specification { root, _, cb ->
            role?.let {
                cb.equal(root.get<UserRole>("role"), it)
            }
        }

    fun createdAfter(date: LocalDateTime?): Specification<User> =
        Specification { root, _, cb ->
            date?.let {
                cb.greaterThanOrEqualTo(root.get("createdAt"), it)
            }
        }

    // 複合 Specification の構築
    fun buildSearchSpec(criteria: UserSearchCriteria): Specification<User> =
        Specification.where(hasName(criteria.name))
            .and(hasEmail(criteria.email))
            .and(hasStatus(criteria.status))
            .and(hasRole(criteria.role))
            .and(createdAfter(criteria.createdAfter))
}

data class UserSearchCriteria(
    val name: String? = null,
    val email: String? = null,
    val status: UserStatus? = null,
    val role: UserRole? = null,
    val createdAfter: LocalDateTime? = null
)

// サービスでの使用
@Service
class UserService(
    private val userRepository: UserRepository
) {
    fun search(criteria: UserSearchCriteria, pageable: Pageable): Page<User> {
        val spec = UserSpecifications.buildSearchSpec(criteria)
        return userRepository.findAll(spec, pageable)
    }
}

9.4 監査(Auditing)機能

@Configuration
@EnableJpaAuditing
class JpaAuditingConfig {

    @Bean
    fun auditorProvider(): AuditorAware<String> =
        AuditorAware {
            Optional.ofNullable(SecurityContextHolder.getContext().authentication)
                .filter { it.isAuthenticated }
                .map { it.name }
        }
}

@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class AuditableEntity(
    @CreatedDate
    @Column(name = "created_at", nullable = false, updatable = false)
    var createdAt: LocalDateTime = LocalDateTime.now(),

    @LastModifiedDate
    @Column(name = "updated_at", nullable = false)
    var updatedAt: LocalDateTime = LocalDateTime.now(),

    @CreatedBy
    @Column(name = "created_by", updatable = false, length = 100)
    var createdBy: String? = null,

    @LastModifiedBy
    @Column(name = "updated_by", length = 100)
    var updatedBy: String? = null
)

// 使用例
@Entity
@Table(name = "articles")
class Article(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    var title: String,
    var content: String
) : AuditableEntity()

10. データアクセスレイヤー: Spring Data R2DBC(リアクティブ)

10.1 R2DBC の概要と設定

R2DBC(Reactive Relational Database Connectivity)は、リレーショナルデータベースへのリアクティブアクセスを提供する。Kotlin コルーチンとの相性が非常に良い。

// build.gradle.kts の依存関係
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
    implementation("org.springframework.boot:spring-boot-starter-webflux")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
    runtimeOnly("org.postgresql:r2dbc-postgresql")
}
# application.yml
spring:
  r2dbc:
    url: r2dbc:postgresql://localhost:5432/demo_db
    username: ${DB_USERNAME:demo_user}
    password: ${DB_PASSWORD:demo_password}
    pool:
      initial-size: 5
      max-size: 20
      max-idle-time: 30m
      max-create-connection-time: 10s

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

// R2DBC エンティティ(JPA アノテーションは使用しない)
@Table("users")
data class R2dbcUser(
    @Id
    val id: Long? = null,

    val name: String,
    val email: String,
    val passwordHash: String,
    val role: String = "USER",
    val status: String = "ACTIVE",
    val bio: String? = null,

    @CreatedDate
    val createdAt: LocalDateTime = LocalDateTime.now(),

    @LastModifiedDate
    var updatedAt: LocalDateTime = LocalDateTime.now()
)

// コルーチンリポジトリ
interface R2dbcUserRepository : CoroutineCrudRepository<R2dbcUser, Long> {

    suspend fun findByEmail(email: String): R2dbcUser?

    fun findByStatus(status: String): Flow<R2dbcUser>

    @Query("SELECT * FROM users WHERE name ILIKE :pattern")
    fun searchByName(@Param("pattern") pattern: String): Flow<R2dbcUser>

    @Query(
        """
        SELECT * FROM users 
        WHERE status = :status 
        ORDER BY created_at DESC 
        LIMIT :limit OFFSET :offset
        """
    )
    fun findByStatusPaged(
        @Param("status") status: String,
        @Param("limit") limit: Int,
        @Param("offset") offset: Int
    ): Flow<R2dbcUser>

    @Query("SELECT COUNT(*) FROM users WHERE status = :status")
    suspend fun countByStatus(@Param("status") status: String): Long

    @Modifying
    @Query("UPDATE users SET status = :status WHERE id = :id")
    suspend fun updateStatus(
        @Param("id") id: Long,
        @Param("status") status: String
    ): Int
}

10.3 リアクティブサービスとコントローラー

@Service
class R2dbcUserService(
    private val userRepository: R2dbcUserRepository,
    private val r2dbcEntityTemplate: R2dbcEntityTemplate
) {
    suspend fun findById(id: Long): R2dbcUser? =
        userRepository.findById(id)

    fun findByStatus(status: String): Flow<R2dbcUser> =
        userRepository.findByStatus(status)

    suspend fun create(request: CreateUserRequest): R2dbcUser {
        val user = R2dbcUser(
            name = request.name,
            email = request.email,
            passwordHash = hashPassword(request.password)
        )
        return userRepository.save(user)
    }

    // R2dbcEntityTemplate による複雑なクエリ
    suspend fun searchUsers(criteria: UserSearchCriteria): List<R2dbcUser> {
        var query = Query.empty()

        criteria.name?.let {
            query = query.addCriteria(
                Criteria.where("name").like("%$it%")
            )
        }
        criteria.status?.let {
            query = query.addCriteria(
                Criteria.where("status").`is`(it.name)
            )
        }
        criteria.createdAfter?.let {
            query = query.addCriteria(
                Criteria.where("created_at").greaterThanOrEquals(it)
            )
        }

        return r2dbcEntityTemplate
            .select(R2dbcUser::class.java)
            .matching(query.sort(Sort.by("createdAt").descending()))
            .flow()
            .toList()
    }
}

@RestController
@RequestMapping("/api/v1/users")
class R2dbcUserController(
    private val userService: R2dbcUserService
) {
    @GetMapping("/{id}")
    suspend fun getUser(@PathVariable id: Long): ResponseEntity<R2dbcUser> {
        val user = userService.findById(id)
            ?: return ResponseEntity.notFound().build()
        return ResponseEntity.ok(user)
    }

    @GetMapping
    fun getUsers(
        @RequestParam(defaultValue = "ACTIVE") status: String
    ): Flow<R2dbcUser> = userService.findByStatus(status)

    @PostMapping
    suspend fun createUser(
        @Valid @RequestBody request: CreateUserRequest
    ): ResponseEntity<R2dbcUser> {
        val user = userService.create(request)
        return ResponseEntity
            .created(URI("/api/v1/users/${user.id}"))
            .body(user)
    }
}

10.4 R2DBC トランザクション管理

@Service
class OrderTransactionService(
    private val orderRepository: R2dbcOrderRepository,
    private val inventoryRepository: R2dbcInventoryRepository,
    private val transactionalOperator: TransactionalOperator
) {
    // アノテーションベース
    @Transactional
    suspend fun createOrder(request: CreateOrderRequest): Order {
        val order = orderRepository.save(
            Order(userId = request.userId, status = "PENDING")
        )

        request.items.forEach { item ->
            val inventory = inventoryRepository.findByProductId(item.productId)
                ?: throw ProductNotFoundException(item.productId)

            if (inventory.quantity < item.quantity) {
                throw InsufficientInventoryException(item.productId)
            }

            inventoryRepository.decreaseQuantity(item.productId, item.quantity)
        }

        return order
    }

    // プログラマティックトランザクション
    suspend fun transferFunds(fromId: Long, toId: Long, amount: BigDecimal) {
        transactionalOperator.executeAndAwait {
            val from = accountRepository.findById(fromId)
                ?: throw AccountNotFoundException(fromId)
            val to = accountRepository.findById(toId)
                ?: throw AccountNotFoundException(toId)

            if (from.balance < amount) {
                throw InsufficientBalanceException(fromId, amount)
            }

            accountRepository.updateBalance(fromId, from.balance - amount)
            accountRepository.updateBalance(toId, to.balance + amount)
        }
    }
}

11. セキュリティ: Spring Security の統合

11.1 基本的なセキュリティ設定

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
class SecurityConfig(
    private val jwtAuthenticationFilter: JwtAuthenticationFilter,
    private val userDetailsService: CustomUserDetailsService
) {

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain =
        http
            .csrf { it.disable() }
            .cors { it.configurationSource(corsConfigurationSource()) }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .authorizeHttpRequests { auth ->
                auth
                    .requestMatchers("/api/v1/auth/**").permitAll()
                    .requestMatchers("/actuator/health/**").permitAll()
                    .requestMatchers("/actuator/prometheus").permitAll()
                    .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                    .requestMatchers(HttpMethod.GET, "/api/v1/users/**").authenticated()
                    .requestMatchers(HttpMethod.POST, "/api/v1/users/**").hasAnyRole("ADMIN", "MODERATOR")
                    .anyRequest().authenticated()
            }
            .exceptionHandling { ex ->
                ex.authenticationEntryPoint(CustomAuthenticationEntryPoint())
                ex.accessDeniedHandler(CustomAccessDeniedHandler())
            }
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
            .build()

    @Bean
    fun corsConfigurationSource(): CorsConfigurationSource {
        val configuration = CorsConfiguration().apply {
            allowedOrigins = listOf("https://example.com", "https://app.example.com")
            allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
            allowedHeaders = listOf("*")
            allowCredentials = true
            maxAge = 3600L
        }
        return UrlBasedCorsConfigurationSource().apply {
            registerCorsConfiguration("/api/**", configuration)
        }
    }

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

    @Bean
    fun authenticationManager(config: AuthenticationConfiguration): AuthenticationManager =
        config.authenticationManager
}

11.2 JWT 認証フィルター

@Component
class JwtAuthenticationFilter(
    private val jwtTokenProvider: JwtTokenProvider,
    private val userDetailsService: CustomUserDetailsService
) : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val token = extractToken(request)

        if (token != null && jwtTokenProvider.validateToken(token)) {
            val username = jwtTokenProvider.getUsernameFromToken(token)
            val userDetails = userDetailsService.loadUserByUsername(username)

            val authentication = UsernamePasswordAuthenticationToken(
                userDetails,
                null,
                userDetails.authorities
            )
            authentication.details = WebAuthenticationDetailsSource()
                .buildDetails(request)

            SecurityContextHolder.getContext().authentication = authentication
        }

        filterChain.doFilter(request, response)
    }

    private fun extractToken(request: HttpServletRequest): String? =
        request.getHeader("Authorization")
            ?.takeIf { it.startsWith("Bearer ") }
            ?.substring(7)
}

@Component
class JwtTokenProvider(
    private val appProperties: AppProperties
) {
    private val logger = KotlinLogging.logger {}

    private val secretKey: SecretKey by lazy {
        Keys.hmacShaKeyFor(appProperties.security.jwtSecret.toByteArray())
    }

    fun generateToken(authentication: Authentication): String {
        val now = Date()
        val expiration = Date(now.time + appProperties.security.jwtExpiration.toMillis())

        return Jwts.builder()
            .subject(authentication.name)
            .claim("roles", authentication.authorities.map { it.authority })
            .issuedAt(now)
            .expiration(expiration)
            .signWith(secretKey)
            .compact()
    }

    fun getUsernameFromToken(token: String): String =
        Jwts.parser()
            .verifyWith(secretKey)
            .build()
            .parseSignedClaims(token)
            .payload
            .subject

    fun validateToken(token: String): Boolean = try {
        Jwts.parser()
            .verifyWith(secretKey)
            .build()
            .parseSignedClaims(token)
        true
    } catch (ex: JwtException) {
        logger.warn { "Invalid JWT token: ${ex.message}" }
        false
    } catch (ex: IllegalArgumentException) {
        logger.warn { "JWT claims string is empty" }
        false
    }
}

11.3 認証コントローラー

@RestController
@RequestMapping("/api/v1/auth")
class AuthController(
    private val authenticationManager: AuthenticationManager,
    private val jwtTokenProvider: JwtTokenProvider,
    private val userService: UserService
) {
    @PostMapping("/login")
    fun login(@Valid @RequestBody request: LoginRequest): ResponseEntity<AuthResponse> {
        val authentication = authenticationManager.authenticate(
            UsernamePasswordAuthenticationToken(request.email, request.password)
        )
        SecurityContextHolder.getContext().authentication = authentication
        val token = jwtTokenProvider.generateToken(authentication)
        return ResponseEntity.ok(AuthResponse(token = token, tokenType = "Bearer"))
    }

    @PostMapping("/register")
    fun register(@Valid @RequestBody request: RegisterRequest): ResponseEntity<UserResponse> {
        val user = userService.register(request)
        return ResponseEntity.status(HttpStatus.CREATED).body(user.toResponse())
    }

    @PostMapping("/refresh")
    fun refreshToken(@RequestHeader("Authorization") bearerToken: String): ResponseEntity<AuthResponse> {
        val token = bearerToken.removePrefix("Bearer ")
        val username = jwtTokenProvider.getUsernameFromToken(token)
        val userDetails = userService.loadUserByUsername(username)
        val auth = UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
        val newToken = jwtTokenProvider.generateToken(auth)
        return ResponseEntity.ok(AuthResponse(token = newToken, tokenType = "Bearer"))
    }
}

data class LoginRequest(
    @field:NotBlank val email: String,
    @field:NotBlank val password: String
)

data class RegisterRequest(
    @field:NotBlank val name: String,
    @field:Email val email: String,
    @field:Size(min = 8) val password: String
)

data class AuthResponse(
    val token: String,
    val tokenType: String = "Bearer"
)

11.4 メソッドレベルセキュリティ

@Service
class AdminService(
    private val userRepository: UserRepository
) {
    @PreAuthorize("hasRole('ADMIN')")
    fun getAllUsers(): List<User> = userRepository.findAll()

    @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
    fun getUserById(id: Long): User =
        userRepository.findById(id).orElseThrow { UserNotFoundException(id) }

    @PreAuthorize("hasRole('ADMIN')")
    @PostAuthorize("returnObject.role != T(com.example.demo.domain.enum.UserRole).ADMIN or hasRole('SUPER_ADMIN')")
    fun updateUserRole(id: Long, role: UserRole): User {
        val user = userRepository.findById(id).orElseThrow { UserNotFoundException(id) }
        user.role = role
        return userRepository.save(user)
    }

    @PreFilter("filterObject.status != T(com.example.demo.domain.enum.UserStatus).SUSPENDED")
    fun batchUpdateUsers(users: List<User>): List<User> =
        userRepository.saveAll(users)
}

12. テスト戦略と実装

12.1 テストピラミッドと全体戦略

Spring Boot + Kotlin のテストは、以下のピラミッド構造に従う。

         /  E2E テスト  \        ← 少数・低速・高コスト
        / 統合テスト      \      ← 中程度
       / サービステスト    \     ← 中程度
      / 単体テスト          \   ← 多数・高速・低コスト

12.2 単体テスト(MockK 使用)

Kotlin テストでは、Mockito の代わりに MockK が推奨される。Kotlin の言語機能(コルーチン、拡張関数、object)との互換性が高い。

@ExtendWith(MockKExtension::class)
class UserServiceTest {

    @MockK
    private lateinit var userRepository: UserRepository

    @MockK
    private lateinit var passwordEncoder: PasswordEncoder

    @MockK
    private lateinit var notificationService: NotificationService

    @InjectMockKs
    private lateinit var userService: UserService

    @BeforeEach
    fun setUp() {
        clearAllMocks()
    }

    @Test
    fun `should create user successfully`() {
        // Given
        val request = CreateUserRequest(
            name = "John Doe",
            email = "john@example.com",
            password = "SecureP@ss1"
        )
        val expectedUser = User(
            id = 1L,
            name = "John Doe",
            email = "john@example.com",
            passwordHash = "hashed_password"
        )

        every { userRepository.existsByEmail(request.email) } returns false
        every { passwordEncoder.encode(request.password) } returns "hashed_password"
        every { userRepository.save(any()) } returns expectedUser
        every { notificationService.sendWelcomeEmail(any()) } just Runs

        // When
        val result = userService.create(request)

        // Then
        assertThat(result.id).isEqualTo(1L)
        assertThat(result.name).isEqualTo("John Doe")
        assertThat(result.email).isEqualTo("john@example.com")

        verify(exactly = 1) { userRepository.existsByEmail(request.email) }
        verify(exactly = 1) { userRepository.save(any()) }
        verify(exactly = 1) { notificationService.sendWelcomeEmail(expectedUser) }
    }

    @Test
    fun `should throw exception when email already exists`() {
        // Given
        val request = CreateUserRequest(
            name = "John Doe",
            email = "existing@example.com",
            password = "SecureP@ss1"
        )

        every { userRepository.existsByEmail(request.email) } returns true

        // When & Then
        assertThrows<DuplicateEmailException> {
            userService.create(request)
        }

        verify(exactly = 1) { userRepository.existsByEmail(request.email) }
        verify(exactly = 0) { userRepository.save(any()) }
    }

    @Test
    fun `should find user by id`() {
        // Given
        val userId = 1L
        val expectedUser = User(id = userId, name = "John", email = "john@example.com", passwordHash = "hash")

        every { userRepository.findById(userId) } returns Optional.of(expectedUser)

        // When
        val result = userService.findById(userId)

        // Then
        assertThat(result).isNotNull
        assertThat(result?.id).isEqualTo(userId)
    }

    @Test
    fun `should return null when user not found`() {
        // Given
        every { userRepository.findById(999L) } returns Optional.empty()

        // When
        val result = userService.findById(999L)

        // Then
        assertThat(result).isNull()
    }

    // コルーチンテスト
    @Test
    fun `should process async operation`() = runTest {
        // Given
        val userId = 1L
        coEvery { asyncUserRepository.findById(userId) } returns mockUser

        // When
        val result = asyncUserService.findById(userId)

        // Then
        assertThat(result).isNotNull
        coVerify { asyncUserRepository.findById(userId) }
    }
}

12.3 コントローラーテスト(@WebMvcTest)

@WebMvcTest(UserController::class)
@AutoConfigureMockMvc(addFilters = false) // セキュリティフィルター無効化
class UserControllerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @MockkBean
    private lateinit var userService: UserService

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    @Test
    fun `GET users should return paginated list`() {
        // Given
        val users = listOf(
            User(1L, "Alice", "alice@example.com", "hash"),
            User(2L, "Bob", "bob@example.com", "hash")
        )
        val page = PageImpl(users, PageRequest.of(0, 20), 2)

        every { userService.findAll(null, any()) } returns page

        // When & Then
        mockMvc.get("/api/v1/users") {
            param("page", "0")
            param("size", "20")
        }.andExpect {
            status { isOk() }
            jsonPath("$.content.length()") { value(2) }
            jsonPath("$.content[0].name") { value("Alice") }
            jsonPath("$.content[1].name") { value("Bob") }
            jsonPath("$.totalElements") { value(2) }
        }
    }

    @Test
    fun `GET user by id should return user`() {
        // Given
        val user = User(1L, "Alice", "alice@example.com", "hash")
        every { userService.findById(1L) } returns user

        // When & Then
        mockMvc.get("/api/v1/users/1")
            .andExpect {
                status { isOk() }
                jsonPath("$.id") { value(1) }
                jsonPath("$.name") { value("Alice") }
                jsonPath("$.email") { value("alice@example.com") }
            }
    }

    @Test
    fun `GET user by non-existent id should return 404`() {
        // Given
        every { userService.findById(999L) } returns null

        // When & Then
        mockMvc.get("/api/v1/users/999")
            .andExpect {
                status { isNotFound() }
            }
    }

    @Test
    fun `POST user with valid data should create user`() {
        // Given
        val request = CreateUserRequest(
            name = "Charlie",
            email = "charlie@example.com",
            password = "SecureP@ss1"
        )
        val createdUser = User(3L, "Charlie", "charlie@example.com", "hash")

        every { userService.create(any()) } returns createdUser

        // When & Then
        mockMvc.post("/api/v1/users") {
            contentType = MediaType.APPLICATION_JSON
            content = objectMapper.writeValueAsString(request)
        }.andExpect {
            status { isCreated() }
            header { string("Location", "/api/v1/users/3") }
            jsonPath("$.id") { value(3) }
            jsonPath("$.name") { value("Charlie") }
        }
    }

    @Test
    fun `POST user with invalid data should return 400`() {
        // Given
        val invalidRequest = mapOf(
            "name" to "",
            "email" to "invalid-email",
            "password" to "short"
        )

        // When & Then
        mockMvc.post("/api/v1/users") {
            contentType = MediaType.APPLICATION_JSON
            content = objectMapper.writeValueAsString(invalidRequest)
        }.andExpect {
            status { isBadRequest() }
            jsonPath("$.code") { value("VALIDATION_ERROR") }
            jsonPath("$.errors.length()") { value(greaterThan(0)) }
        }
    }
}

12.4 リポジトリテスト(@DataJpaTest)

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class UserRepositoryTest {

    companion object {
        @Container
        @JvmStatic
        val postgres = PostgreSQLContainer("postgres:16-alpine").apply {
            withDatabaseName("testdb")
            withUsername("test")
            withPassword("test")
        }

        @JvmStatic
        @DynamicPropertySource
        fun configureProperties(registry: DynamicPropertyRegistry) {
            registry.add("spring.datasource.url") { postgres.jdbcUrl }
            registry.add("spring.datasource.username") { postgres.username }
            registry.add("spring.datasource.password") { postgres.password }
        }
    }

    @Autowired
    private lateinit var userRepository: UserRepository

    @Autowired
    private lateinit var entityManager: TestEntityManager

    @Test
    fun `should find user by email`() {
        // Given
        val user = User(
            name = "Test User",
            email = "test@example.com",
            passwordHash = "hash123"
        )
        entityManager.persistAndFlush(user)

        // When
        val found = userRepository.findByEmail("test@example.com")

        // Then
        assertThat(found).isNotNull
        assertThat(found?.name).isEqualTo("Test User")
    }

    @Test
    fun `should return null for non-existent email`() {
        val found = userRepository.findByEmail("nonexistent@example.com")
        assertThat(found).isNull()
    }

    @Test
    fun `should find users by status with pagination`() {
        // Given
        repeat(25) { i ->
            entityManager.persist(
                User(
                    name = "User $i",
                    email = "user$i@example.com",
                    passwordHash = "hash",
                    status = if (i % 2 == 0) UserStatus.ACTIVE else UserStatus.INACTIVE
                )
            )
        }
        entityManager.flush()

        // When
        val pageable = PageRequest.of(0, 10)
        val result = userRepository.findByStatus(UserStatus.ACTIVE, pageable)

        // Then
        assertThat(result.content).hasSize(10)
        assertThat(result.totalElements).isEqualTo(13)
        assertThat(result.content).allSatisfy { user ->
            assertThat(user.status).isEqualTo(UserStatus.ACTIVE)
        }
    }
}

12.5 統合テスト(@SpringBootTest)

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("test")
class UserIntegrationTest {

    companion object {
        @Container
        @JvmStatic
        val postgres = PostgreSQLContainer("postgres:16-alpine")

        @JvmStatic
        @DynamicPropertySource
        fun configureProperties(registry: DynamicPropertyRegistry) {
            registry.add("spring.datasource.url") { postgres.jdbcUrl }
            registry.add("spring.datasource.username") { postgres.username }
            registry.add("spring.datasource.password") { postgres.password }
        }
    }

    @Autowired
    private lateinit var testRestTemplate: TestRestTemplate

    @Autowired
    private lateinit var userRepository: UserRepository

    @BeforeEach
    fun setUp() {
        userRepository.deleteAll()
    }

    @Test
    fun `full user lifecycle - create, read, update, delete`() {
        // Create
        val createRequest = CreateUserRequest(
            name = "Integration Test User",
            email = "integration@example.com",
            password = "SecureP@ss1"
        )

        val createResponse = testRestTemplate.postForEntity(
            "/api/v1/users",
            createRequest,
            UserResponse::class.java
        )

        assertThat(createResponse.statusCode).isEqualTo(HttpStatus.CREATED)
        val userId = createResponse.body!!.id

        // Read
        val getResponse = testRestTemplate.getForEntity(
            "/api/v1/users/$userId",
            UserResponse::class.java
        )

        assertThat(getResponse.statusCode).isEqualTo(HttpStatus.OK)
        assertThat(getResponse.body!!.name).isEqualTo("Integration Test User")

        // Update
        val updateRequest = UpdateUserRequest(name = "Updated Name")
        val updateResponse = testRestTemplate.exchange(
            "/api/v1/users/$userId",
            HttpMethod.PUT,
            HttpEntity(updateRequest),
            UserResponse::class.java
        )

        assertThat(updateResponse.statusCode).isEqualTo(HttpStatus.OK)
        assertThat(updateResponse.body!!.name).isEqualTo("Updated Name")

        // Delete
        testRestTemplate.delete("/api/v1/users/$userId")

        val deletedResponse = testRestTemplate.getForEntity(
            "/api/v1/users/$userId",
            ErrorResponse::class.java
        )
        assertThat(deletedResponse.statusCode).isEqualTo(HttpStatus.NOT_FOUND)
    }
}

12.6 Kotest による BDD スタイルテスト

class UserServiceKotestSpec : BehaviorSpec({

    val userRepository = mockk<UserRepository>()
    val passwordEncoder = mockk<PasswordEncoder>()
    val notificationService = mockk<NotificationService>()
    val userService = UserService(userRepository, passwordEncoder, notificationService)

    beforeSpec {
        clearAllMocks()
    }

    Given("新規ユーザー登録のリクエスト") {
        val request = CreateUserRequest(
            name = "テスト太郎",
            email = "taro@example.com",
            password = "SecureP@ss1"
        )

        When("メールアドレスが未使用の場合") {
            every { userRepository.existsByEmail(request.email) } returns false
            every { passwordEncoder.encode(any()) } returns "encoded"
            every { userRepository.save(any()) } answers {
                (firstArg() as User).apply { /* id is set */ }
            }
            every { notificationService.sendWelcomeEmail(any()) } just Runs

            val result = userService.create(request)

            Then("ユーザーが正常に作成される") {
                result.name shouldBe "テスト太郎"
                result.email shouldBe "taro@example.com"
            }

            Then("ウェルカムメールが送信される") {
                verify(exactly = 1) { notificationService.sendWelcomeEmail(any()) }
            }
        }

        When("メールアドレスが既に使用されている場合") {
            every { userRepository.existsByEmail(request.email) } returns true

            Then("DuplicateEmailException がスローされる") {
                shouldThrow<DuplicateEmailException> {
                    userService.create(request)
                }
            }
        }
    }
})

13. コルーチンと WebFlux によるリアクティブプログラミング

13.1 Kotlin コルーチンの基礎

Kotlin コルーチンは、非同期プログラミングを同期的なコードスタイルで記述できる軽量な並行処理メカニズムである。

// suspend 関数の基本
suspend fun fetchUserData(userId: Long): UserData {
    val user = userService.findById(userId)         // 非同期だが同期的に見える
    val orders = orderService.findByUserId(userId)   // 順次実行
    return UserData(user, orders)
}

// 並行実行
suspend fun fetchUserDataConcurrently(userId: Long): UserData = coroutineScope {
    val userDeferred = async { userService.findById(userId) }
    val ordersDeferred = async { orderService.findByUserId(userId) }

    UserData(
        user = userDeferred.await(),
        orders = ordersDeferred.await()
    )
}

13.2 Spring WebFlux とコルーチンの統合

@RestController
@RequestMapping("/api/v1/reactive/users")
class ReactiveUserController(
    private val userService: ReactiveUserService
) {
    // suspend 関数による単一値の返却
    @GetMapping("/{id}")
    suspend fun getUser(@PathVariable id: Long): ResponseEntity<UserResponse> {
        val user = userService.findById(id)
            ?: return ResponseEntity.notFound().build()
        return ResponseEntity.ok(user.toResponse())
    }

    // Flow による複数値のストリーミング
    @GetMapping(produces = [MediaType.APPLICATION_NDJSON_VALUE])
    fun streamUsers(): Flow<UserResponse> =
        userService.findAll()
            .map { it.toResponse() }

    // Server-Sent Events
    @GetMapping("/events", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
    fun userEvents(): Flow<ServerSentEvent<UserEvent>> =
        userService.userEventStream()
            .map { event ->
                ServerSentEvent.builder<UserEvent>()
                    .id(event.id.toString())
                    .event(event.type.name)
                    .data(event)
                    .build()
            }

    @PostMapping
    suspend fun createUser(
        @Valid @RequestBody request: CreateUserRequest
    ): ResponseEntity<UserResponse> {
        val user = userService.create(request)
        return ResponseEntity
            .created(URI("/api/v1/reactive/users/${user.id}"))
            .body(user.toResponse())
    }
}

13.3 リアクティブサービス層

@Service
class ReactiveUserService(
    private val userRepository: R2dbcUserRepository,
    private val webClient: WebClient,
    private val cacheManager: ReactiveCacheManager
) {
    private val logger = KotlinLogging.logger {}

    suspend fun findById(id: Long): R2dbcUser? {
        return cacheManager.get("user:$id") {
            userRepository.findById(id)
        }
    }

    fun findAll(): Flow<R2dbcUser> =
        userRepository.findAll().asFlow()

    suspend fun create(request: CreateUserRequest): R2dbcUser {
        // 外部サービスでのメール検証(非同期)
        val isValidEmail = verifyEmail(request.email)
        if (!isValidEmail) {
            throw InvalidEmailException(request.email)
        }

        val user = R2dbcUser(
            name = request.name,
            email = request.email,
            passwordHash = hashPassword(request.password)
        )

        return userRepository.save(user).also {
            logger.info { "User created: ${it.id}" }
        }
    }

    // WebClient によるリアクティブ HTTP 通信
    private suspend fun verifyEmail(email: String): Boolean =
        webClient.get()
            .uri("/verify?email={email}", email)
            .retrieve()
            .awaitBody<EmailVerificationResponse>()
            .isValid

    // 複数の非同期操作の並行実行
    suspend fun getUserDashboard(userId: Long): UserDashboard = coroutineScope {
        val user = async { findById(userId) ?: throw UserNotFoundException(userId) }
        val recentOrders = async { orderService.findRecentByUserId(userId) }
        val notifications = async { notificationService.getUnread(userId) }
        val analytics = async { analyticsService.getUserAnalytics(userId) }

        UserDashboard(
            user = user.await().toResponse(),
            recentOrders = recentOrders.await().toList(),
            unreadNotifications = notifications.await().toList(),
            analytics = analytics.await()
        )
    }

    // Flow を使用したイベントストリーム
    fun userEventStream(): Flow<UserEvent> = flow {
        while (currentCoroutineContext().isActive) {
            val events = eventRepository.findNewEvents()
            events.collect { event ->
                emit(event)
            }
            delay(1000) // 1秒ごとにポーリング
        }
    }
}

13.4 WebClient の設定と使用

@Configuration
class WebClientConfig {

    @Bean
    fun webClient(builder: WebClient.Builder): WebClient =
        builder
            .baseUrl("https://api.external-service.com")
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
            .filter(logRequest())
            .filter(logResponse())
            .filter(retryFilter())
            .codecs { configurer ->
                configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024) // 16MB
            }
            .build()

    private fun logRequest() = ExchangeFilterFunction.ofRequestProcessor { request ->
        KotlinLogging.logger {}.debug {
            ">>> ${request.method()} ${request.url()}"
        }
        Mono.just(request)
    }

    private fun logResponse() = ExchangeFilterFunction.ofResponseProcessor { response ->
        KotlinLogging.logger {}.debug {
            "<<< ${response.statusCode()}"
        }
        Mono.just(response)
    }

    private fun retryFilter() = ExchangeFilterFunction { request, next ->
        next.exchange(request)
            .retryWhen(
                Retry.backoff(3, Duration.ofSeconds(1))
                    .filter { it is WebClientResponseException.ServiceUnavailable }
            )
    }
}

// WebClient を使用した外部 API クライアント
@Component
class ExternalApiClient(
    private val webClient: WebClient
) {
    private val logger = KotlinLogging.logger {}

    suspend fun fetchData(id: String): ExternalData =
        webClient.get()
            .uri("/data/{id}", id)
            .retrieve()
            .onStatus({ it.is4xxClientError }) { response ->
                response.bodyToMono<String>().map { body ->
                    ClientException("Client error: ${response.statusCode()} - $body")
                }
            }
            .onStatus({ it.is5xxServerError }) { response ->
                Mono.error(ServerException("Server error: ${response.statusCode()}"))
            }
            .awaitBody()

    suspend fun postData(data: CreateDataRequest): ExternalData =
        webClient.post()
            .uri("/data")
            .bodyValue(data)
            .retrieve()
            .awaitBody()

    // Flow でのストリーミング受信
    fun streamData(): Flow<ExternalData> =
        webClient.get()
            .uri("/data/stream")
            .accept(MediaType.APPLICATION_NDJSON)
            .retrieve()
            .bodyToFlow()
}

14. メッセージングとイベント駆動アーキテクチャ

14.1 Spring Events(アプリケーション内イベント)

// イベント定義
sealed class DomainEvent(
    val timestamp: Instant = Instant.now(),
    val eventId: String = UUID.randomUUID().toString()
)

data class UserCreatedEvent(
    val userId: Long,
    val email: String,
    val name: String
) : DomainEvent()

data class OrderPlacedEvent(
    val orderId: Long,
    val userId: Long,
    val totalAmount: BigDecimal
) : DomainEvent()

data class PaymentCompletedEvent(
    val paymentId: Long,
    val orderId: Long,
    val amount: BigDecimal
) : DomainEvent()

// イベント発行
@Service
class UserService(
    private val userRepository: UserRepository,
    private val applicationEventPublisher: ApplicationEventPublisher
) {
    @Transactional
    fun create(request: CreateUserRequest): User {
        val user = userRepository.save(request.toEntity())

        applicationEventPublisher.publishEvent(
            UserCreatedEvent(
                userId = user.id,
                email = user.email,
                name = user.name
            )
        )

        return user
    }
}

// イベントリスナー
@Component
class UserEventListener(
    private val emailService: EmailService,
    private val analyticsService: AnalyticsService
) {
    private val logger = KotlinLogging.logger {}

    @EventListener
    fun handleUserCreated(event: UserCreatedEvent) {
        logger.info { "User created event received: ${event.userId}" }
        emailService.sendWelcomeEmail(event.email, event.name)
    }

    // 非同期イベント処理
    @Async
    @EventListener
    fun handleUserCreatedAsync(event: UserCreatedEvent) {
        logger.info { "Processing analytics for new user: ${event.userId}" }
        analyticsService.trackUserCreation(event)
    }

    // 条件付きリスナー
    @EventListener(condition = "#event.totalAmount > 10000")
    fun handleHighValueOrder(event: OrderPlacedEvent) {
        logger.info { "High value order detected: ${event.orderId}" }
    }

    // トランザクションイベントリスナー
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun handleAfterCommit(event: UserCreatedEvent) {
        logger.info { "User creation committed, sending notifications" }
    }
}

14.2 Apache Kafka 連携

// build.gradle.kts
// implementation("org.springframework.kafka:spring-kafka")

// Kafka 設定
@Configuration
class KafkaConfig {

    @Bean
    fun producerFactory(): ProducerFactory<String, String> {
        val config = mapOf(
            ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to "localhost:9092",
            ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
            ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
            ProducerConfig.ACKS_CONFIG to "all",
            ProducerConfig.RETRIES_CONFIG to 3,
            ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG to true
        )
        return DefaultKafkaProducerFactory(config)
    }

    @Bean
    fun kafkaTemplate(): KafkaTemplate<String, String> =
        KafkaTemplate(producerFactory())

    @Bean
    fun consumerFactory(): ConsumerFactory<String, String> {
        val config = mapOf(
            ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to "localhost:9092",
            ConsumerConfig.GROUP_ID_CONFIG to "demo-group",
            ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
            ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
            ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest",
            ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false
        )
        return DefaultKafkaConsumerFactory(config)
    }

    @Bean
    fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory<String, String> =
        ConcurrentKafkaListenerContainerFactory<String, String>().apply {
            consumerFactory = consumerFactory()
            containerProperties.ackMode = ContainerProperties.AckMode.MANUAL_IMMEDIATE
            setConcurrency(3)
        }
}

// Kafka プロデューサー
@Service
class KafkaEventPublisher(
    private val kafkaTemplate: KafkaTemplate<String, String>,
    private val objectMapper: ObjectMapper
) {
    private val logger = KotlinLogging.logger {}

    suspend fun publish(topic: String, key: String, event: DomainEvent) {
        val message = objectMapper.writeValueAsString(event)
        try {
            kafkaTemplate.send(topic, key, message).await()
            logger.info { "Published event to $topic: $key" }
        } catch (ex: Exception) {
            logger.error(ex) { "Failed to publish event to $topic: $key" }
            throw EventPublishException("Failed to publish event", ex)
        }
    }
}

// Kafka コンシューマー
@Component
class KafkaEventConsumer(
    private val orderService: OrderService,
    private val objectMapper: ObjectMapper
) {
    private val logger = KotlinLogging.logger {}

    @KafkaListener(
        topics = ["order-events"],
        groupId = "order-processor",
        containerFactory = "kafkaListenerContainerFactory"
    )
    fun handleOrderEvent(
        @Payload message: String,
        @Header(KafkaHeaders.RECEIVED_KEY) key: String,
        @Header(KafkaHeaders.RECEIVED_TOPIC) topic: String,
        @Header(KafkaHeaders.OFFSET) offset: Long,
        acknowledgment: Acknowledgment
    ) {
        logger.info { "Received message from $topic [offset=$offset]: $key" }

        try {
            val event = objectMapper.readValue(message, OrderPlacedEvent::class.java)
            orderService.processOrder(event)
            acknowledgment.acknowledge()
        } catch (ex: Exception) {
            logger.error(ex) { "Failed to process message: $key" }
            // DLQ (Dead Letter Queue) に送信するなどのエラー処理
        }
    }
}

15. キャッシュとパフォーマンス最適化

15.1 Spring Cache 抽象化

@Configuration
@EnableCaching
class CacheConfig {

    @Bean
    fun cacheManager(): CacheManager {
        val caffeine = Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(Duration.ofMinutes(10))
            .recordStats()

        return CaffeineCacheManager().apply {
            setCaffeine(caffeine)
            setCacheNames(listOf("users", "orders", "products"))
        }
    }

    // 複数キャッシュマネージャーの設定
    @Bean("redisCacheManager")
    fun redisCacheManager(connectionFactory: RedisConnectionFactory): RedisCacheManager {
        val defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(30))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    GenericJackson2JsonRedisSerializer()
                )
            )
            .disableCachingNullValues()

        val cacheConfigs = mapOf(
            "users" to defaultConfig.entryTtl(Duration.ofMinutes(15)),
            "sessions" to defaultConfig.entryTtl(Duration.ofHours(1)),
            "products" to defaultConfig.entryTtl(Duration.ofHours(24))
        )

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigs)
            .transactionAware()
            .build()
    }
}

@Service
class CachedUserService(
    private val userRepository: UserRepository
) {
    private val logger = KotlinLogging.logger {}

    @Cacheable(value = ["users"], key = "#id")
    fun findById(id: Long): User? {
        logger.info { "Cache miss for user: $id" }
        return userRepository.findById(id).orElse(null)
    }

    @Cacheable(value = ["users"], key = "'email:' + #email")
    fun findByEmail(email: String): User? {
        logger.info { "Cache miss for email: $email" }
        return userRepository.findByEmail(email)
    }

    @CachePut(value = ["users"], key = "#result.id")
    fun update(id: Long, request: UpdateUserRequest): User {
        val user = userRepository.findById(id).orElseThrow { UserNotFoundException(id) }
        request.name?.let { user.name = it }
        request.email?.let { user.email = it }
        return userRepository.save(user)
    }

    @CacheEvict(value = ["users"], key = "#id")
    fun delete(id: Long) {
        userRepository.deleteById(id)
    }

    @CacheEvict(value = ["users"], allEntries = true)
    fun clearCache() {
        logger.info { "User cache cleared" }
    }

    // 複合キャッシュ操作
    @Caching(
        put = [CachePut(value = ["users"], key = "#result.id")],
        evict = [CacheEvict(value = ["users"], key = "'email:' + #result.email")]
    )
    fun save(user: User): User = userRepository.save(user)
}

15.2 N+1 問題の解決

// 問題のあるコード(N+1 クエリ発生)
@Service
class BadOrderService(private val orderRepository: OrderRepository) {
    fun getOrdersWithUsers(): List<OrderDto> {
        val orders = orderRepository.findAll() // 1回目のクエリ
        return orders.map { order ->
            OrderDto(
                id = order.id,
                userName = order.user?.name ?: "Unknown" // N回のクエリ
            )
        }
    }
}

// 解決策1: JOIN FETCH
interface OrderRepository : JpaRepository<Order, Long> {
    @Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.status = :status")
    fun findByStatusWithUser(@Param("status") status: OrderStatus): List<Order>
}

// 解決策2: @EntityGraph
interface OrderRepository : JpaRepository<Order, Long> {
    @EntityGraph(attributePaths = ["user", "items"])
    fun findByStatus(status: OrderStatus): List<Order>

    @EntityGraph(attributePaths = ["user"])
    override fun findAll(): List<Order>
}

// 解決策3: Batch Fetch Size(application.yml で設定済み)
// spring.jpa.properties.hibernate.default_batch_fetch_size: 100

15.3 接続プーリングと非同期処理

@Configuration
@EnableAsync
class AsyncConfig : AsyncConfigurer {

    override fun getAsyncExecutor(): Executor =
        ThreadPoolTaskExecutor().apply {
            corePoolSize = 10
            maxPoolSize = 50
            queueCapacity = 100
            setThreadNamePrefix("async-")
            setRejectedExecutionHandler(ThreadPoolExecutor.CallerRunsPolicy())
            setWaitForTasksToCompleteOnShutdown(true)
            setAwaitTerminationSeconds(60)
            initialize()
        }

    override fun getAsyncUncaughtExceptionHandler(): AsyncUncaughtExceptionHandler =
        AsyncUncaughtExceptionHandler { ex, method, params ->
            KotlinLogging.logger {}.error(ex) {
                "Async execution failed: ${method.name} with params: ${params.toList()}"
            }
        }
}

@Service
class AsyncProcessingService(
    private val emailService: EmailService
) {
    @Async
    fun sendBulkEmails(recipients: List<String>, template: EmailTemplate) {
        recipients.chunked(50).forEach { batch ->
            batch.forEach { recipient ->
                try {
                    emailService.send(recipient, template)
                } catch (ex: Exception) {
                    KotlinLogging.logger {}.error(ex) { "Failed to send email to $recipient" }
                }
            }
        }
    }

    @Async
    fun processInBackground(): CompletableFuture<ProcessingResult> {
        // 長時間処理
        val result = heavyComputation()
        return CompletableFuture.completedFuture(result)
    }
}

16. 監視・可観測性(Observability)

16.1 Spring Boot Actuator

# application.yml - Actuator 設定
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus,env,loggers,threaddump,heapdump
      base-path: /actuator
  endpoint:
    health:
      show-details: when_authorized
      show-components: when_authorized
      probes:
        enabled: true
      group:
        readiness:
          include: db,redis,kafka
        liveness:
          include: ping
  health:
    db:
      enabled: true
    redis:
      enabled: true
    diskspace:
      enabled: true
      threshold: 1GB
  info:
    env:
      enabled: true
    git:
      mode: full
    build:
      enabled: true
  metrics:
    tags:
      application: ${spring.application.name}
      environment: ${spring.profiles.active:default}
    distribution:
      percentiles-histogram:
        http.server.requests: true
      percentiles:
        http.server.requests: 0.5,0.75,0.95,0.99
      slo:
        http.server.requests: 50ms,100ms,200ms,500ms,1s

16.2 カスタムヘルスインジケーター

@Component
class ExternalServiceHealthIndicator(
    private val webClient: WebClient
) : AbstractHealthIndicator() {

    override fun doHealthCheck(builder: Health.Builder) {
        try {
            val response = webClient.get()
                .uri("/health")
                .retrieve()
                .toBodilessEntity()
                .block(Duration.ofSeconds(5))

            if (response?.statusCode?.is2xxSuccessful == true) {
                builder.up()
                    .withDetail("status", "reachable")
                    .withDetail("responseTime", "OK")
            } else {
                builder.down()
                    .withDetail("status", "unhealthy")
                    .withDetail("statusCode", response?.statusCode?.value() ?: "unknown")
            }
        } catch (ex: Exception) {
            builder.down()
                .withDetail("error", ex.message ?: "Unknown error")
                .withException(ex)
        }
    }
}

@Component
class DatabaseConnectionPoolHealthIndicator(
    private val dataSource: DataSource
) : AbstractHealthIndicator() {

    override fun doHealthCheck(builder: Health.Builder) {
        if (dataSource is HikariDataSource) {
            val pool = dataSource.hikariPoolMXBean
            builder.up()
                .withDetail("activeConnections", pool?.activeConnections ?: 0)
                .withDetail("idleConnections", pool?.idleConnections ?: 0)
                .withDetail("totalConnections", pool?.totalConnections ?: 0)
                .withDetail("threadsAwaitingConnection", pool?.threadsAwaitingConnection ?: 0)
        }
    }
}

16.3 カスタムメトリクス

@Component
class BusinessMetrics(
    private val meterRegistry: MeterRegistry
) {
    // カウンター
    private val orderCounter = Counter.builder("business.orders.created")
        .description("Number of orders created")
        .tag("type", "total")
        .register(meterRegistry)

    // ゲージ
    private val activeUsers = AtomicInteger(0)

    init {
        Gauge.builder("business.users.active", activeUsers) { it.get().toDouble() }
            .description("Number of currently active users")
            .register(meterRegistry)
    }

    // タイマー
    private val orderProcessingTimer = Timer.builder("business.orders.processing")
        .description("Order processing time")
        .publishPercentiles(0.5, 0.95, 0.99)
        .register(meterRegistry)

    // ディストリビューションサマリー
    private val orderAmountSummary = DistributionSummary.builder("business.orders.amount")
        .description("Order amount distribution")
        .baseUnit("yen")
        .publishPercentiles(0.5, 0.75, 0.95)
        .register(meterRegistry)

    fun recordOrderCreated(amount: BigDecimal) {
        orderCounter.increment()
        orderAmountSummary.record(amount.toDouble())
    }

    fun <T> timeOrderProcessing(block: () -> T): T =
        orderProcessingTimer.recordCallable(block)!!

    fun incrementActiveUsers() = activeUsers.incrementAndGet()
    fun decrementActiveUsers() = activeUsers.decrementAndGet()
}

// AOP によるメトリクス収集
@Aspect
@Component
class MetricsAspect(
    private val meterRegistry: MeterRegistry
) {
    @Around("@annotation(timed)")
    fun timeMethod(joinPoint: ProceedingJoinPoint, timed: Timed): Any? {
        val timer = Timer.builder(timed.value.ifEmpty { "method.execution" })
            .tag("class", joinPoint.target.javaClass.simpleName)
            .tag("method", joinPoint.signature.name)
            .register(meterRegistry)

        return timer.recordCallable { joinPoint.proceed() }
    }
}

// カスタム @Timed アノテーションの使用
@Service
class OrderService(
    private val businessMetrics: BusinessMetrics
) {
    @Timed("business.order.creation")
    fun createOrder(request: CreateOrderRequest): Order {
        return businessMetrics.timeOrderProcessing {
            // 注文処理ロジック
            val order = processOrder(request)
            businessMetrics.recordOrderCreated(order.totalAmount)
            order
        }
    }
}

16.4 分散トレーシング(Micrometer Tracing)

// build.gradle.kts
// implementation("io.micrometer:micrometer-tracing-bridge-brave")
// implementation("io.zipkin.reporter2:zipkin-reporter-brave")

// application.yml
// management:
//   tracing:
//     sampling:
//       probability: 1.0
//   zipkin:
//     tracing:
//       endpoint: http://localhost:9411/api/v2/spans

@Service
class TracedOrderService(
    private val orderRepository: OrderRepository,
    private val observationRegistry: ObservationRegistry
) {
    private val logger = KotlinLogging.logger {}

    fun processOrder(orderId: Long): Order {
        return Observation.createNotStarted("order.processing", observationRegistry)
            .lowCardinalityKeyValue("order.type", "standard")
            .observe {
                logger.info { "Processing order: $orderId" }

                val order = orderRepository.findById(orderId)
                    .orElseThrow { OrderNotFoundException(orderId) }

                validateOrder(order)
                processPayment(order)
                updateInventory(order)

                order.apply { status = OrderStatus.CONFIRMED }
                    .let { orderRepository.save(it) }
            }!!
    }
}

16.5 構造化ログ

// logback-spring.xml
/*
<configuration>
    <springProfile name="prod">
        <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                <includeMdcKeyName>requestId</includeMdcKeyName>
                <includeMdcKeyName>userId</includeMdcKeyName>
                <includeMdcKeyName>traceId</includeMdcKeyName>
                <includeMdcKeyName>spanId</includeMdcKeyName>
            </encoder>
        </appender>
        <root level="INFO">
            <appender-ref ref="JSON" />
        </root>
    </springProfile>
</configuration>
*/

// MDC を活用した構造化ログ
@Component
class StructuredLoggingFilter : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val requestId = request.getHeader("X-Request-Id") ?: UUID.randomUUID().toString()
        val userId = SecurityContextHolder.getContext().authentication?.name

        MDC.put("requestId", requestId)
        MDC.put("userId", userId ?: "anonymous")
        MDC.put("method", request.method)
        MDC.put("path", request.requestURI)

        try {
            filterChain.doFilter(request, response)
        } finally {
            MDC.clear()
        }
    }
}

17. Docker とクラウドネイティブデプロイ

17.1 マルチステージ Dockerfile

# ビルドステージ
FROM eclipse-temurin:21-jdk-alpine AS builder

WORKDIR /app

# Gradle ラッパーと設定ファイルをコピー(キャッシュ最適化)
COPY gradle/ gradle/
COPY gradlew build.gradle.kts settings.gradle.kts gradle.properties ./

# 依存関係のダウンロード(キャッシュレイヤー)
RUN ./gradlew dependencies --no-daemon

# ソースコードのコピーとビルド
COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test

# 実行ステージ
FROM eclipse-temurin:21-jre-alpine AS runtime

RUN addgroup -S spring && adduser -S spring -G spring

WORKDIR /app

# ビルド成果物のコピー
COPY --from=builder /app/build/libs/*.jar app.jar

# 非 root ユーザーで実行
USER spring:spring

# ヘルスチェック
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
    CMD wget -qO- http://localhost:8080/actuator/health/liveness || exit 1

EXPOSE 8080

ENTRYPOINT ["java", \
    "-XX:+UseContainerSupport", \
    "-XX:MaxRAMPercentage=75.0", \
    "-XX:+ExitOnOutOfMemoryError", \
    "-Djava.security.egd=file:/dev/./urandom", \
    "-jar", "app.jar"]

17.2 Spring Boot の Layered JAR(レイヤー化 JAR)

# レイヤー化ビルド(より高速なキャッシュ)
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY gradle/ gradle/
COPY gradlew build.gradle.kts settings.gradle.kts gradle.properties ./
RUN ./gradlew dependencies --no-daemon
COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test

# レイヤー展開
FROM eclipse-temurin:21-jdk-alpine AS layers
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

# 実行ステージ(レイヤーキャッシュ最適化)
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app

# 変更頻度の低い順にコピー(キャッシュ効率最大化)
COPY --from=layers /app/dependencies/ ./
COPY --from=layers /app/spring-boot-loader/ ./
COPY --from=layers /app/snapshot-dependencies/ ./
COPY --from=layers /app/application/ ./

USER spring:spring
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

17.3 Docker Compose による開発環境

# docker-compose.yml
version: '3.9'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=dev
      - DB_HOST=postgres
      - DB_PORT=5432
      - DB_NAME=demo_db
      - DB_USERNAME=demo_user
      - DB_PASSWORD=demo_password
      - REDIS_HOST=redis
      - KAFKA_BOOTSTRAP_SERVERS=kafka:9092
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - app-network
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: demo_db
      POSTGRES_USER: demo_user
      POSTGRES_PASSWORD: demo_password
    ports:
      - "5432:5432"
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U demo_user -d demo_db"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app-network

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    networks:
      - app-network

  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
    networks:
      - app-network

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana-data:/var/lib/grafana
    networks:
      - app-network

volumes:
  postgres-data:
  redis-data:
  grafana-data:

networks:
  app-network:
    driver: bridge

17.4 Kubernetes マニフェスト

# k8s/deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo-application
  labels:
    app: demo-application
spec:
  replicas: 3
  selector:
    matchLabels:
      app: demo-application
  template:
    metadata:
      labels:
        app: demo-application
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "8080"
        prometheus.io/path: "/actuator/prometheus"
    spec:
      containers:
        - name: demo-application
          image: demo-application:latest
          ports:
            - containerPort: 8080
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: "prod"
            - name: DB_HOST
              valueFrom:
                secretKeyRef:
                  name: db-credentials
                  key: host
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: db-credentials
                  key: password
            - name: JAVA_OPTS
              value: "-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"
          resources:
            requests:
              memory: "512Mi"
              cpu: "250m"
            limits:
              memory: "1Gi"
              cpu: "1000m"
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 60
            periodSeconds: 15
            failureThreshold: 3
          startupProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5
            failureThreshold: 30
      terminationGracePeriodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
  name: demo-application
spec:
  selector:
    app: demo-application
  ports:
    - port: 80
      targetPort: 8080
  type: ClusterIP

18. GraalVM ネイティブイメージ対応

18.1 GraalVM ネイティブイメージの概要

GraalVM ネイティブイメージは、Java/Kotlin アプリケーションを事前コンパイル(AOT: Ahead-of-Time)により、ネイティブ実行可能ファイルに変換する技術である。以下の利点がある。

  • 起動時間: ミリ秒単位の超高速起動(通常の JVM 起動の 50-100 倍高速)
  • メモリ使用量: JVM に比べて大幅に削減(通常 1/3 から 1/5)
  • ピーク性能: ウォームアップ不要で即座にピーク性能を発揮

18.2 ビルド設定

// build.gradle.kts
plugins {
    id("org.graalvm.buildtools.native") version "0.10.3"
}

graalvmNative {
    binaries {
        named("main") {
            imageName.set("demo-application")
            mainClass.set("com.example.demo.DemoApplicationKt")
            buildArgs.addAll(
                "--no-fallback",
                "--enable-url-protocols=http,https",
                "-H:+ReportExceptionStackTraces",
                "--initialize-at-build-time=org.slf4j"
            )
            jvmArgs.add("-Xmx4g") // ビルド時のメモリ
        }
    }
    metadataRepository {
        enabled.set(true)
    }
}

18.3 リフレクション設定

ネイティブイメージではリフレクションが制限されるため、使用するクラスを事前に登録する必要がある。

// RuntimeHints による登録
@Configuration
@ImportRuntimeHints(AppRuntimeHints::class)
class NativeConfig

class AppRuntimeHints : RuntimeHintsRegistrar {
    override fun registerHints(hints: RuntimeHints, classLoader: ClassLoader?) {
        // リフレクション登録
        hints.reflection()
            .registerType(UserResponse::class.java, MemberCategory.entries.toTypedArray())
            .registerType(CreateUserRequest::class.java, MemberCategory.entries.toTypedArray())
            .registerType(ErrorResponse::class.java, MemberCategory.entries.toTypedArray())

        // リソース登録
        hints.resources()
            .registerPattern("db/migration/*")
            .registerPattern("templates/*")
            .registerPattern("static/*")

        // プロキシ登録
        hints.proxies()
            .registerJdkProxy(UserRepository::class.java)
    }
}

18.4 ネイティブイメージ用 Dockerfile

# GraalVM ネイティブイメージビルド
FROM ghcr.io/graalvm/native-image-community:21 AS builder

WORKDIR /app

COPY gradle/ gradle/
COPY gradlew build.gradle.kts settings.gradle.kts gradle.properties ./
RUN ./gradlew dependencies --no-daemon

COPY src/ src/
RUN ./gradlew nativeCompile --no-daemon

# 実行ステージ(極小イメージ)
FROM alpine:3.19
RUN apk add --no-cache libc6-compat

COPY --from=builder /app/build/native/nativeCompile/demo-application /app/demo-application

RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring

EXPOSE 8080
ENTRYPOINT ["/app/demo-application"]

19. マイクロサービスアーキテクチャの実践

19.1 Spring Cloud との統合

// build.gradle.kts
dependencyManagement {
    imports {
        mavenBom("org.springframework.cloud:spring-cloud-dependencies:2024.0.0")
    }
}

dependencies {
    implementation("org.springframework.cloud:spring-cloud-starter-config")
    implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client")
    implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j")
    implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
    implementation("org.springframework.cloud:spring-cloud-starter-gateway")
}

19.2 サーキットブレーカー(Resilience4j)

@Configuration
class Resilience4jConfig {

    @Bean
    fun circuitBreakerConfig(): Customizer<Resilience4JCircuitBreakerFactory> =
        Customizer { factory ->
            factory.configureDefault { id ->
                Resilience4JConfigBuilder(id)
                    .circuitBreakerConfig(
                        CircuitBreakerConfig.custom()
                            .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
                            .slidingWindowSize(10)
                            .failureRateThreshold(50f)
                            .waitDurationInOpenState(Duration.ofSeconds(30))
                            .permittedNumberOfCallsInHalfOpenState(3)
                            .minimumNumberOfCalls(5)
                            .build()
                    )
                    .timeLimiterConfig(
                        TimeLimiterConfig.custom()
                            .timeoutDuration(Duration.ofSeconds(5))
                            .build()
                    )
                    .build()
            }
        }
}

@Service
class ResilientExternalService(
    private val circuitBreakerFactory: CircuitBreakerFactory<*, *>,
    private val webClient: WebClient
) {
    private val logger = KotlinLogging.logger {}

    fun fetchData(id: String): ExternalData {
        val circuitBreaker = circuitBreakerFactory.create("externalService")

        return circuitBreaker.run(
            {
                webClient.get()
                    .uri("/data/$id")
                    .retrieve()
                    .bodyToMono(ExternalData::class.java)
                    .block()!!
            },
            { throwable ->
                logger.warn { "Circuit breaker fallback: ${throwable.message}" }
                ExternalData.default(id)
            }
        )
    }
}

// Resilience4j アノテーションベースの使用
@Service
class AnnotatedResilientService(
    private val webClient: WebClient
) {
    @CircuitBreaker(name = "externalApi", fallbackMethod = "fallback")
    @Retry(name = "externalApi")
    @RateLimiter(name = "externalApi")
    @Bulkhead(name = "externalApi")
    suspend fun callExternalApi(request: ApiRequest): ApiResponse {
        return webClient.post()
            .uri("/api/external")
            .bodyValue(request)
            .retrieve()
            .awaitBody()
    }

    private suspend fun fallback(request: ApiRequest, ex: Exception): ApiResponse {
        KotlinLogging.logger {}.warn(ex) { "Fallback triggered for: $request" }
        return ApiResponse.defaultResponse()
    }
}

application.yml での Resilience4j 設定:

resilience4j:
  circuitbreaker:
    instances:
      externalApi:
        register-health-indicator: true
        sliding-window-size: 10
        minimum-number-of-calls: 5
        failure-rate-threshold: 50
        wait-duration-in-open-state: 30s
        permitted-number-of-calls-in-half-open-state: 3
        sliding-window-type: COUNT_BASED
        record-exceptions:
          - java.io.IOException
          - java.net.SocketTimeoutException
          - org.springframework.web.reactive.function.client.WebClientResponseException
  retry:
    instances:
      externalApi:
        max-attempts: 3
        wait-duration: 1s
        exponential-backoff-multiplier: 2
        retry-exceptions:
          - java.io.IOException
  ratelimiter:
    instances:
      externalApi:
        limit-for-period: 100
        limit-refresh-period: 1s
        timeout-duration: 5s
  bulkhead:
    instances:
      externalApi:
        max-concurrent-calls: 25
        max-wait-duration: 500ms

19.3 OpenFeign クライアント

@EnableFeignClients
@Configuration
class FeignConfig

@FeignClient(
    name = "user-service",
    url = "\${external.user-service.url}",
    configuration = [UserServiceFeignConfig::class],
    fallbackFactory = UserServiceFallbackFactory::class
)
interface UserServiceClient {

    @GetMapping("/api/v1/users/{id}")
    fun getUser(@PathVariable id: Long): UserResponse

    @PostMapping("/api/v1/users")
    fun createUser(@RequestBody request: CreateUserRequest): UserResponse

    @GetMapping("/api/v1/users")
    fun getUsers(
        @RequestParam page: Int = 0,
        @RequestParam size: Int = 20
    ): PageResponse<UserResponse>
}

class UserServiceFeignConfig {
    @Bean
    fun requestInterceptor(): RequestInterceptor = RequestInterceptor { template ->
        val token = SecurityContextHolder.getContext().authentication?.credentials?.toString()
        token?.let { template.header("Authorization", "Bearer $it") }
    }

    @Bean
    fun errorDecoder(): ErrorDecoder = ErrorDecoder { methodKey, response ->
        when (response.status()) {
            404 -> UserNotFoundException(0)
            409 -> DuplicateEmailException("unknown")
            else -> FeignException.errorStatus(methodKey, response)
        }
    }
}

@Component
class UserServiceFallbackFactory : FallbackFactory<UserServiceClient> {
    private val logger = KotlinLogging.logger {}

    override fun create(cause: Throwable): UserServiceClient = object : UserServiceClient {
        override fun getUser(id: Long): UserResponse {
            logger.warn(cause) { "Fallback: getUser($id)" }
            return UserResponse(id = id, name = "Unknown", email = "", role = UserRole.USER,
                status = UserStatus.ACTIVE, bio = null,
                createdAt = LocalDateTime.now(), updatedAt = LocalDateTime.now())
        }

        override fun createUser(request: CreateUserRequest): UserResponse {
            throw ServiceUnavailableException("User service is unavailable")
        }

        override fun getUsers(page: Int, size: Int): PageResponse<UserResponse> {
            return PageResponse(content = emptyList(), totalElements = 0, totalPages = 0)
        }
    }
}

19.4 API Gateway パターン

// Spring Cloud Gateway(リアクティブ)
@Configuration
class GatewayConfig {

    @Bean
    fun routeLocator(builder: RouteLocatorBuilder): RouteLocator =
        builder.routes {
            route("user-service") {
                path("/api/v1/users/**")
                filters {
                    stripPrefix(0)
                    addRequestHeader("X-Gateway", "spring-cloud-gateway")
                    circuitBreaker { config ->
                        config.name = "userServiceCB"
                        config.fallbackUri = "forward:/fallback/user-service"
                    }
                    retry { config ->
                        config.retries = 3
                        config.methods = setOf(HttpMethod.GET)
                        config.backoff(Duration.ofMillis(100), Duration.ofSeconds(1), 2, true)
                    }
                    requestRateLimiter { config ->
                        config.rateLimiter = redisRateLimiter()
                    }
                }
                uri("lb://user-service")
            }
            route("order-service") {
                path("/api/v1/orders/**")
                filters {
                    stripPrefix(0)
                    circuitBreaker { config ->
                        config.name = "orderServiceCB"
                        config.fallbackUri = "forward:/fallback/order-service"
                    }
                }
                uri("lb://order-service")
            }
        }

    @Bean
    fun redisRateLimiter(): RedisRateLimiter =
        RedisRateLimiter(10, 20, 1)
}

20. ベストプラクティスとアンチパターン

20.1 Kotlin 固有のベストプラクティス

データクラスの適切な使用

// GOOD: DTO にはデータクラスを使用
data class UserDto(
    val id: Long,
    val name: String,
    val email: String
)

// BAD: JPA エンティティにデータクラスを使用(等価性の問題)
// data class User(@Id val id: Long, ...)
// → equals/hashCode が全フィールドを比較するため、
//   JPA のプロキシオブジェクトと不整合が発生する

// GOOD: JPA エンティティは通常のクラスで、手動 equals/hashCode
@Entity
class User(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    var name: String
) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is User) return false
        return id != 0L && id == other.id
    }
    override fun hashCode(): Int = javaClass.hashCode()
}

Null 安全の活用

// GOOD: Kotlin の null 安全を活用
fun findUser(id: Long): User? = userRepository.findByIdOrNull(id)

fun processUser(id: Long): UserResponse {
    val user = findUser(id) ?: throw UserNotFoundException(id)
    return user.toResponse()
}

// GOOD: エルビス演算子チェーン
fun getUserDisplayName(userId: Long): String =
    findUser(userId)?.profile?.displayName ?: findUser(userId)?.name ?: "Anonymous"

// BAD: 不要な !! (NonNull アサーション)
fun badExample(id: Long): User = userRepository.findById(id).get()!! // NPE リスク

sealed class によるドメインモデリング

// GOOD: sealed class で状態を表現
sealed class PaymentResult {
    data class Success(val transactionId: String, val amount: BigDecimal) : PaymentResult()
    data class Failed(val reason: String, val errorCode: String) : PaymentResult()
    data class Pending(val estimatedCompletion: Instant) : PaymentResult()
}

fun handlePaymentResult(result: PaymentResult): ResponseEntity<*> = when (result) {
    is PaymentResult.Success -> ResponseEntity.ok(result)
    is PaymentResult.Failed -> ResponseEntity.badRequest().body(result)
    is PaymentResult.Pending -> ResponseEntity.accepted().body(result)
    // when は網羅的 → 新しい状態追加時にコンパイルエラーで検知
}

// sealed interface(Kotlin 1.5+)
sealed interface DomainError {
    data class ValidationError(val fields: Map<String, String>) : DomainError
    data class NotFoundError(val resourceType: String, val id: Any) : DomainError
    data class ConflictError(val message: String) : DomainError
    data object UnauthorizedError : DomainError
}

拡張関数によるユーティリティ

// GOOD: 拡張関数でドメインロジックを明確に
fun User.toResponse() = UserResponse(
    id = id, name = name, email = email,
    role = role, status = status, bio = bio,
    createdAt = createdAt, updatedAt = updatedAt
)

fun String.toSlug(): String =
    this.lowercase()
        .replace(Regex("[^a-z0-9\\s-]"), "")
        .replace(Regex("\\s+"), "-")
        .trim('-')

fun <T> Optional<T>.toNullable(): T? = orElse(null)

// Spring 固有の拡張
fun HttpServletRequest.clientIp(): String =
    getHeader("X-Forwarded-For")?.split(",")?.firstOrNull()?.trim()
        ?: getHeader("X-Real-IP")
        ?: remoteAddr

20.2 避けるべきアンチパターン

// ANTI-PATTERN 1: フィールドインジェクション
@Service
class BadService {
    @Autowired // BAD: フィールドインジェクション
    private lateinit var userRepository: UserRepository
}

// GOOD: コンストラクタインジェクション
@Service
class GoodService(
    private val userRepository: UserRepository // GOOD
)

// ANTI-PATTERN 2: open-in-view の有効化
// spring.jpa.open-in-view=true (デフォルト) は LazyInitializationException を隠蔽する
// GOOD: spring.jpa.open-in-view=false を設定し、必要なデータを明示的にフェッチ

// ANTI-PATTERN 3: 巨大な @Configuration クラス
// BAD: すべての Bean を一つの設定クラスに詰め込む
// GOOD: 機能ごとに分割する(SecurityConfig, WebConfig, DatabaseConfig, etc.)

// ANTI-PATTERN 4: 例外の握り潰し
// BAD:
fun badErrorHandling() {
    try {
        riskyOperation()
    } catch (e: Exception) {
        // 何もしない
    }
}

// GOOD:
fun goodErrorHandling() {
    try {
        riskyOperation()
    } catch (e: SpecificException) {
        logger.error(e) { "Operation failed: ${e.message}" }
        throw BusinessException("Operation failed", e)
    }
}

20.3 パフォーマンスチェックリスト

項目推奨設定
JPA open-in-viewfalse
Hibernate batch size50-100
Connection pool sizeCPU コア数 * 2 + ディスク数
Lazy initialization必要に応じて有効化
Cache頻繁に読まれるデータに適用
Indexクエリパターンに基づく最適なインデックス
N+1 問題JOIN FETCH / EntityGraph で解決
JSON シリアライゼーション不要なフィールドを除外
圧縮gzip を有効化(1KB 以上)
Virtual ThreadsI/O バウンドワークロードに検討

21. まとめ

21.1 Spring Boot + Kotlin の主要な利点

  1. 簡潔性: Kotlin の data class、拡張関数、スコープ関数により、Java に比べてコード量を 30-40% 削減できる
  2. 安全性: Null 安全型システムにより NullPointerException を大幅に削減
  3. コルーチン: リアクティブプログラミングを直感的な同期スタイルで記述可能
  4. DSL: 型安全なビルダーパターンにより、設定やテストコードが読みやすくなる
  5. 相互運用性: 既存の Java ライブラリ・フレームワークとの 100% 互換性
  6. 公式サポート: Spring Framework による Kotlin ファーストクラスサポート

21.2 アーキテクチャ選定の指針

要件推奨アーキテクチャ
標準的な Web APISpring MVC + JPA
高並行性が必要WebFlux + R2DBC + Coroutines
マイクロサービスSpring Cloud + Spring Boot
サーバーレス/FaaSGraalVM Native Image
イベント駆動Spring Cloud Stream + Kafka
バッチ処理Spring Batch

21.3 今後の展望

  • Kotlin 2.x: K2 コンパイラによるコンパイル速度の向上、新しい言語機能
  • Project Loom (Virtual Threads): Java 21 の Virtual Threads と Kotlin コルーチンの共存
  • Spring Boot 4.0: Jakarta EE の進化に伴う機能拡張
  • GraalVM の成熟: ネイティブイメージの安定性とエコシステムの拡大
  • AI/ML 統合: Spring AI フレームワークとの統合

Spring Boot と Kotlin の組み合わせは、現代の JVM ベースのエンタープライズアプリケーション開発において、生産性、安全性、パフォーマンスのバランスが取れた最良の選択肢の一つである。本書で解説した設計パターンとベストプラクティスを活用し、堅牢で保守性の高いアプリケーションの構築に役立てていただきたい。


本書は Spring Boot 3.3.x / Kotlin 2.0.x / Java 21 をベースに記述されている。フレームワークの更新に伴い、一部の API や設定方法が変更される可能性がある。