Spring with Kotlin
Spring Framework 徹底解説(Kotlin)— final との戦いと、その先の利点
目次
- KotlinでSpringを使うとは何か
- 検証環境
- 最大の障壁 — Kotlinのクラスは
finalである kotlin-spring(all-open)プラグインfinalメソッドは黙って織り込まれない- コンストラクタ注入が自然になる
- null安全とSpringの境界
data classの落とし穴- 既定引数とコンストラクタの多重化
lateinitと遅延初期化@ConfigurationPropertiesと不変性@TransactionalのKotlin特有問題- コルーチンとトランザクション
- 拡張関数・トップレベル関数とBean
- テスト
- 実運用での姿 — SMPを実測する
- 落とし穴
- まとめ
1. KotlinでSpringを使うとは何か
1.1 3行で言うと
Kotlin と Spring は相性が良い。ただし1点だけ、言語の既定がフレームワークの前提と衝突する。
相性が良い理由 ・コンストラクタ注入が構文的に自然(プライマリコンストラクタ)
・null安全が「設定値が無い」を型で表現できる
・data class が DTO を1行にする
・拡張関数がユーティリティクラスを不要にする
衝突する1点 ★ Kotlin のクラスとメソッドは既定で final
→ Spring は CGLIB でサブクラス化してプロキシを作る
→ @Configuration が起動に失敗し、@Transactional が黙って無効になる
この1点を解決したあとは、Java より書きやすい。 本記事はまずこの衝突を正確に理解し、そのうえで Kotlin 固有の利点と落とし穴を扱う。
1.2 姉妹記事との分担
| 記事 | 扱う範囲 |
|---|---|
Spring with Java-overview-ai_JP.md | コンテナ本体、Bean定義、DI、ライフサイクル、AOP、プロキシ、トランザクションの実装 |
| 本記事 | 上記をKotlinで使うときに変わること |
SpringBoot with Kotlin-overview-ai_JP.md | Boot の自動構成、WebFlux、R2DBC、Security、Actuator |
コンテナの仕組み自体(起動シーケンス、BeanPostProcessor、循環依存、伝播)は Java 版で詳述している。 本記事は重複を避け、Kotlin で挙動が変わる箇所に集中する。
Java版 第11章 プロキシは2種類ある(JDK動的プロキシ / CGLIB)
本記事 第3章 Kotlin では CGLIB が使えないことがある。だから all-open が要る
Java版 第6章 コンストラクタ注入を推奨する(実運用では2割)
本記事 第6章 Kotlin では構文上コンストラクタ注入が既定になる
Java版 第13章 検査例外でコミットされる
本記事 第12章 Kotlin に検査例外は無い。だからこの問題は起きない(別の問題が起きる)
1.3 Kotlin を選ぶ理由と、その代償
利点。
① コンストラクタ注入がボイラープレートなしで書ける
class OrderService(private val repo: Repo) ← これだけ
② null安全が設定値の欠落を型で表す
@Value("\${x}") val x: String ← 無ければ起動時に落ちる
@Value("\${x:}") val x: String? ← 任意であることが型に出る
③ data class が DTO を1行にする
④ 拡張関数がユーティリティクラスを不要にする
⑤ コルーチンが WebFlux をコールバックなしで書ける
代償。
① all-open プラグインが必須(第4章)
② data class と JPA が噛み合わない(第8章)
③ 既定引数がコンストラクタを増やし、Spring の解決を曖昧にする(第9章)
④ プラットフォーム型(Java から来る値)で null安全が崩れる(第7章)
⑤ コルーチンとスレッドローカル(トランザクション)の相性問題(第13章)
①は解決済みの問題である。 ②〜⑤は設計で回避する必要がある。
2. 検証環境
2.1 環境
$ uname -m
arm64
$ /usr/libexec/java_home -V
21.0.8.9.1 (x86_64, arm64) "Apple Inc." - "AppleJDK 21"
macOS 26 / Apple Silicon(M4 Max)/ AppleJDK 21.0.8 である。
2.2 公開ネットワークは遮断されている
$ curl -s -o /dev/null -w "%{http_code}\n" --max-time 8 https://repo1.maven.org/maven2/
000
$ curl -s -o /dev/null -w "%{http_code}\n" --max-time 15 https://artifacts.apple.com/libs-release/
200
社内 Artifactory のみ到達可能である。 Spring の JAR はここから取得する(Java版 第2.3節)。
2.3 Kotlinコンパイラを用意する
kotlinc は入っていないが、Gradle が kotlin-compiler-embeddable を同梱している。
$ D=$(ls -d ~/.gradle/wrapper/dists/gradle-8.14.1-bin/*/gradle-8.14.1)
$ ls "$D/lib" | grep -E '^kotlin' | head -4
kotlin-compiler-embeddable-2.0.21.jar
kotlin-daemon-embeddable-2.0.21.jar
kotlin-reflect-2.0.21.jar
kotlin-stdlib-2.0.21.jar
コンパイラを起動するには依存を揃える必要がある。
$ KCE="$D/lib/kotlin-compiler-embeddable-2.0.21.jar"
$ STD="$D/lib/kotlin-stdlib-2.0.21.jar"
$ EXTRA=$(ls "$D"/lib/{kotlin-*,kotlinx-*,trove4j*,annotations-24*,guava*}.jar | tr '\n' ':')
$ kc() { java -cp "$KCE:$EXTRA" org.jetbrains.kotlin.cli.jvm.K2JVMCompiler "$@" -no-stdlib -no-reflect; }
$ printf 'fun main() { println("Kotlin ${KotlinVersion.CURRENT}") }\n' > h.kt
$ kc h.kt -d kout -cp "$STD" 2>&1 | grep -v '^warning'
$ java -cp "$STD:kout" HKt
Kotlin 2.0.21
筆者が踏んだ罠。 コンパイラ JAR だけを classpath に入れると、クラス定義を含むファイルが
BackendException: Exception during IR loweringで失敗する。exception: org.jetbrains.kotlin.backend.common.BackendException: Backend Internal error The root cause java.lang.RuntimeException was thrown at: FunctionCodegen.generateトップレベル関数だけなら通るため、原因が分かりにくい。
kotlinx-coroutines-coreとtrove4jを含む依存一式が必要である。
2.4 Gradle を使う場合
実務ではこちらが正しい。
// settings.gradle.kts
pluginManagement {
repositories { maven { url = uri("https://artifacts.apple.com/libs-release") } }
}
dependencyResolutionManagement {
repositories { maven { url = uri("https://artifacts.apple.com/libs-release") } }
}
// build.gradle.kts
plugins {
kotlin("jvm") version "2.0.21"
kotlin("plugin.spring") version "2.0.21" // ★ all-open(第4章)
}
dependencies {
implementation("org.springframework:spring-context:6.1.14")
implementation("org.springframework:spring-tx:6.1.14")
implementation(kotlin("reflect")) // ★ Spring が要求する(第9章)
}
kotlin { jvmToolchain(21) }
$ gradle -q run
Spring 6.1.14 / Kotlin 2.0.21 / JVM 21.0.8
kotlin("reflect") を忘れると、既定引数のあるコンストラクタで Bean を作れない(第9.3節)。
3. 最大の障壁 — Kotlinのクラスは final である
3.1 実測する
package demo
import org.springframework.transaction.annotation.Transactional
import org.springframework.stereotype.Service
@Service class Closed { @Transactional fun tx() = "tx" } // ① 既定
@Service open class Opened { @Transactional open fun tx() = "tx" } // ② 両方 open
@Service open class HalfOpen { @Transactional fun tx() = "tx" } // ③ クラスだけ open
$ java -cp "$STD:$SPRING:kout:out" demo.KtSpringProbe
== Kotlin の final とプロキシ可能性 ==
Closed class final=true | tx final=true
Opened class final=false | tx final=false
HalfOpen class final=false | tx final=true
Kotlin は class も method も既定で final である。
Java クラスもメソッドも既定で継承可能。final は明示する
Kotlin クラスもメソッドも既定で final。継承可能にするには open を明示する
3.2 なぜ Spring と衝突するのか
Spring AOP は CGLIB でサブクラスを作る(Java版 第11.2節)。
CGLIB は対象クラスを継承したサブクラスを生成する
→ final クラスは継承できない → プロキシを作れない
→ final メソッドはオーバーライドできない → アドバイスを織り込めない
CGLIB でのプロキシ生成を実測した。
== CGLIB でプロキシを作る ==
KtService(final class) 失敗: AopConfigException
KtOpenService(open) 成功: KtOpenService$$SpringCGLIB$$0
IfcImpl(interface あり) 失敗: AopConfigException
final クラスは AopConfigException になる。
3行目に注意する。
IfcImplはインタフェースを実装しているが、クラス自体がfinalなのでsetProxyTargetClass(true)(CGLIB強制)では失敗する。 インタフェース経由の JDK 動的プロキシなら成功するが、Spring Boot 2.0 以降の既定は CGLIB であるため、既定設定では失敗する。
3.3 何が壊れるのか
① @Configuration が起動に失敗する。
@Configuration
class AppConfig { // ❌ final なので CGLIB がサブクラス化できない
@Bean fun svc() = Svc()
}
Caused by: org.springframework.beans.factory.parsing.BeanDefinitionParsingException:
Configuration problem: @Configuration class 'AppConfig' may not be final.
Remove the final modifier to continue.
これはエラーになるので気付ける。 まだ良い方である。
② @Transactional が黙って無効になる。
@Service
class OrderService(private val repo: Repo) {
@Transactional fun place(o: Order) { repo.save(o) } // ❌ トランザクションが張られない
}
エラーも警告も出ない。 起動し、動く。しかしトランザクションが無い。
これが Kotlin + Spring で最も危険な状態である。
③ @Cacheable / @Async / @PreAuthorize も同様に無効になる。 すべてプロキシで実装されているため、同じ運命をたどる。
3.4 手動で open を付ける選択肢
@Configuration
open class AppConfig {
@Bean open fun svc() = Svc() // メソッドも open が必要
}
@Service
open class OrderService(private val repo: Repo) {
@Transactional open fun place(o: Order) { }
}
動くが、現実的ではない。
・Spring の対象になるクラスすべてに open を書く必要がある
・メソッド単位でも書く必要がある(第5章で実測する)
・付け忘れが黙って動く(②の状態になる)
・「なぜ open なのか」がコードから読み取れない
そのため kotlin-spring プラグインを使う。
4. kotlin-spring(all-open)プラグイン
4.1 何をするのか
指定したアノテーションが付いたクラスとそのメンバを、コンパイル時に open にする。
// build.gradle.kts
plugins {
kotlin("plugin.spring") version "2.0.21"
}
これは all-open プラグインの Spring 向けプリセットである。
// kotlin("plugin.spring") が内部で行っていること
allOpen {
annotation("org.springframework.stereotype.Component")
annotation("org.springframework.transaction.annotation.Transactional")
annotation("org.springframework.scheduling.annotation.Async")
annotation("org.springframework.cache.annotation.Cacheable")
annotation("org.springframework.boot.test.context.SpringBootTest")
}
@Component のメタアノテーションが効く。 @Service / @Repository / @Controller / @Configuration はすべて @Component を持つため、まとめて対象になる。
4.2 適用前後の差
@Service class OrderService { @Transactional fun place() { } }
| プラグインなし | プラグインあり | |
|---|---|---|
OrderService の final | true | false |
place() の final | true | false |
| CGLIBプロキシ | 作れない | 作れる |
@Transactional | 無効(黙って) | 有効 |
ソースコードは1文字も変わらない。 バイトコードだけが変わる。
4.3 独自アノテーションを追加する
// build.gradle.kts
allOpen {
annotation("com.example.MyAspectTarget") // 自作アスペクトの対象
}
自作アノテーションでAOPを織り込む場合、これを忘れると黙って無効になる。
4.4 Gradle 以外での適用
<!-- Maven -->
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<configuration>
<compilerPlugins>
<plugin>spring</plugin> <!-- all-open の Spring プリセット -->
<plugin>jpa</plugin> <!-- no-arg(第8章) -->
</compilerPlugins>
</configuration>
</plugin>
4.5 適用されているか確認する
これが実務上重要である。 プラグインは convention plugin 経由で適用されることが多く、アプリのビルドファイルからは見えない。
# ① コンパイル済みクラスの修飾子を確認する(最も確実)
$ javap -p build/classes/kotlin/main/com/example/OrderService.class | head -3
public class com.example.OrderService { # ← final が無ければ適用されている
public com.example.OrderService(com.example.Repo);
public void place(); # ← final が無ければ適用されている
# ② 実行時に確認する
$ java -e 'println(Modifier.isFinal(OrderService::class.java.modifiers))'
# ③ ビルドファイルを探す(convention plugin だと見つからないことがある)
$ grep -rn 'plugin.spring\|kotlin-spring\|allOpen' --include='*.gradle' --include='*.kts' .
①が唯一確実な方法である。
第16章で実測するが、あるプラットフォームでは Kotlin + Spring が 519 ファイルある��に、アプリのビルドファイルに
kotlin-springの宣言が0件だった。 実際には社内の convention plugin(wpc.platform.conventions.kotlin.*)が適用していたが、アプリのコードを読むだけでは判断できない。
4.6 起動時に検証する
規約として、@Component 系のクラスが open になっていることを起動時に確認できる。
@Component
class ProxyabilityCheck(private val ctx: ApplicationContext) {
@EventListener(ContextRefreshedEvent::class)
fun verify() {
val bad = ctx.getBeanDefinitionNames()
.mapNotNull { runCatching { ctx.getType(it) }.getOrNull() }
.filter { it.isAnnotationPresent(Service::class.java) && Modifier.isFinal(it.modifiers) }
require(bad.isEmpty()) { "final な @Service が存在する: ${bad.map { it.name }}" }
}
}
CI の起動テストに含めれば、all-open の適用漏れを検出できる。
5. final メソッドは黙って織り込まれない
5.1 最も危険なパターンを実測する
クラスが open でもメソッドが final なら、プロキシは作られるがアドバイスは効かない。
open class KtOpenService {
open fun work() = "work" // open
fun finalMethod() = "final" // final(既定)
}
== open と final のメソッドの差 ==
open work() 傍受 1回
final finalMethod() 傍受 0回 ← ★織り込めない
finalMethod() は一度も傍受されない。 プロキシは作られている(KtOpenService$$SpringCGLIB$$0)が、final メソッドはオーバーライドできないため素通りする。
エラーも警告も出ない。
5.2 なぜ危険なのか
@Service
open class PaymentService(private val repo: Repo) {
@Transactional
fun charge(id: Long, amt: BigDecimal) { // ★ open を忘れた
repo.debit(id, amt)
repo.credit(id, amt)
}
}
クラスに open があるので起動は成功する。 @Configuration のような明示的エラーは出ない。しかしトランザクションが無い。
・repo.debit が成功し、repo.credit が失敗しても、debit は確定する
・ロールバックされない
・テストで検出できない(テストでも同じく効かないため、一貫して「動く」)
「クラスだけ open にした」が最悪の状態である。 all-open プラグインはクラスとメンバの両方を open にするため、この問題は起きない。手動で open を書く運用が危険である。
5.3 検出する
# @Transactional などが付いたメソッドが final かどうかを、コンパイル済みクラスで確認する
$ for c in $(find build/classes/kotlin/main -name '*.class'); do
javap -p "$c" | grep -E 'final .*\(' | sed "s|^|${c#build/classes/kotlin/main/}: |"
done | head
# ソース側での検出(open が付いていない @Transactional)
$ grep -rn -A1 '@Transactional' --include='*.kt' . | grep -E 'fun ' | grep -v 'open fun'
2つ目は all-open プラグインを使っていれば偽陽性になる。 確実なのは1つ目(バイトコード)である。
5.4 テストで境界を検証する
@Test
fun `トランザクション境界が張られること`() {
val proxy = ctx.getBean(PaymentService::class.java)
assertThat(AopUtils.isAopProxy(proxy)).isTrue()
// メソッドがアドバイスの対象になっているか
val advised = proxy as Advised
val m = PaymentService::class.java.getMethod("charge", Long::class.java, BigDecimal::class.java)
assertThat(advised.getAdvisors()).isNotEmpty()
assertThat(Modifier.isFinal(m.modifiers)).isFalse() // ★ final なら落とす
}
Modifier.isFinal のアサーションが実質的な all-open の検証になる。
6. コンストラクタ注入が自然になる
6.1 Kotlin では構文が味方する
// Java — フィールド注入が短い
@Service
class OrderService {
@Autowired private Repo repo;
@Autowired private Cache cache;
}
// Java — コンストラクタ注入は長い
@Service
class OrderService {
private final Repo repo;
private final Cache cache;
OrderService(Repo repo, Cache cache) { this.repo = repo; this.cache = cache; }
}
// Kotlin — コンストラクタ注入が最短
@Service
class OrderService(
private val repo: Repo,
private val cache: Cache,
)
Kotlin ではコンストラクタ注入が最も短い。 Java版 第6.2節で「記述量の少なさがフィールド注入を選ばせる」と書いたが、Kotlin ではその動機が消える。
@Autowired も不要である(コンストラクタが1つなら Spring 4.3 以降は省略できる)。
6.2 依存が多いことが目に見える
@Service
class GodService(
private val a: A, private val b: B, private val c: C,
private val d: D, private val e: E, private val f: F,
private val g: G, private val h: H, private val i: I,
) // ← 9個の依存が一目で分かる
コンストラクタ引数が縦に並ぶため、設計の異常が見える。 フィールド注入では気付きにくい(Java版 第6.3節③)。
6.3 それでもフィールド注入が必要な場面
// ① 循環依存の応急処置
@Service
class A {
@Autowired @Lazy lateinit var b: B
}
// ② テストクラス(Spring が管理する)
@SpringBootTest
class MyTest {
@Autowired lateinit var svc: OrderService // ← テストでは lateinit が慣用
}
テストクラスは Spring がインスタンス化するため、コンストラクタ注入も可能である(JUnit 5 + SpringExtension)。
@SpringBootTest
class MyTest(@Autowired val svc: OrderService) // ← これも動く
6.4 複数コンストラクタがある場合
@Service
class Svc {
constructor(a: A) { }
constructor(a: A, b: B) { } // ❌ どちらを使うか決まらない
}
@Service
class Svc {
@Autowired constructor(a: A, b: B) { } // ✅ 明示する
constructor(a: A) : this(a, DefaultB())
}
既定引数を使うと、この問題が暗黙に発生する(第9章)。
7. null安全とSpringの境界
7.1 null非許容の実体は実行時チェックである
class NotNull(val s: String)
== null 非許容の実体(合成 null チェック) ==
NullPointerException: Parameter specified as non-null is null: method demo.NotNull.<init>, parameter s
Kotlin は public な関数の引数に対して、実行時の null チェックを合成する。
コンパイル時 Kotlin 同士なら型で防ぐ
実行時 Java やリフレクション(=Spring)から来る null は、
合成された Intrinsics.checkNotNullParameter が投げる
つまり Spring が null を注入しようとすると、NullPointerException になる。 これは望ましい挙動である — 設定漏れが起動時に露出する。
7.2 @Value と null安全
@Component
class Cfg(
@Value("\${app.name}") val name: String, // 必須。無ければ起動失敗
@Value("\${app.desc:}") val desc: String, // 既定は空文字列
@Value("\${app.opt:#{null}}") val opt: String?, // 任意。null を許す
)
3つの書き分けが型に現れる。 Java では String に null が入りうるため、この区別が型で表現できない。
\${...}のエスケープに注意する。 Kotlin の文字列テンプレートが${...}を先に解釈してしまうため、$をエスケープする必要がある。@Value("${app.name}") // ❌ Kotlin が app.name を式として解釈しようとする @Value("\${app.name}") // ✅ @Value($$"$app.name") // Kotlin 2.0 以降の複数ドル記号(使用は稀)これは Kotlin + Spring で最初に踏む構文の罠である。
7.3 プラットフォーム型が抜け穴になる
// Java のメソッド
public String findName(Long id) { return null; } // アノテーションなし
val name: String = javaRepo.findName(1L) // ❌ 実行時に NullPointerException
val name: String? = javaRepo.findName(1L) // ✅ null を想定する
アノテーションの無い Java の戻り値は「プラットフォーム型」となり、Kotlin はnullチェックを省略する。 Spring のAPIは @Nullable を付けているものが多いが、サードパーティやレガシーコードでは付いていない。
Spring の主要APIは注釈されている。
val bean: Any? = ctx.getBean("maybe") // Spring は @Nullable を付けている
val v: String? = env.getProperty("x") // → String? になる
val v: String = env.getRequiredProperty("x") // → String になる
getProperty と getRequiredProperty の差が型に出る。 これは Kotlin の利点である。
7.4 @Nullable の相互運用
// Kotlin 側で Java に見せる null 許容性
fun find(id: Long): Order? = repo.findById(id) // Java からは @Nullable として見える
Kotlin の ? は @Nullable アノテーションとしてバイトコードに出力される。 Java 側から使うときに IDE が警告を出せる。
8. data class の落とし穴
8.1 引数なしコンストラクタが無い
data class Dto(val a: Int, val b: String = "def")
class Single(val a: Int, val b: String)
== data class / 既定引数のコンストラクタ ==
Dto: 2引数 4引数 [引数なしなし]
Single: 2引数 [引数なしなし]
どちらも引数なしコンストラクタを持たない。
これが問題になる場面。
| 用途 | 引数なしコンストラクタを要求するか |
|---|---|
JPA @Entity | 要求する(仕様) |
| Jackson(JSON) | jackson-module-kotlin があれば不要 |
Spring の @Bean / DI | 不要(引数で解決する) |
@ConfigurationProperties | Boot 2.2 以降は不要(コンストラクタバインディング) |
JPA だけが構造的に噛み合わない。
8.2 JPA と data class は使わない
// ❌ 動かない(引数なしコンストラクタが無い)
@Entity data class Order(@Id val id: Long, val amount: BigDecimal)
kotlin("plugin.jpa")(no-arg プラグイン)が合成コンストラクタを生成する。
plugins { kotlin("plugin.jpa") version "2.0.21" }
しかし data class は JPA と本質的に相性が悪い。
① equals / hashCode が全プロパティを使う
→ 遅延ロードのプロキシで LazyInitializationException が起きる
→ ID だけで比較すべき(JPA の同一性は ID)
② copy() が意図せずデタッチされたエンティティを作る
③ val(不変)なのに JPA は setter でフィールドを書き換える
→ リフレクションで書き換わるので、不変性が嘘になる
④ toString() が全プロパティを出す
→ 遅延ロードを誘発し、N+1 やログ肥大を招く
推奨する形。
@Entity
class Order(
@Id @GeneratedValue var id: Long? = null,
var amount: BigDecimal = BigDecimal.ZERO,
) {
override fun equals(other: Any?): Boolean =
this === other || (other is Order && id != null && id == other.id)
override fun hashCode(): Int = id?.hashCode() ?: 0
override fun toString(): String = "Order(id=$id)" // 遅延ロードを誘発しない
}
data class は DTO / 値オブジェクトに使い、エンティティには使わない。
8.3 DTO には積極的に使う
// レスポンス
data class OrderResponse(val id: Long, val amount: BigDecimal, val status: String)
// リクエスト
data class CreateOrderRequest(
@field:NotBlank val customerId: String, // ★ @field: が必要
@field:Positive val amount: BigDecimal,
)
@field:の指定が必要である。 Kotlin のコンストラクタプロパティは「パラメータ」「フィールド」「getter」の3つの適用対象を持つため、アノテーションがどこに付くかを明示しないと Bean Validation が効かない。@NotBlank val x: String // ❌ パラメータに付く(Validation は見ない) @field:NotBlank val x: String // ✅ フィールドに付く @get:NotBlank val x: String // getter に付く(これでも動く)これは黙って無効になるため、Kotlin + Spring で最も見落とされる問題の1つである。
8.4 Jackson との組み合わせ
dependencies { implementation("com.fasterxml.jackson.module:jackson-module-kotlin") }
このモジュールが無いと、data class のデシリアライズに失敗する。
com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
Cannot construct instance of `Dto` (no Creators, like default constructor, exist)
Spring Boot は自動で登録するが、素の Spring では明示的に登録する。
@Bean fun objectMapper(): ObjectMapper = ObjectMapper().registerKotlinModule()
jackson-module-kotlin は既定引数も扱える。 JSON に無いフィールドは既定値が使われる。
9. 既定引数とコンストラクタの多重化
9.1 既定引数はコンストラクタを増やす
Dto: 2引数 4引数 [引数なしなし] ← data class Dto(val a: Int, val b: String = "def")
Single: 2引数 [引数なしなし] ← class Single(val a: Int, val b: String)
既定引数のある Dto は、コンストラクタが2つある。
2引数版 Dto(a, b) 通常のコンストラクタ
4引数版 Dto(a, b, int mask, DefaultConstructorMarker) ← 合成された既定引数用
4引数版は合成されたもので、int のビットマスクで「どの引数が省略されたか」を伝える。
9.2 Spring の解決が曖昧になる
@Service
class Svc(
private val repo: Repo,
private val timeout: Duration = Duration.ofSeconds(30), // 既定引数
)
コンストラクタが2つあるため、Spring はどちらを使うか決められない可能性がある。
実際には Spring は kotlin-reflect を使ってプライマリコンストラクタを識別する。 そのため動く。しかし kotlin-reflect が classpath に無いと失敗する。
Caused by: java.lang.IllegalStateException:
No primary or single unique constructor found for class Svc
kotlin("reflect") は必須の依存である。
dependencies { implementation(kotlin("reflect")) }
Spring Boot の Kotlin スターターは自動で含めるが、素の Spring では明示する。
9.3 既定引数と DI の相互作用
@Service
class Svc(
private val repo: Repo,
private val cache: Cache = NoOpCache(), // ← Spring は Cache Bean を注入するか?
)
Spring は Bean があればそれを注入し、無ければ…既定値を使わない。 解決に失敗する。
NoSuchBeanDefinitionException: No qualifying bean of type 'Cache' available
「Bean が無ければ既定値」を期待すると裏切られる。 正しい書き方は次である。
@Service
class Svc(
private val repo: Repo,
cacheProvider: ObjectProvider<Cache>,
) {
private val cache = cacheProvider.getIfAvailable { NoOpCache() }
}
または @Autowired(required = false) 相当を明示する。
@Service
class Svc(private val repo: Repo, private val cache: Cache?) {
private val effectiveCache = cache ?: NoOpCache()
}
null許容型にすると「Bean が無ければ null」になる。 これが Kotlin での自然な書き方である。
9.4 実務での指針
① DI を受けるコンストラクタに既定引数を使わない
→ 意図が伝わらず、解決の失敗が分かりにくい
② 任意の依存は null許容型(Type?)で受ける
→ 「Bean が無ければ null」が型に出る
③ DTO / 値オブジェクトの既定引数は積極的に使う
→ Spring の DI が絡まないので問題ない
④ kotlin-reflect を必ず依存に入れる
10. lateinit と遅延初期化
10.1 lateinit の実体
@Service
class LateInit { @Autowired lateinit var dep: Closed }
== lateinit var のフィールド ==
修飾子 : public
型 : Closed
未初期化で読む: UninitializedPropertyAccessException
フィールドは public になり、未初期化アクセスは専用の例外を投げる。
Java のフィールド注入 private + null
Kotlin の lateinit public + UninitializedPropertyAccessException
lateinit は「null を許さないが、後で入る」を表現する。 null許容型(var dep: Closed?)にしなくて済むため、使う側で ?. が不要になる。
制約が4つある。
・var のみ(val には使えない)
・null許容型には使えない
・プリミティブ型には使えない(Int / Long など)
・カスタムゲッター/セッターを持てない
10.2 使うべき場面と避けるべき場面
// ✅ テストクラス(Spring がインスタンス化するのでコンストラクタが使えない場面)
@SpringBootTest
class MyTest {
@Autowired lateinit var svc: OrderService
}
// ✅ 循環依存の応急処置
@Service
class A { @Autowired @Lazy lateinit var b: B }
// ❌ 通常のサービス(コンストラクタ注入で書ける)
@Service
class OrderService {
@Autowired lateinit var repo: Repo // ← コンストラクタ注入にすべき
}
フィールドが public になる点も懸念である。 外部から差し替えられてしまう。
10.3 by lazy との違い
class Svc(private val repo: Repo) {
private val expensive: Heavy by lazy { Heavy(repo) } // 初回アクセスで生成
}
lateinit var | by lazy | |
|---|---|---|
| 誰が値を入れるか | 外部(Spring など) | 自分(初期化式) |
val に使えるか | ✗ | ○ |
| スレッド安全 | ✗ | ○(既定は SYNCHRONIZED) |
| null許容型 | ✗ | ○ |
by lazy は「重い計算を遅らせる」ためのもので、DI とは無関係である。 混同しないこと。
Spring の @Lazy とも別物である。
Spring の @Lazy Bean の生成をプロキシで遅延させる(コンテナの機能)
Kotlin の by lazy プロパティの初期化を遅延させる(言語の機能)
11. @ConfigurationProperties と不変性
11.1 コンストラクタバインディングが自然に書ける
@ConfigurationProperties(prefix = "app")
data class AppProps(
val name: String,
val timeout: Duration = Duration.ofSeconds(30),
val retries: Int = 3,
val endpoints: List<String> = emptyList(),
)
app:
name: order-service
timeout: 45s
endpoints:
- https://a.example.com
- https://b.example.com
Boot 2.2 以降はコンストラクタバインディングが使えるため、val で不変にできる。 Java でも可能だが、Kotlin の方が圧倒的に短い。
登録方法。
@ConfigurationPropertiesScan // Boot 2.2+
@SpringBootApplication
class App
// または明示的に
@EnableConfigurationProperties(AppProps::class)
Boot 3.0 以降は @ConstructorBinding が不要になった(コンストラクタが1つなら自動)。
11.2 @Value との使い分け
@Value 単発の値。式(SpEL)が使える
@ConfigurationProperties 構造化された設定。型安全、検証可能、IDE補完が効く
設定項目が3つ以上になったら @ConfigurationProperties に移すべきである。
// ❌ @Value が散らばる
@Service
class Svc(
@Value("\${app.name}") private val name: String,
@Value("\${app.timeout}") private val timeout: Duration,
@Value("\${app.retries}") private val retries: Int,
)
// ✅ まとめる
@Service
class Svc(private val props: AppProps)
11.3 検証
@ConfigurationProperties(prefix = "app")
@Validated
data class AppProps(
@field:NotBlank val name: String,
@field:Min(1) @field:Max(10) val retries: Int = 3,
)
@field: が必要である(第8.3節)。付け忘れると検証が黙って効かない。
起動時に検証されるため、設定ミスがデプロイ��に露出する。
Binding to target [Bindable@... type = AppProps] failed:
Property: app.retries
Value: "99"
Reason: must be less than or equal to 10
11.4 ネストした設定
@ConfigurationProperties(prefix = "app")
data class AppProps(
val name: String,
val db: Db,
val features: Map<String, Boolean> = emptyMap(),
) {
data class Db(val url: String, val poolSize: Int = 10)
}
app:
name: svc
db:
url: jdbc:postgresql://localhost/db
pool-size: 20
features:
new-checkout: true
ケバブケース(pool-size)がキャメルケース(poolSize)に対応する。 これは Boot の relaxed binding である。
12. @Transactional のKotlin特有問題
12.1 検査例外の問題は起きない
Java 版 第13.2節で「検査例外はコミットされる」と実測した。
RuntimeException を投げる [BEGIN, ROLLBACK]
検査例外を投げる [BEGIN, COMMIT] ← Java の問題
Kotlin には検査例外が無い。 すべての例外は非検査である。
@Transactional
fun transfer() {
throw InsufficientFundsException() // Kotlin の例外は常に非検査 → ロールバックする
}
したがってこの問題は構造的に起きない。 Kotlin の利点である。
ただし Java のライブラリが投げる検査例外は依然として検査例外である。
@Transactional
fun read() {
Files.readString(path) // IOException(Java の検査例外)→ ★コミットされる
}
JVM 上のバイトコードは同じなので、TransactionInterceptor は IOException を検査例外として扱う。 Kotlin から見えないだけである。
@Transactional(rollbackFor = [Exception::class]) // ✅ 明示する
rollbackFor の記法が Java と違う点にも注意する。
@Transactional(rollbackFor = Exception.class) // Java
@Transactional(rollbackFor = [Exception::class]) // Kotlin(配列リテラルと ::class)
12.2 自己呼び出し問題は同じ
Java 版 第12章と完全に同じである。
@Service
open class Svc {
@Transactional open fun outer() { this.inner() } // ❌ inner の @Transactional は無効
@Transactional(propagation = Propagation.REQUIRES_NEW) open fun inner() { }
}
Kotlin 特有の悪化要因がある。 open の付け忘れが加わるため、「自己呼び出しでもない、final でもない」場合でも効かないことがある。
効かない原因の候補(Kotlin)
① 自己呼び出し(Java と同じ)
② クラスが final(all-open 未適用)
③ メソッドが final(クラスだけ open)
④ private / internal
②③が Kotlin 固有である。 原因の切り分けには第5.3節の javap が要る。
12.3 internal はプロキシできない
@Service
open class Svc {
@Transactional internal fun tx() { } // ❌ 効かない
}
internal はバイトコード上 public になるが、名前がマングルされる(tx$module_name)。CGLIB はオーバーライドできるが、Spring のポイントカットが一致しない場合がある。
@Transactional を付けるメソッドは public にする。 これは Java でも同じ規則である。
12.4 拡張関数に AOP は効かない
@Transactional
fun Repo.saveAll(orders: List<Order>) { } // ❌ 効かない
拡張関数は静的メソッドにコンパイルされる。
fun Repo.saveAll(...) → public static void saveAll(Repo $this, List orders)
静的メソッドはプロキシで傍受できない。 @Transactional も @Cacheable も効かない。
トランザクションが必要なロジックは、クラスのメンバ関数にする。
13. コルーチンとトランザクション
13.1 トランザクションはスレッドローカルである
Spring のトランザクション同期は ThreadLocal(TransactionSynchronizationManager)で実装されている。
@Transactional
→ TransactionSynchronizationManager に「現在のトランザクション」を ThreadLocal で束縛
→ 同じスレッドで実行される限り、参加できる
コルーチンはスレッドを移動しうるため、これが壊れる。
@Transactional
suspend fun process() {
repo.save(a) // スレッド1
withContext(Dispatchers.IO) {
repo.save(b) // ★別スレッド。同じトランザクションではない
}
}
13.2 Spring 5.2 以降は suspend に対応している
@Transactional は suspend 関数をサポートする(Spring 5.2+)。
@Transactional
suspend fun process() {
repo.save(a)
repo.save(b) // ✅ 同じトランザクション(Reactive Transaction Manager 経由)
}
ただし条件がある。
・R2DBC など、リアクティブなトランザクションマネージャが必要
(ReactiveTransactionManager)
・JDBC(PlatformTransactionManager)との組み合わせでは動かない
・withContext でディスパッチャを変えると壊れる
JDBC + コルーチンは組み合わせない。 ブロッキングI/Oをコルーチンで包むだけなら意味がなく、トランザクションが壊れるリスクだけが増える。
13.3 現実的な選択
① JDBC / JPA を使う → 通常の関数(suspend でない)+ @Transactional
必要なら Dispatchers.IO でラップして呼ぶ
② R2DBC を使う → suspend 関数 + @Transactional(ReactiveTransactionManager)
③ 混在させる → トランザクション境界を suspend の外に置く
// ① の形
@Service
class OrderService(private val repo: JdbcRepo) {
@Transactional
fun placeBlocking(o: Order) { repo.save(o) } // 通常の関数
}
@Service
class OrderFacade(private val svc: OrderService) {
suspend fun place(o: Order) = withContext(Dispatchers.IO) {
svc.placeBlocking(o) // トランザクションは内側で完結
}
}
トランザクション境界をコルーチンの外に出すのが安全である。
13.4 MDC / スレッドローカルも同じ問題を持つ
// ❌ ログの相関IDが失われる
suspend fun handle() {
MDC.put("traceId", id)
withContext(Dispatchers.IO) { log.info("...") } // traceId が無い
}
MDCContext(kotlinx-coroutines-slf4j)を使う。
withContext(Dispatchers.IO + MDCContext()) { }
トランザクション・MDC・セキュリティコンテキストはすべて ThreadLocal である。 コルーチンを使うなら、それぞれに対応する CoroutineContext 要素が必要になる。
14. 拡張関数・トップレベル関数とBean
14.1 拡張関数は Bean にならない
// ユーティリティを拡張関数で書く
fun Order.toResponse() = OrderResponse(id, amount, status.name)
これは静的メソッドなので、Bean ではない。 DI も AOP も効かない(第12.4節)。
依存が必要なら通常のクラスにする。
@Component
class OrderMapper(private val clock: Clock) {
fun toResponse(o: Order) = OrderResponse(o.id, o.amount, clock.instant())
}
依存が無いなら拡張関数の方が良い。 Bean を1つ減らせる。
依存が無い変換・整形 → 拡張関数(トップレベル)
��存が必要 / AOP が必要 → @Component のクラス
14.2 Bean定義DSL
Kotlin では関数型の Bean 登録が使える。
val beans = beans {
bean<OrderService>()
bean<OrderRepository>()
bean { OrderMapper(ref()) } // ref() で他の Bean を参照
bean<Cache>(name = "l1", isLazyInit = true)
profile("prod") { bean<ProdDataSource>() }
}
// 適用
val ctx = GenericApplicationContext().apply {
beans.initialize(this)
refresh()
}
利点。
・リフレクションを使わないため起動が速い(GraalVM Native Image に有利)
・@Configuration の CGLIB プロキシが不要 → all-open の問題を回避できる
・条件分岐が普通の Kotlin コードで書ける
欠点。
・コンポーネントスキャンの自動性を失う(すべて手で登録する)
・Boot の自動構成との組み合わせが複雑
・情報が少ない
実務ではまだ主流ではない。 Native Image を本気で使う場合の選択肢である。
14.3 ルーター DSL(WebFlux)
@Bean
fun routes(handler: OrderHandler) = coRouter {
"/orders".nest {
GET("/{id}", handler::get)
POST("", handler::create)
}
}
@RestController の代わりに関数で書ける。 coRouter はコルーチン対応版である。
アノテーションベースとの比較。
@RestController | ルーターDSL | |
|---|---|---|
| 記述量 | 中 | 少ない |
| ルーティングの一覧性 | 分散する | 1箇所に集まる |
AOP(@PreAuthorize など) | 効く | 効かない(関数なので) |
| テスト | @WebMvcTest | WebTestClient |
AOP が効かない点が重要である。 認可をアノテーションで書いている場合は移行できない。
15. テスト
15.1 バッククォートのテスト名
class OrderServiceTest {
@Test
fun `注文が作成されたら在庫が減ること`() { }
}
Kotlin はバッククォートで任意の識別子を書けるため、テスト名が自然言語になる。 JUnit の @DisplayName が不要になる。
JVM の制約でいくつかの文字は使えない。
.;[]/\は不可である。
15.2 lateinit と DI
@SpringBootTest
class IntegrationTest {
@Autowired lateinit var svc: OrderService // ① 慣用
}
@SpringBootTest
class IntegrationTest(@Autowired val svc: OrderService) // ② コンストラクタ注入も可
②は JUnit 5 の SpringExtension が対応している。 val にできるので望ましいが、@MockBean などと混在させると煩雑になる。
15.3 モックライブラリ
// MockK — Kotlin 専用
val repo = mockk<Repo>()
every { repo.findById(1L) } returns Order(1L)
verify { repo.save(any()) }
// Mockito — final クラスに対応が必要
@Mock lateinit var repo: Repo // mockito-inline が必要
Mockito は final クラスをモックできない。 Kotlin では既定で final なので、mockito-inline(Mockito 5 以降は既定)が必要である。
MockK は Kotlin の言語機能に対応している。
coEvery { repo.suspendFind(1L) } returns Order(1L) // suspend 関数
mockkStatic(::topLevelFunction) // トップレベル関数
mockkObject(Singleton) // object
Kotlin プロジェクトでは MockK を推奨する。
15.4 コンテナのキャッシュを効かせる
@SpringBootTest // ← 設定が同じならコンテキストが再利用される
class ATest
@SpringBootTest // ← A と同じ設定なら再利用
class BTest
@SpringBootTest(properties = ["x=1"]) // ★ 設定が違うので別コンテキスト
class CTest
ApplicationContext の生成は重い。 テストクラスごとに再生成されると、テスト全体が極端に遅くなる。
コンテキストが再利用されない要因
・@SpringBootTest の properties / classes が違う
・@MockBean / @SpyBean の対象が違う
・@ActiveProfiles が違う
・@DirtiesContext が付いている
@MockBean はコンテキストを再生成させる。 多用するとテストが遅くなる。MockK でコンストラクタに渡す方が速い。
// ✅ コンテキストを汚さない
class OrderServiceTest {
private val repo = mockk<Repo>()
private val svc = OrderService(repo) // Spring を起動しない
}
コンストラクタ注入だと Spring 無しでテストできる(第6章)。これが Kotlin でコンストラクタ注入が有利な実践的理由である。
15.5 all-open のテスト
第5.4節で述べたが、テストで final を検証すると all-open の適用漏れを防げる。
@SpringBootTest
class ProxyabilityTest(@Autowired val ctx: ApplicationContext) {
@Test
fun `@Service はすべて open であること`() {
val finals = ctx.getBeanNamesForAnnotation(Service::class.java)
.mapNotNull { ctx.getType(it) }
.filter { Modifier.isFinal(it.modifiers) }
assertThat(finals).isEmpty()
}
@Test
fun `@Transactional メソッドはすべて open であること`() {
val bad = ctx.getBeanDefinitionNames()
.mapNotNull { ctx.getType(it) }
.flatMap { it.declaredMethods.toList() }
.filter { it.isAnnotationPresent(Transactional::class.java) && Modifier.isFinal(it.modifiers) }
assertThat(bad).isEmpty()
}
}
このテストがあれば、第5.2節の「黙ってトランザクションが無い」状態を防げる。
16. 実運用での姿 — SMPを実測する
16.1 Kotlin での Spring 採用状況
ある決済プラットフォームのリポジトリ群を走査した。
$ find . -name '*.kt' -not -path '*/.git/*' | wc -l
4454
$ grep -rl 'import org.springframework' --include='*.kt' . | wc -l
784
Kotlin ファイルが 4,454、うち Spring を使うものが 784 である。
Java 側と比べる。
| Java + Kotlin 合計 | うち Kotlin | |
|---|---|---|
import org.springframework | 14,857 | 784(5.3%) |
Spring の利用は依然として Java が主体である。 Kotlin は新しいサービス群に集中している。
16.2 Kotlin + Spring はどこにあるか
$ for d in $(grep -rl 'import org.springframework' --include='*.kt' . | cut -d/ -f2-3 | sort -u); do
printf " %-42s %4s ファイル\n" "$d" "$(grep -rl 'import org.springframework' --include='*.kt' "$d" | wc -l)"
done
cash-wdp/apple-cash-account-service 237 ファイル
cash-wdp/apple-cash-transfer-service 129 ファイル
issuer-products/magma-service-application 110 ファイル
energy-services/grid-data-service 88 ファイル
cash-wdp/apple-cash-disbursement-service 68 ファイル
issuer-products/magma-onboarding 59 ファイル
cash-wdp/apple-cash-ingress 43 ファイル
cash-wdp/apple-cash-notification-service 42 ファイル
connected-accounts/boskoop 4 ファイル
tap-to-pay/payments-pos-translate 4 ファイル
cash-wdp(5サービスで519ファイル)が最大の Kotlin + Spring 資産である。 新しいサービス群が Kotlin を選んでいる。
16.3 kotlin-spring プラグインの適用状況 — 見えない
第4.5節で述べた問題が、実データで確認できる。
$ grep -rn 'plugin.spring\|kotlin-spring\|allOpen' --include='*.gradle' --include='*.kts' . | head
tap-to-pay/payments-pos-translate/translate-service/build.gradle:9:
id "org.jetbrains.kotlin.plugin.spring" version "${versionKotlin}"
energy-services/grid-data-service/build.gradle:14:
id "org.jetbrains.kotlin.plugin.spring" version "$kotlinVersion"
energy-services/grid-data-service/build.gradle:38:
apply plugin: "org.jetbrains.kotlin.plugin.spring"
energy-services/grid-data-service/build.gradle:63: allOpen {
issuer-products/magma-onboarding/build.gradle:23:
apply plugin: 'org.jetbrains.kotlin.plugin.spring'
宣言が見つかるのは energy-services / issuer-products / tap-to-pay である。
最大の資産である cash-wdp(519ファイル)には宣言が0件である。
$ head -6 cash-wdp/apple-cash-account-service/build.gradle.kts
plugins {
alias(catalog.plugins.wpc.platform.conventions.kotlin.group.application)
alias(catalog.plugins.wpc.platform.conventions.kotlin.group)
alias(catalog.plugins.wpc.platform.conventions.docker.group)
alias(catalog.plugins.wpc.platform.conventions.pkl.platform.versions)
}
社内の convention plugin(wpc.platform.conventions.kotlin.*)が適用している。 アプリのビルドファイルからは kotlin-spring の存在が読み取れない。
# 版カタログには宣言がある
$ grep -n 'kotlin-spring' cash-wdp/apple-cash-notification-service/gradle/libs.versions.toml
8:kotlin-spring = "2.1.21"
46:kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin-spring" }
含意は2つある。
① 新しく参加した開発者は、all-open が効いていることを知らない。 「なぜ open を書かなくても @Transactional が動くのか」が説明できない。
② convention plugin の変更が全サービスに影響する。 プラグインを外す変更が入ると、519ファイルのトランザクションが黙って無効になる。
第4.5節で「javap で確認するのが唯一確実」と書いたのは、この構造があるからである。 そして第5.4節・第15.5節のテストが必要になる。
16.4 data class と JPA は使われていない
$ grep -rl -B3 '@Entity' --include='*.kt' . | wc -l
0
$ grep -rl 'plugin.jpa\|noArg\|no-arg' --include='*.gradle' --include='*.kts' . | wc -l
0
Kotlin で @Entity を書いているコードは0件、kotlin("plugin.jpa") の適用も0件である。
第8.2節で「data class と JPA は相性が悪い」と書いたが、この資産ではその問題自体が発生していない。 Kotlin のサービス群は JPA を使っていない(R2DBC や独自のデータアクセス層を使っている)。
逆に言えば、「Kotlin + JPA」は避けられているとも読める。
16.5 lateinit によるフィールド注入は少ない
$ grep -rh -A1 '@Autowired' --include='*.kt' . | grep -c 'lateinit'
30
lateinit var + @Autowired は30箇所しかない。
Java 側と比較すると差が際立つ。
| フィールド注入 | 比率 | |
|---|---|---|
| Java | 6,888 箇所 | 78.6% |
| Kotlin | 30 箇所 | ごく少数 |
Kotlin ではコンストラクタ注入が実際に主流になっている。 第6.1節で「Kotlin ではコンストラクタ注入が最短だから動機が消える」と述べたことが、実データで裏付けられた。
これは Kotlin を採用する実務的な利益である。 「公式の推奨に従わせる」ためにレビューで戦う必要がなく、構文が自然に正しい形へ導く。
16.6 測定から言えること
① Kotlin + Spring は新規サービスに集中している(784 / 14,857 = 5.3%)
→ 既存の Java 資産を書き換える動きは無い
② コンストラクタ注入は Kotlin で実際に主流になっている(30 対 6,888)
→ 言語の構文が設計を誘導した実例である
③ all-open プラグインは convention plugin に隠れている
→ 適用の確認手段(javap / テスト)を持つ必要がある(第4.5節・第15.5節)
④ Kotlin + JPA は避けられている(@Entity が0件)
→ data class と JPA の非互換(第8.2節)を回避する選択と読める
16.7 実例集 — Kotlin での Spring 使用状況
Kotlin での Spring 使用を機能別に数えた(--include='*.kt' で784ファイルを対象)。
| 機能 | Kotlin | 参考: Java+Kotlin 合計 |
|---|---|---|
suspend fun | 600 | — |
data class | 721 | — |
mockk | 537 | — |
companion object | 436 | — |
lateinit var | 260 | — |
kotlinx.coroutines | 212 | — |
@Service | 171 | 2,654 |
@Component | 105 | 3,131 |
Mono< | 71 | 2,047 |
@Autowired | 62 | 4,837 |
@Configuration | 61 | 1,172 |
@Bean | 55 | 960 |
open class | 36 | — |
@RestController | 30 | 325 |
@Qualifier | 30 | 1,349 |
sealed interface | 28 | — |
open fun | 25 | — |
sealed class | 22 | — |
@Lazy / @Primary | 各18 | 1,239 / 227 |
Dispatchers | 15 | — |
@Value | 13 | 1,151 |
withContext | 13 | — |
@Transactional | 12 | 512 |
Flux< | 12 | 289 |
@EnableConfigurationProperties | 12 | 266 |
@Repository | 11 | 181 |
@ConfigurationProperties | 8 | 369 |
@SpringBootApplication | 7 | 123 |
awaitSingle | 6 | — |
@field: | 5 | — |
runApplication | 5 | — |
ObjectProvider | 4 | 165 |
@Validated | 2 | 271 |
@Aspect / @Around | 0 | 18 |
@Entity | 0 | 335 |
CoroutineCrudRepository | 0 | — |
この表から4つのことが読める。
① Java と Kotlin で非同期モデルが逆である。
Java 側 Mono< 2,047 ファイル ← Reactor
Kotlin 側 suspend fun 600 ファイル ← コルーチン(Mono< は 71 のみ)
Kotlin 側はコルーチンを使い、Reactor を直接触っていない。 第13章で述べた「コルーチンとトランザクション」の問題が、実際にはコルーチン側で @Transactional を使わない(12ファイルのみ)という形で回避されている。
② 自作アスペクトが0件である。 Java 側には18件あるが、Kotlin 側にはない。final の問題を避けているとも、単に必要がなかったとも読める。
③ @Entity が0件である。 第8.2節で「data class と JPA は相性が悪い」と述べたが、この資産ではそもそも Kotlin で JPA を使っていない。 kotlin("plugin.jpa") の適用も0件だった。
④ @field: が5件しかない。 第8.3節で「付け忘れると黙って効かない」と警告したが、Bean Validation を使う箇所自体が少ない(@Validated は2件)。逆に言えば、使っている5件は正しく書けている。
16.8 実例集 — コンストラクタ注入が実際に主流
@Service
class PurchaseTransactionService(
private val issuingProcessorService: IssuingProcessorService,
private val storedValueProviderService: StoredValueProviderService,
private val peerPaymentAccountLookupService: PeerPaymentAccountLookupService,
private val cachingPersistenceService: CachingPersistenceService,
📎 PurchaseTransactionService.kt#L35-L41
@Autowired すら書いていない。 第6.1節で述べたとおり、Kotlin ではこれが最短形である。
lateinit var は260ファイルにあるが、@Autowired と組むのは62ファイルのみで、その大半がテストである。
@Autowired private lateinit var seeder: MarketAvailabilitySeeder
📎 MarketAvailabilityCascadeIntegrationTest.kt#L30-L40
第6.3節で「テストクラスは lateinit が慣用」と述べた形である。 本番コードではコンストラクタ注入が使われている。
16.9 実例集 — @Transactional と final の実際
Kotlin の @Transactional は12ファイルしかない。 そのすべてを調べた結果が興味深い。
| ファイル | クラス宣言 | メソッド |
|---|---|---|
PreferencePersistenceServiceImpl.kt | open class | override fun |
QuoteOrderPersistenceServiceImpl.kt | open class | override fun |
PaymentRecordPersistenceServiceImpl.kt | open class | override fun |
RegisteredDevicePersistenceServiceImpl.kt | open class | override fun |
StoredValueAssociatedAccountPersistenceServiceImpl.kt | open class | override fun |
AssociatedAccountPreferencePersistenceServiceImpl.kt | open class | override fun |
StoredValueAccountAliasPersistenceServiceImpl.kt | open class | override fun |
BusinessAccountPersistenceServiceImpl.kt | open class | override fun |
ComponentTestSpringConfig.kt | open class | — |
StoredValueAccountPersistenceServiceImpl.kt | class(open なし) | override fun |
MigrateDataServiceImpl.kt | class(open なし) | override fun |
UpdatePaymentMetadataServiceImpl.kt | class(open なし) | override suspend fun |
実際のコードを見る。
@Service
@Transactional(rollbackFor = [AppleCashServerException::class, RuntimeException::class])
open class PreferencePersistenceServiceImpl(
private val accountPreferenceRepository: AccountPreferenceRepository
) : PreferencePersistenceService {
private val log = LogFactory.getLogger<PreferencePersistenceServiceImpl>()
override fun getPreferences(accountId: String): List<AccountPreference> {
return accountPreferenceRepository.findByAccountIdentifier(accountId)
}
📎 PreferencePersistenceServiceImpl.kt#L11-L20
このコードから3つのことが読める。
① rollbackFor を正しく指定している。 Kotlin の配列リテラル記法([...::class])で、第12.1節で述べた形になっている。Java 側は940件中92件(9.8%)しか指定していないのに対し、Kotlin 側は正しく書けている。
② open class を手で書いている。 12ファイル中9ファイルが open class である。all-open プラグインに依存せず、明示している。
③ メソッドは override fun である(open fun ではない)。 インタフェース(PreferencePersistenceService)を実装しているため、メソッドは暗黙に open になる。
これが本ガイド第3.1節の実測(
IfcImpl.workがfinal=false)と一致する。 インタフェースを実装してoverrideすれば、メソッドは all-open なしでもopenになる。つまりこの設計は、all-open プラグインの有無に関係なく動く。 第5.2節で警告した「クラスだけ
openでメソッドがfinal」という最悪の状態を、インタフェース経由の設計で構造的に回避している。
ただし一貫はしていない。 3ファイル(StoredValueAccountPersistenceServiceImpl / MigrateDataServiceImpl / UpdatePaymentMetadataServiceImpl)は open の無い素の class である。
@Transactional
override suspend fun updatePaymentMetadata(
📎 UpdatePaymentMetadataServiceImpl.kt#L18-L28
これらは all-open プラグインが適用されていることに依存している。 クラスが final のままだと AopConfigException で起動に失敗するため、起動している事実が「all-open が効いている」ことの証拠になっている。
@Transactional+suspend funの組み合わせにも注目したい。 第13.2節で述べたとおり、これが正しく動くにはReactiveTransactionManagerが必要である。Java+Kotlin 全体でReactiveTransactionManagerは1件しかないため、この箇所の挙動は確認する価値がある。
16.10 実例集 — @Bean に open を付ける防御
@Configuration
@ImportAutoConfiguration(DynamoDbBindings::class, EventBindings::class)
@Import(LocalizationConfiguration::class)
@ComponentScan(basePackages = ["com.apple.wpc.applecash.common"])
@EnableAspectJAutoProxy
class ApplicationFactory {
…
@Bean("isLocal") open fun isLocal(config: ApplicationConfig): Boolean = config.wdp.isLocal
@Bean fun keyPerformanceAspect() = KeyPerformanceAspect()
@Bean(SERVER_HEADER_INTERCEPTOR)
fun serverHeaderInterceptor(): ServerHeaderInterceptor {
return ServerHeaderInterceptor()
}
📎 ApplicationFactory.kt#L276-L295
この1ファイルに @Bean が118個ある。 Kotlin 側で最大の設定クラスである。
注目点が3つある。
① class ApplicationFactory は open ではない。 @Configuration に open を付けていないため、all-open プラグインが適用されていなければ起動に失敗する(第3.3節①)。起動している事実が適用の証拠である。
② @Bean("isLocal") open fun — メソッドにだけ open を付けている箇所がある。 Kotlin 全体で @Bean に open fun を付けているのは39件、付けていないのが546件である。一貫していない。
③ @EnableAspectJAutoProxy があるのに @Aspect が0件である。 Kotlin 側に自作アスペクトはない。ただし keyPerformanceAspect() という @Bean があるため、アスペクト自体は別モジュール(おそらく Java か社内ライブラリ)から来ている。
16.11 実例集 — コルーチンと Web 層
@RestController
class GridLookupController(
private val coordinateMappingService: CoordinateMappingService
) : GridLookupControllerApi {
override suspend fun getGrid(postGridLookupRequest: PostGridLookupRequest): ResponseEntity<PostGridLookupResponse> {
val longLat = postGridLookupRequest.locations
.map { location -> doubleArrayOf(location.longitude, location.latitude) }
.toList()
val mappedCoordinates = coordinateMappingService.translateCoordinates(longLat)
return ResponseEntity.ok(toApiResponse(mappedCoordinates))
}
📎 GridLookupController.kt#L1-L25
3つの設計判断が読み取れる。
① suspend fun をコントローラで直接使っている。 Spring MVC / WebFlux は suspend 関数をサポートするため、Mono を書かずにノンブロッキングにできる。
② GridLookupControllerApi インタフェースを実装している。 これは OpenAPI からの生成インタフェースと読める。override によりメソッドが暗黙に open になるため、all-open に依存しない。
③ コンストラクタ注入で、クラスに open は無い。 @RestController は @Component のメタアノテーションを持つため、all-open の対象になる。
coRouter(Kotlin のルーターDSL)は0件である。 第14.3節で紹介したが、この資産では使われていない — アノテーションベースの @RestController を使っている。
16.12 実例集 — data class と設定
DTO に @field: を正しく付けている
@LoggableObject
@Serializable
data class RequestHeader(
@DataClassification([DataElement.INTERNAL_NON_SENSITIVE])
@field:Size(max = 64)
val requestId: String = "requestIdNotPresent",
@DataClassification([DataElement.INTERNAL_NON_SENSITIVE])
@field:Size(max = 64)
val conversationId: String = "conversationIdNotPresent",
@DataClassification([DataElement.INTERNAL_NON_SENSITIVE]) var seId: String? = null,
@DataClassification([DataElement.INTERNAL_NON_SENSITIVE])
var testOverrides: Map<String, String>? = null,
)
第8.3節で警告した @field: が正しく付いている。 対比が明確である。
@field:Size(max = 64) val requestId: String // ✅ Validation が見る
@DataClassification([...]) var seId: String? = null // ← 自作アノテーションは対象指定なし
自作の @DataClassification には @field: を付けていない。 これはValidation ではなく静的解析・ログ用のアノテーションなので、パラメータに付いていれば十分と判断していると読める。「どのアノテーションに対象指定が必要か」を区別できている。
加えて val と var を使い分けている。 requestId / conversationId は不変、seId / testOverrides は可変である。既定引数(= "requestIdNotPresent")も併用しており、第9章で述べた「DTO の既定引数は積極的に使う」に沿っている。
@ConfigurationProperties を data class で受ける
@ConfigurationProperties("config")
data class GridDataServiceProperties(
val emissionsDataSourceConfig: EmissionsDataSourceConfig,
📎 GridDataServiceProperties.kt#L121-L123
@ConfigurationPropertiesScan
class Application {
@PostConstruct
第11.1節で推奨した「data class によるコンストラクタバインディング」がそのまま使われている。 すべて val で不変であり、ネストした設定(EmissionsDataSourceConfig)も型で表現されている。
16.13 all-open プラグインの適用状況 — 見えない
第4.5節で「convention plugin に隠れる」と述べた状況が、実データで確認できる。
$ grep -rn 'plugin.spring\|kotlin-spring\|allOpen' --include='*.gradle' --include='*.kts' . | head
tap-to-pay/payments-pos-translate/translate-service/build.gradle:9:
id "org.jetbrains.kotlin.plugin.spring" version "${versionKotlin}"
energy-services/grid-data-service/build.gradle:14:
id "org.jetbrains.kotlin.plugin.spring" version "$kotlinVersion"
energy-services/grid-data-service/build.gradle:38:
apply plugin: "org.jetbrains.kotlin.plugin.spring"
energy-services/grid-data-service/build.gradle:63: allOpen {
issuer-products/magma-onboarding/build.gradle:23:
apply plugin: 'org.jetbrains.kotlin.plugin.spring'
📎 grid-data-service/build.gradle#L10-L70
宣言があるのは energy-services / issuer-products / tap-to-pay である。
最大の資産である cash-wdp(5サービス・519ファイル)には宣言が0件である。
// cash-wdp/apple-cash-account-service/build.gradle.kts
plugins {
alias(catalog.plugins.wpc.platform.conventions.kotlin.group.application)
alias(catalog.plugins.wpc.platform.conventions.kotlin.group)
alias(catalog.plugins.wpc.platform.conventions.docker.group)
alias(catalog.plugins.wpc.platform.conventions.pkl.platform.versions)
}
📎 apple-cash-account-service/build.gradle.kts#L1-L10
社内の convention plugin(wpc.platform.conventions.kotlin.*)が適用している。 版カタログには宣言がある。
kotlin-spring = "2.1.21"
kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin-spring" }
📎 apple-cash-notification-service/gradle/libs.versions.toml
含意が2つある。
① 新しく参加した開発者は、all-open が効いていることを知らない。 「なぜ open を書かなくても @Transactional が動くのか」を説明できない。第16.9節で見たとおり、9ファイルは防御的に open class を書き、3ファイルはプラグインに依存している — この不一致は「知っている人と知らない人が混在している」ことの表れと読める。
② convention plugin の変更が全サービスに影響する。 プラグインを外す変更が入ると、open を書いていない3ファイルは起動に失敗し(AopConfigException)、open class の9ファイルは起動するがメソッドの状況次第で挙動が変わる。
だから第4.5節の javap による確認と、第15.5節のテストが必要になる。
16.14 測定から言えること — Java との対比
| 論点 | Java | Kotlin |
|---|---|---|
| 非同期モデル | Reactor(Mono< 2,047) | コルーチン(suspend fun 600、Mono< は71) |
| 注入方式 | フィールド注入 78.6% | コンストラクタ注入が主流(lateinit+@Autowired は62、大半がテスト) |
@Transactional の rollbackFor | 940件中92件(9.8%) | 12件すべてで適切に指定([...::class]) |
| 自作アスペクト | 18件 | 0件 |
| JPA | @Entity 335件 | 0件(plugin.jpa も0件) |
final 対策 | 不要 | 9件が open class を手書き、3件がプラグイン依存 |
| DTO | record を採用 | data class 721件 |
| Validation | @Valid 953件 | @field: 5件(使用箇所自体が少ない) |
| モック | Mockito | MockK 537件 |
| ルーターDSL | RouterFunction 138件(Java) | coRouter 0件(@RestController を使用) |
4つの結論を挙げる。
① コンストラクタ注入は Kotlin で実際に主流になっている。 Java のフィールド注入6,888箇所に対し、Kotlin の lateinit フィールド注入は62ファイル(大半がテスト)である。第6.1節で述べた「構文が設計を誘導する」ことの実例である。
② rollbackFor の指定率が Kotlin で圧倒的に高い。 Java は9.8%、Kotlin は12件すべてである。理由は推測になるが、Kotlin のサービス群が新しく、レビュー基準が確立していたためと読める。 第12.1節で述べた「Kotlin には検査例外が無い」ことを知っていれば rollbackFor は不要だが、Java ライブラリ由来の検査例外に備えて明示しているなら、より慎重な判断である。
③ final 対策が一貫していない。 12ファイル中9が open class を手書き、3がプラグインに依存している。javap による確認(第4.5節)と自動テスト(第15.5節)を導入する価値が、この不一致から具体的に説明できる。
④ Kotlin + JPA は避けられている。 @Entity が0件、plugin.jpa も0件である。第8.2節で述べた data class と JPA の非互換を、そもそも踏まない選択がされている。 Kotlin のサービス群は DynamoDB / PostgreSQL のリポジトリ層を独自に組んでいる。
17. 落とし穴
17.1 final クラスで @Configuration が起動に失敗する
// ❌
@Configuration class AppConfig { @Bean fun svc() = Svc() }
// ✅ all-open プラグインを適用する(または open を書く)
Configuration problem: @Configuration class 'AppConfig' may not be final.
第3.3節①。エラーになるので気付ける。
17.2 final メソッドで @Transactional が黙って無効になる
// ❌ 起動は成功する。トランザクションだけが無い
@Service open class Svc { @Transactional fun tx() { } }
// ✅
@Service open class Svc { @Transactional open fun tx() { } }
第5.2節。実測で「傍受0回」を確認した。最も危険である。
17.3 @Value の $ をエスケープしない
// ❌ Kotlin の文字列テンプレートが先に解釈する
@Value("${app.name}") val name: String
// ✅
@Value("\${app.name}") val name: String
第7.2節。
17.4 Bean Validation に @field: を付けない
// ❌ 検証が黙って効かない
data class Req(@NotBlank val name: String)
// ✅
data class Req(@field:NotBlank val name: String)
第8.3節。 アノテーションがコンストラクタパラメータに付き、Validation が見ない。
17.5 data class を @Entity にする
// ❌ 引数なしコンストラクタが無い。equals/hashCode が遅延ロードを壊す
@Entity data class Order(@Id val id: Long, val amount: BigDecimal)
// ✅ 通常のクラス + ID ベースの equals
第8.2節。実測で「引数なしなし」を確認した。
17.6 DI を受けるコンストラクタに既定引数を使う
// ❌ Bean が無ければ既定値、にはならない
@Service class Svc(private val repo: Repo, private val cache: Cache = NoOpCache())
// ✅
@Service class Svc(private val repo: Repo, cache: Cache?) { … }
第9.3節。NoSuchBeanDefinitionException になる。
17.7 kotlin-reflect を依存に入れない
No primary or single unique constructor found for class Svc
第9.2節。 implementation(kotlin("reflect")) が必要である。
17.8 拡張関数に AOP を期待する
// ❌ 静的メソッドなのでプロキシできない
@Transactional fun Repo.saveAll(orders: List<Order>) { }
// ✅ クラスのメンバ関数にする
第12.4節。
17.9 コルーチンでトランザクションを跨ぐ
// ❌ 別スレッドになるので同じトランザクションではない
@Transactional suspend fun process() {
repo.save(a)
withContext(Dispatchers.IO) { repo.save(b) }
}
// ✅ トランザクション境界をコルーチンの外に置く
第13.1〜13.3節。
17.10 internal メソッドに AOP を付ける
// ❌ 名前がマングルされ、ポイントカットが一致しないことがある
@Transactional internal fun tx() { }
// ✅ public にする
第12.3節。
17.11 プラットフォーム型を非null型で受ける
// ❌ Java の戻り値が null なら実行時 NPE
val name: String = javaRepo.findName(1L)
// ✅
val name: String? = javaRepo.findName(1L)
第7.3節。
17.12 @MockBean を多用してテストを遅くする
// ❌ コンテキストが再生成される
@SpringBootTest class T { @MockBean lateinit var repo: Repo }
// ✅ Spring を起動しない
class T { private val svc = OrderService(mockk()) }
第15.4節。 コンストラクタ注入なら Spring 無しでテストできる。
17.13 症状別の逆引き
| 症状 | 原因 | 対処 | 節 |
|---|---|---|---|
@Configuration class ... may not be final | クラスが final | all-open プラグイン | 17.1 |
@Transactional が効かない(起動は成功) | メソッドが final | all-open / open | 17.2 |
@Transactional が効かない(open は付いている) | 自己呼び出し | クラスを分ける | 12.2 |
AopConfigException | final クラスに CGLIB | all-open | 3.2 |
@Value が式として解釈される | $ のエスケープ漏れ | \${...} | 17.3 |
| Bean Validation が効かない | @field: の欠落 | @field:NotBlank | 17.4 |
| JPA でエンティティを作れない | 引数なしコンストラクタが無い | plugin.jpa / 通常のクラス | 17.5 |
NoSuchBeanDefinitionException(任意の依存) | 既定引数に期待した | null許容型で受ける | 17.6 |
No primary or single unique constructor | kotlin-reflect が無い | 依存に追加 | 17.7 |
| 拡張関数で AOP が効かない | 静的メソッド | メンバ関数にする | 17.8 |
| コルーチンでトランザクションが分裂する | ThreadLocal | 境界をコルーチン外へ | 17.9 |
UninitializedPropertyAccessException | lateinit の未初期化 | 注入順序を確認 | 10.1 |
| 実行時 NPE(Java 呼び出し後) | プラットフォーム型 | null許容型で受ける | 17.11 |
| テストが遅い | @MockBean の多用 | MockK + コンストラクタ | 17.12 |
LazyInitializationException | data class の equals/toString | ID ベースに書き換える | 8.2 |
18. まとめ
18.1 Kotlin で Spring を使うということ
① 障壁は1つだけ — Kotlin の final と Spring の CGLIB の衝突
→ kotlin-spring(all-open)プラグインで解決する(第4章)
→ ただし「解決されていることの確認手段」を持つ必要がある(第4.5節)
② 解決したあとは Java より書きやすい
→ コンストラクタ注入が最短になる(第6章)
→ null安全が設定の必須/任意を型で表す(第7章)
→ data class が DTO を1行にする(第8.3節)
③ 別の落とし穴が3つ増える
→ data class と JPA(第8章)
→ 既定引数と DI(第9章)
→ コルーチンとトランザクション(第13章)
18.2 実測で確定した5つの事実
① Kotlin は class も method も既定で final である。 Closed クラスは class final=true / tx final=true だった(第3.1節)。
② final クラスは CGLIB プロキシを作れない。 AopConfigException になる。インタフェースを実装していても、クラスが final なら CGLIB強制では失敗する(第3.2節)。
③ クラスだけ open でメソッドが final の場合、プロキシは作られるがアドバイスは織り込まれない。 傍受0回を実測した。エラーも警告も出ない(第5.1節)。
④ data class は引数なしコンストラクタを持たない。 既定引数があるとコンストラクタが2つ(2引数と4引数)になる(第8.1節・第9.1節)。
⑤ null非許容の実体は合成された実行時チェックである。 NullPointerException: Parameter specified as non-null is null を実測した(第7.1節)。
18.3 実運用から得た教訓
第16章の測定から3点を挙げる。
① コンストラクタ注入は Kotlin で実際に主流になっている。 Java のフィールド注入 6,888 箇所に対し、Kotlin の lateinit フィールド注入は 30 箇所だった。「言語の構文が設計を誘導する」ことの実例である。 レビューで戦うより、構文が短い方が強い。
② all-open プラグインは convention plugin に隠れる。 最大の Kotlin + Spring 資産(519ファイル)で、アプリのビルドファイルに宣言が0件だった。「なぜ動いているか」が読み取れない状態は、変更のときに危険である。 javap による確認とテスト(第15.5節)が必要になる。
③ Kotlin + JPA は避けられている。 @Entity を持つ Kotlin ファイルは0件だった。data class と JPA の非互換(第8.2節)を、そもそも踏まない選択がされている。
18.4 学習の順序
1. Java版 第3章・第11章 コンテナとプロキシの仕組み(前提)
2. 本記事 第3〜5章 final の問題と all-open。最重要
3. 本記事 第6〜7章 コンストラクタ注入と null安全(Kotlin の利点)
4. 本記事 第8〜9章 data class と既定引数の罠
5. 本記事 第12章 @Transactional の Kotlin 特有問題
6. 本記事 第17章 逆引き表を手元に置く
第13章(コルーチン)と第14章(DSL)は、必要になってから読めばよい。
18.5 Java 版との対比まとめ
| 論点 | Java | Kotlin |
|---|---|---|
| プロキシ可能性 | 既定で可 | final により不可。all-open が必須 |
| コンストラクタ注入 | 推奨されるが実運用は21% | 構文上最短。実運用でも主流 |
| 検査例外でコミット | 問題になる(第13.2節) | 起きない(言語に検査例外が無い) |
| 設定値の必須/任意 | 型で表せない | 型で表せる(String / String?) |
| DTO | record(Java 16+) | data class |
| JPA エンティティ | 素直に書ける | data class は使えない |
| Bean Validation | そのまま動く | @field: が必要 |
| テストのモック | Mockito | MockK(final 対応) |
| トランザクションとスレッド | 意識不要 | コルーチンで壊れる |
結論。 Kotlin は Spring と相性が良いが、「all-open が効いていること」を検証する仕組みを持たない限り、黙って壊れる余地が残る。 第15.5節のテストを最初に書くことを推奨する。
付録A: 最小構成のビルドファイル
// settings.gradle.kts
pluginManagement {
repositories { maven { url = uri("https://artifacts.apple.com/libs-release") } }
}
dependencyResolutionManagement {
repositories { maven { url = uri("https://artifacts.apple.com/libs-release") } }
}
rootProject.name = "spring-kotlin-lab"
// build.gradle.kts
plugins {
kotlin("jvm") version "2.0.21"
kotlin("plugin.spring") version "2.0.21" // ★ all-open(必須)
// kotlin("plugin.jpa") version "2.0.21" // JPA を使う場合(no-arg)
}
dependencies {
implementation("org.springframework:spring-context:6.1.14")
implementation("org.springframework:spring-tx:6.1.14")
implementation(kotlin("reflect")) // ★ 必須(第9.2節)
implementation("jakarta.annotation:jakarta.annotation-api:2.1.1")
testImplementation("io.mockk:mockk:1.13.11")
}
kotlin { jvmToolchain(21) }
// 自作アノテーションも all-open の対象にする場合
// allOpen { annotation("com.example.MyAspectTarget") }
付録B: 棚卸しコマンド
# all-open が効いているか(最も確実)— final が無ければ適用されている
for c in $(find build/classes/kotlin/main -name '*.class'); do
javap -p "$c" | head -1 | grep -q 'final class' && echo "final: ${c##*/}"
done
# @Transactional が付いた final メソッド(第5.3節)
for c in $(find build/classes/kotlin/main -name '*.class'); do
javap -p "$c" | grep -E 'final .*\(' | sed "s|^|${c##*/}: |"
done
# open を書いていない @Transactional(all-open 未使用の場合のみ有効)
grep -rn -A1 '@Transactional' --include='*.kt' . | grep 'fun ' | grep -v 'open fun'
# $ のエスケープ漏れ(第17.3節)
grep -rn '@Value("\${' --include='*.kt' . | grep -v '\\\${'
# Bean Validation の @field: 漏れ(第17.4節)
grep -rnE '@(NotBlank|NotNull|NotEmpty|Size|Min|Max|Positive|Email|Pattern)\s+val' --include='*.kt' . \
| grep -v '@field:' | grep -v '@get:'
# data class を @Entity にしている(第17.5節)
grep -rn -B2 '@Entity' --include='*.kt' . | grep 'data class'
# DI コンストラクタの既定引数(第17.6節)
grep -rn -A6 '@Service\|@Component\|@Repository' --include='*.kt' . | grep -E 'val \w+: \w+ = '
# 拡張関数に AOP アノテーション(第17.8節)
grep -rn -A1 '@Transactional\|@Cacheable\|@Async' --include='*.kt' . | grep -E 'fun [A-Z][A-Za-z]*\.'
# suspend + @Transactional(第17.9節)
grep -rn -A1 '@Transactional' --include='*.kt' . | grep 'suspend fun'
# kotlin-reflect が依存にあるか(第17.7節)
grep -rn 'kotlin("reflect")\|kotlin-reflect' --include='*.gradle' --include='*.kts' .
# kotlin-spring プラグインの宣言(convention plugin だと見つからない)(第16.3節)
grep -rn 'plugin.spring\|kotlin-spring\|allOpen' --include='*.gradle' --include='*.kts' .
# lateinit によるフィールド注入の箇所数(第16.5節)
grep -rh -A1 '@Autowired' --include='*.kt' . | grep -c 'lateinit'
付録C: 参考資料
| 資料 | 内容 |
|---|---|
| Spring Framework — Kotlin support | 公式のKotlin対応 |
| Kotlin — all-open compiler plugin | kotlin-spring の実体 |
| Kotlin — no-arg compiler plugin | plugin.jpa の実体 |
| Kotlin — Java interop / platform types | プラットフォーム型 |
| Annotation use-site targets | @field: の規則 |
| Bean definition DSL | 関数型のBean登録 |
| MockK | Kotlin向けモック |
姉妹記事:
| 記事 | 内容 |
|---|---|
Spring with Java-overview-ai_JP.md | コンテナ、DI、ライフサイクル、AOP、トランザクションの実装(前提) |
SpringBoot with Kotlin-overview-ai_JP.md | Bootの自動構成、WebFlux、R2DBC、Security |
Kotlin-overview-ai_JP.md | Kotlin言語そのもの |
Spring with Kotlin-get-started-ai_JP.md | 本記事の内容を手で確かめる手順書 |
本記事の検証環境: Spring Framework 6.1.14 / Kotlin 2.0.21 / AppleJDK 21.0.8 / macOS 26(arm64、Apple M4 Max)。掲載したコンソール出力はすべてこの環境で実測したものである。JARは社内Artifactory(artifacts.apple.com/libs-release)から、KotlinコンパイラはGradle 8.14.1 同梱の kotlin-compiler-embeddable を用いた。SMPの数値は cash-wdp / issuer-products / energy-services / core-smp の各ツリーを走査して得た。