SpringBoot with Kotlin
Spring Boot with Kotlin 完全ガイド
目次
- はじめに
- Kotlin と Spring Boot の親和性
- プロジェクトのセットアップと構成
- Gradle (Kotlin DSL) によるビルド設定
- アプリケーション設定と構成管理
- Spring Boot の自動構成(Auto-Configuration)
- 依存性注入(Dependency Injection)と Bean 管理
- Web レイヤー: REST API の構築
- データアクセスレイヤー: Spring Data JPA と Kotlin
- データアクセスレイヤー: Spring Data R2DBC(リアクティブ)
- セキュリティ: Spring Security の統合
- テスト戦略と実装
- コルーチンと WebFlux によるリアクティブプログラミング
- メッセージングとイベント駆動アーキテクチャ
- キャッシュとパフォーマンス最適化
- 監視・可観測性(Observability)
- Docker とクラウドネイティブデプロイ
- GraalVM ネイティブイメージ対応
- マイクロサービスアーキテクチャの実践
- ベストプラクティスとアンチパターン
- まとめ
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.0 | 2017 | Kotlin 拡張関数、Null 安全対応 |
| Spring Boot 2.0 | 2018 | Kotlin DSL for Bean Definition |
| Spring Boot 2.2 | 2019 | コルーチンサポート強化 |
| Spring Boot 2.4 | 2020 | Kotlin 1.4 対応 |
| Spring Boot 3.0 | 2022 | Jakarta EE 移行、GraalVM 対応 |
| Spring Boot 3.2 | 2023 | Virtual Threads、CRaC 対応 |
| Spring Boot 3.3 | 2024 | Kotlin 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=strict | JSR-305 アノテーションを厳格に解釈(推奨) |
-Xjvm-default=all | Kotlin インターフェースの default メソッドを JVM default メソッドとして生成 |
-Xcontext-receivers | Context 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
自動構成の動作原理:
spring-boot-autoconfigureJAR 内のMETA-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.importsを読み込む- 各自動構成クラスの条件アノテーション(
@Conditional*)を評価する - 条件を満たす設定クラスの 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 | 指定リソースが存在する場合 |
@ConditionalOnWebApplication | Web アプリケーションの場合 |
@ConditionalOnExpression | SpEL 式が 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-view | false |
| Hibernate batch size | 50-100 |
| Connection pool size | CPU コア数 * 2 + ディスク数 |
| Lazy initialization | 必要に応じて有効化 |
| Cache | 頻繁に読まれるデータに適用 |
| Index | クエリパターンに基づく最適なインデックス |
| N+1 問題 | JOIN FETCH / EntityGraph で解決 |
| JSON シリアライゼーション | 不要なフィールドを除外 |
| 圧縮 | gzip を有効化(1KB 以上) |
| Virtual Threads | I/O バウンドワークロードに検討 |
21. まとめ
21.1 Spring Boot + Kotlin の主要な利点
- 簡潔性: Kotlin の data class、拡張関数、スコープ関数により、Java に比べてコード量を 30-40% 削減できる
- 安全性: Null 安全型システムにより NullPointerException を大幅に削減
- コルーチン: リアクティブプログラミングを直感的な同期スタイルで記述可能
- DSL: 型安全なビルダーパターンにより、設定やテストコードが読みやすくなる
- 相互運用性: 既存の Java ライブラリ・フレームワークとの 100% 互換性
- 公式サポート: Spring Framework による Kotlin ファーストクラスサポート
21.2 アーキテクチャ選定の指針
| 要件 | 推奨アーキテクチャ |
|---|---|
| 標準的な Web API | Spring MVC + JPA |
| 高並行性が必要 | WebFlux + R2DBC + Coroutines |
| マイクロサービス | Spring Cloud + Spring Boot |
| サーバーレス/FaaS | GraalVM 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 や設定方法が変更される可能性がある。