Spring with Kotlin

Spring Framework 徹底解説(Kotlin)— final との戦いと、その先の利点

目次

  1. KotlinでSpringを使うとは何か
  2. 検証環境
  3. 最大の障壁 — Kotlinのクラスは final である
  4. kotlin-spring(all-open)プラグイン
  5. final メソッドは黙って織り込まれない
  6. コンストラクタ注入が自然になる
  7. null安全とSpringの境界
  8. data class の落とし穴
  9. 既定引数とコンストラクタの多重化
  10. lateinit と遅延初期化
  11. @ConfigurationProperties と不変性
  12. @Transactional のKotlin特有問題
  13. コルーチンとトランザクション
  14. 拡張関数・トップレベル関数とBean
  15. テスト
  16. 実運用での姿 — SMPを実測する
  17. 落とし穴
  18. まとめ

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.mdBoot の自動構成、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-coretrove4j を含む依存一式が必要である。

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() { } }
プラグインなしプラグインあり
OrderServicefinaltruefalse
place()finaltruefalse
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 になる

getPropertygetRequiredProperty の差が型に出る。 これは 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不要(引数で解決する)
@ConfigurationPropertiesBoot 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 varby 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 上のバイトコードは同じなので、TransactionInterceptorIOException を検査例外として扱う。 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 のトランザクション同期は ThreadLocalTransactionSynchronizationManager)で実装されている。

@Transactional
  → TransactionSynchronizationManager に「現在のトランザクション」を ThreadLocal で束縛
  → 同じスレッドで実行される限り、参加できる

コルーチンはスレッドを移動しうるため、これが壊れる。

@Transactional
suspend fun process() {
    repo.save(a)                          // スレッド1
    withContext(Dispatchers.IO) {
        repo.save(b)                      // ★別スレッド。同じトランザクションではない
    }
}

13.2 Spring 5.2 以降は suspend に対応している

@Transactionalsuspend 関数をサポートする(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 など)効く効かない(関数なので)
テスト@WebMvcTestWebTestClient

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.springframework14,857784(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 側と比較すると差が際立つ。

フィールド注入比率
Java6,888 箇所78.6%
Kotlin30 箇所ごく少数

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 fun600
data class721
mockk537
companion object436
lateinit var260
kotlinx.coroutines212
@Service1712,654
@Component1053,131
Mono<712,047
@Autowired624,837
@Configuration611,172
@Bean55960
open class36
@RestController30325
@Qualifier301,349
sealed interface28
open fun25
sealed class22
@Lazy / @Primary各181,239 / 227
Dispatchers15
@Value131,151
withContext13
@Transactional12512
Flux<12289
@EnableConfigurationProperties12266
@Repository11181
@ConfigurationProperties8369
@SpringBootApplication7123
awaitSingle6
@field:5
runApplication5
ObjectProvider4165
@Validated2271
@Aspect / @Around018
@Entity0335
CoroutineCrudRepository0

この表から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 実例集 — @Transactionalfinal の実際

Kotlin の @Transactional は12ファイルしかない。 そのすべてを調べた結果が興味深い。

ファイルクラス宣言メソッド
PreferencePersistenceServiceImpl.ktopen classoverride fun
QuoteOrderPersistenceServiceImpl.ktopen classoverride fun
PaymentRecordPersistenceServiceImpl.ktopen classoverride fun
RegisteredDevicePersistenceServiceImpl.ktopen classoverride fun
StoredValueAssociatedAccountPersistenceServiceImpl.ktopen classoverride fun
AssociatedAccountPreferencePersistenceServiceImpl.ktopen classoverride fun
StoredValueAccountAliasPersistenceServiceImpl.ktopen classoverride fun
BusinessAccountPersistenceServiceImpl.ktopen classoverride fun
ComponentTestSpringConfig.ktopen class
StoredValueAccountPersistenceServiceImpl.ktclass(open なし)override fun
MigrateDataServiceImpl.ktclass(open なし)override fun
UpdatePaymentMetadataServiceImpl.ktclass(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.workfinal=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 実例集 — @Beanopen を付ける防御

@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 ApplicationFactoryopen ではない。 @Configurationopen を付けていないため、all-open プラグインが適用されていなければ起動に失敗する(第3.3節①)。起動している事実が適用の証拠である。

@Bean("isLocal") open fun — メソッドにだけ open を付けている箇所がある。 Kotlin 全体で @Beanopen 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,
)

📎 RequestHeader.kt#L1-L22

第8.3節で警告した @field: が正しく付いている。 対比が明確である。

@field:Size(max = 64) val requestId: String                  // ✅ Validation が見る
@DataClassification([...]) var seId: String? = null          // ← 自作アノテーションは対象指定なし

自作の @DataClassification には @field: を付けていない。 これはValidation ではなく静的解析・ログ用のアノテーションなので、パラメータに付いていれば十分と判断していると読める。「どのアノテーションに対象指定が必要か」を区別できている。

加えて valvar を使い分けている。 requestId / conversationId は不変、seId / testOverrides は可変である。既定引数(= "requestIdNotPresent")も併用しており、第9章で述べた「DTO の既定引数は積極的に使う」に沿っている。

@ConfigurationPropertiesdata class で受ける
@ConfigurationProperties("config")
data class GridDataServiceProperties(
    val emissionsDataSourceConfig: EmissionsDataSourceConfig,

📎 GridDataServiceProperties.kt#L121-L123

@ConfigurationPropertiesScan
class Application {
    @PostConstruct

📎 Application.kt#L1-L20

第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 との対比

論点JavaKotlin
非同期モデルReactorMono< 2,047)コルーチンsuspend fun 600、Mono< は71)
注入方式フィールド注入 78.6%コンストラクタ注入が主流lateinit+@Autowired は62、大半がテスト)
@TransactionalrollbackFor940件中92件(9.8%12件すべてで適切に指定[...::class]
自作アスペクト18件0件
JPA@Entity 335件0件plugin.jpa も0件)
final 対策不要9件が open class を手書き、3件がプラグイン依存
DTOrecord を採用data class 721件
Validation@Valid 953件@field: 5件(使用箇所自体が少ない)
モックMockitoMockK 537件
ルーターDSLRouterFunction 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クラスが finalall-open プラグイン17.1
@Transactional が効かない(起動は成功)メソッドが finalall-open / open17.2
@Transactional が効かない(open は付いている)自己呼び出しクラスを分ける12.2
AopConfigExceptionfinal クラスに CGLIBall-open3.2
@Value が式として解釈される$ のエスケープ漏れ\${...}17.3
Bean Validation が効かない@field: の欠落@field:NotBlank17.4
JPA でエンティティを作れない引数なしコンストラクタが無いplugin.jpa / 通常のクラス17.5
NoSuchBeanDefinitionException(任意の依存)既定引数に期待したnull許容型で受ける17.6
No primary or single unique constructorkotlin-reflect が無い依存に追加17.7
拡張関数で AOP が効かない静的メソッドメンバ関数にする17.8
コルーチンでトランザクションが分裂するThreadLocal境界をコルーチン外へ17.9
UninitializedPropertyAccessExceptionlateinit の未初期化注入順序を確認10.1
実行時 NPE(Java 呼び出し後)プラットフォーム型null許容型で受ける17.11
テストが遅い@MockBean の多用MockK + コンストラクタ17.12
LazyInitializationExceptiondata classequals/toStringID ベースに書き換える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 版との対比まとめ

論点JavaKotlin
プロキシ可能性既定で可final により不可。all-open が必須
コンストラクタ注入推奨されるが実運用は21%構文上最短。実運用でも主流
検査例外でコミット問題になる(第13.2節)起きない(言語に検査例外が無い)
設定値の必須/任意型で表せない型で表せるString / String?
DTOrecord(Java 16+)data class
JPA エンティティ素直に書けるdata class は使えない
Bean Validationそのまま動く@field: が必要
テストのモックMockitoMockKfinal 対応)
トランザクションとスレッド意識不要コルーチンで壊れる

結論。 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 pluginkotlin-spring の実体
Kotlin — no-arg compiler pluginplugin.jpa の実体
Kotlin — Java interop / platform typesプラットフォーム型
Annotation use-site targets@field: の規則
Bean definition DSL関数型のBean登録
MockKKotlin向けモック

姉妹記事:

記事内容
Spring with Java-overview-ai_JP.mdコンテナ、DI、ライフサイクル、AOP、トランザクションの実装(前提)
SpringBoot with Kotlin-overview-ai_JP.mdBootの自動構成、WebFlux、R2DBC、Security
Kotlin-overview-ai_JP.mdKotlin言語そのもの
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 の各ツリーを走査して得た。