Spring Boot は Kotlin を公式にサポートしていて、Java と同じ注釈(@RestController や @Entity)のまま書けます。 ただし Kotlin のクラスは「最初から final(継承できない)」で、「引数なしのコンストラクタ」も作られません。この2点を埋めるのが、次の2つのコンパイラプラグインです。

入れるもの 何をするか 無いとどうなるか
kotlin("plugin.spring")(kotlin-spring) Spring の注釈が付いたクラスとメソッドを open(継承できる)にする @Transactional などのプロキシ(クラスを継承して作る代理オブジェクト)が作れない
kotlin("plugin.jpa")(kotlin-jpa) @Entity などが付いたクラスに、引数なしのコンストラクタを足す JPA(Hibernate)がエンティティを生成できない
allOpen { annotation("jakarta.persistence.Entity") … } エンティティも open にする 遅延読み込み用のプロキシが作れない
jackson-module-kotlin と kotlin-reflect JSON と Kotlin のクラスを相互に変換する Kotlin のコンストラクタで JSON を受け取れない

エンティティは data class にせず、普通の class にします。 data class の equals・hashCode は全部のプロパティから計算されるため、保存して ID が入った瞬間に値が変わります。下で実際に動かした結果を載せています。

確認環境:Spring Boot 4.1.1 と Kotlin 2.3.21 の組み合わせ、依存の名前は、kotlinlang.org のチュートリアル・start.spring.io の設定・docs.spring.io で確かめました。kotlin-spring・kotlin-jpa の効き目と、data class・注釈の付き先の挙動は、Kotlin コンパイラ kotlinc 2.4.20(OpenJDK 21.0.10、Linux)で、Spring・Jakarta の注釈と同じ名前の注釈を自作して再現しました(確認日 2026年10月12日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Boot Kotlin|REST APIの完成コードとJPAの注意点」

プロジェクトの作り方(start.spring.io)

start.spring.io で次のように選ぶと、下の build.gradle.kts とほぼ同じ形のプロジェクトができます。

項目 選ぶもの
Project Gradle - Kotlin
Language Kotlin
Spring Boot 4.1.1(2026年10月12日時点の既定)
Java 21(選べるのは 17・21・25・27)
Dependencies Spring Web、Spring Data JPA、Validation、H2 Database

Language で Kotlin を選ぶと、kotlin-spring プラグインは最初から入っています(Spring Framework のドキュメントにも「start.spring.io は kotlin-spring プラグインを既定で有効にする」とあります)。Spring Boot 4.1 は Kotlin 2.2 以上が必要です。

build.gradle.kts の全文

plugins {
    kotlin("jvm") version "2.3.21"
    kotlin("plugin.spring") version "2.3.21"   // Spring の注釈が付いたクラスを open にする
    kotlin("plugin.jpa") version "2.3.21"      // @Entity などに引数なしコンストラクタを足す
    id("org.springframework.boot") version "4.1.1"
    id("io.spring.dependency-management") version "1.1.7"
}

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

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-webmvc")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("tools.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    runtimeOnly("com.h2database:h2")

    testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
    testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
    testImplementation("com.ninja-squad:springmockk:5.0.1")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

kotlin {
    compilerOptions {
        freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property")
    }
}

// エンティティも open にする(遅延読み込みのプロキシのため)
allOpen {
    annotation("jakarta.persistence.Entity")
    annotation("jakarta.persistence.MappedSuperclass")
    annotation("jakarta.persistence.Embeddable")
}

tasks.withType<Test> {
    useJUnitPlatform()
}
行 意味
spring-boot-starter-webmvc Spring MVC と Tomcat のスターター(Spring Boot 4 のドキュメントでの名前)
tools.jackson.module:jackson-module-kotlin Jackson 3 用の Kotlin モジュール。クラスパスにあれば自動で登録される
kotlin-reflect Spring が Kotlin のコンストラクタや null 許容を読み取るのに使う
-Xjsr305=strict Spring の API に付いた null の注釈を、Kotlin の型(String か String? か)として扱う
-Xannotation-default-target=param-property コンストラクタの引数に付けた注釈を、フィールドにも付ける(後述)
springmockk テストで @MockkBean を使うため。5.x が Spring Framework 7(Boot 4)向け

Spring Boot 4 の依存の名前の変更は、関連記事「Spring Boot 4移行の手順|3から4の変更点一覧とチェックリスト」で扱っています。Gradle の書き方そのものは、関連記事「build.gradle書き方|Groovy/Kotlin DSL対訳と依存設定一覧」で扱っています。

kotlin-spring が要る理由(kotlinc で再現)

Kotlin のクラスとメソッドは、何も書かないと final です。 Spring は @Transactional・@Cacheable・@Configuration などを、クラスを継承した代理(CGLIB プロキシ)で実現します。final のままだと継承できません。

@Service を付けたクラスと、何も付けないクラスを、kotlin-spring(allopen プラグインの spring 設定)の有無で比べました。

@Service
class UserService {
    fun find(id: Long): String = "user$id"
    val name: String = "svc"
}

class PlainClass { fun hello() = "hi" }
コンパイルのしかた UserService PlainClass
プラグインなし クラス final・find も getName も final final
kotlin-spring あり クラス open・find・getName も open final のまま

コンパイラに渡したのは、次の2つの指定です(Gradle では kotlin("plugin.spring") の1行で同じことになります)。

kotlinc Stubs.kt App.kt -d out \
  -Xplugin=$KOTLIN_HOME/lib/allopen-compiler-plugin.jar \
  -P plugin:org.jetbrains.kotlin.allopen:preset=spring

プロキシの代わりに、Java で UserService を継承するクラスを書いてコンパイルしました。プラグインなしでは、継承そのものがコンパイルエラーになりました。

Proxy.java:1: error: cannot inherit from final UserService
public class Proxy extends demo.UserService {
                               ^
Proxy.java:2: error: find(long) in Proxy cannot override find(long) in UserService
  overridden method is final

kotlin-spring ありでコンパイルしたクラスに対しては、同じ Java のコードがエラーなくコンパイルできました。

@Service には open が付きましたが、注釈の定義に @Component が付いているからです(メタ注釈)。kotlinlang.org のドキュメントでは、kotlin-spring が対象にする注釈は次の通りです。

直接の対象 メタ注釈で対象になるもの
@Component・@Async・@Transactional・@Cacheable・@SpringBootTest @Configuration・@Controller・@RestController・@Service・@Repository

PlainClass のように注釈の無いクラスは final のままです。自作の注釈でプロキシを作る場合は、allOpen { annotation("…") } に足します。@Transactional が効かないときの見分け方は、関連記事「Spring @Transactionalが効かない原因8選|ロールバックの直し方」で扱っています。

kotlin-jpa が要る理由と、初期値の落とし穴(kotlinc で再現)

JPA(Hibernate)は、エンティティを「引数なしのコンストラクタ」で作ってから、フィールドに値を入れます。 Hibernate のドキュメントでは、エンティティの条件は「final でないクラス」で「private でない、引数なしのコンストラクタを持つ」ことです。Kotlin で class Book(var title: String, var price: Int) と書くと、引数ありのコンストラクタしかありません。

次のクラスを、Hibernate と同じようにリフレクションで getDeclaredConstructor().newInstance() しました。

@Entity
class Book(
    var title: String,
    var price: Int,
) {
    var id: Long? = null
    val tags: MutableList<String> = mutableListOf()
    var status: String = "DRAFT"
}
コンパイルのしかた 結果
プラグインなし java.lang.NoSuchMethodException: demo.Book.<init>()
kotlin-jpa(noarg の jpa 設定) 生成できた。title=null price=0 id=null tags=null status=null
kotlin-jpa + invokeInitializers = true 生成できた。title=null price=0 id=null tags=[] status=DRAFT

kotlin-jpa ありでは、javap で見ると public demo.Book(); が増えていました。

注目したいのは2行目です。 tags は MutableList<String>(null にならない型)で、mutableListOf() という初期値も書いてあるのに、null でした。足されたコンストラクタは、既定ではプロパティの初期値を実行しないためです(kotlinlang.org のドキュメントにも「invokeInitializers は既定で無効」とあります)。

DB の列に対応するフィールドは、Hibernate が読み込んだ値をあとから入れます。困るのは、@Transient などで DB と対応しないフィールドに初期値を書いた場合です。null になり、使った瞬間に NullPointerException になります。初期値も実行したいときは、次のように設定します。

noArg {
    invokeInitializers = true
}

kotlin-jpa が対象にするのは @Entity・@Embeddable・@MappedSuperclass の3つです。ドキュメントでは、足されるコンストラクタは Java や Kotlin のコードからは直接呼べず、リフレクションからだけ呼べるものとされています。

REST API の完成コード

ユーザーの登録・一覧・取得・更新・削除をする API です。パッケージは com.example.demo です。

src/main/kotlin/com/example/demo/
├── DemoApplication.kt
├── user/
│   ├── User.kt              ← Entity
│   ├── UserRepository.kt    ← Repository
│   ├── UserService.kt       ← Service
│   ├── UserController.kt    ← Controller
│   └── UserDto.kt           ← リクエストとレスポンスの形
└── common/
    └── ApiExceptionHandler.kt
src/main/resources/application.properties

DemoApplication.kt

package com.example.demo

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class DemoApplication

fun main(args: Array<String>) {
    runApplication<DemoApplication>(*args)
}

runApplication は、Java の SpringApplication.run(DemoApplication.class, args) に当たる Kotlin 用の関数です。

User.kt(Entity)

package com.example.demo.user

import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table

@Entity
@Table(name = "users")
class User(
    @Column(nullable = false, length = 50)
    var name: String,

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

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
) {
    // ID で比べる。保存前(id が null)のものは、同じインスタンスだけを等しいとみなす
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is User) return false
        return id != null && id == other.id
    }

    // 保存の前後で変わらない値にする
    override fun hashCode(): Int = User::class.hashCode()

    // 関連を持ったときに無限再帰しないよう、必要な項目だけ出す
    override fun toString(): String = "User(id=$id, name=$name)"
}
  • id は、保存前は無いので Long? にして、引数の最後に = null で置きます。User(name = "Ito", email = "…") と書けます。
  • @Table(name = "users") は、H2 で USER が予約語のためです。
  • equals・hashCode をこう書く理由は、下の「data class をエンティティにしない理由」で、実際の動きと一緒に説明します。

UserRepository.kt(Repository)

package com.example.demo.user

import org.springframework.data.jpa.repository.JpaRepository

interface UserRepository : JpaRepository<User, Long> {
    fun existsByEmail(email: String): Boolean
}

メソッド名からクエリを作るしくみは Java と同じです。命名の一覧は、関連記事「Spring Data JPA使い方|クエリメソッド命名一覧と@Query実例」で扱っています。

UserDto.kt(リクエストとレスポンス)

package com.example.demo.user

import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size

data class UserRequest(
    @field:NotBlank(message = "名前を入力します")
    @field:Size(max = 50, message = "名前は50文字までです")
    val name: String?,

    @field:NotBlank(message = "メールアドレスを入力します")
    @field:Email(message = "メールアドレスの形ではありません")
    val email: String?,
)

data class UserResponse(
    val id: Long,
    val name: String,
    val email: String,
)

fun User.toResponse() = UserResponse(
    id = checkNotNull(id) { "保存前のエンティティです" },
    name = name,
    email = email,
)
  • DTO(受け渡し用のクラス)は data class で問題ありません。 data class を避けるのはエンティティだけです。
  • リクエストのプロパティを String?(null 許容)にしているのは、項目が無い JSON も、いったん受け取ってから @NotBlank で「どの項目が足りないか」を返すためです(理由は後述)。
  • fun User.toResponse() は拡張関数です。エンティティに API の形を持ち込まずに変換を書けます。

UserService.kt(Service)

package com.example.demo.user

import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

class UserNotFoundException(id: Long) : RuntimeException("ID $id のユーザーはありません")
class DuplicateEmailException(email: String) : RuntimeException("$email は登録済みです")

@Service
@Transactional(readOnly = true)
class UserService(
    private val userRepository: UserRepository,
) {
    fun findAll(): List<UserResponse> =
        userRepository.findAll().map { it.toResponse() }

    fun findById(id: Long): UserResponse =
        getUser(id).toResponse()

    @Transactional
    fun create(request: UserRequest): UserResponse {
        // @Valid を通った後なので name・email は null ではない
        val email = request.email!!
        if (userRepository.existsByEmail(email)) throw DuplicateEmailException(email)
        val saved = userRepository.save(User(name = request.name!!, email = email))
        return saved.toResponse()
    }

    @Transactional
    fun update(id: Long, request: UserRequest): UserResponse {
        val user = getUser(id)
        user.name = request.name!!
        user.email = request.email!!
        return user.toResponse()   // トランザクションの終わりに UPDATE される
    }

    @Transactional
    fun delete(id: Long) {
        userRepository.delete(getUser(id))
    }

    private fun getUser(id: Long): User =
        userRepository.findByIdOrNull(id) ?: throw UserNotFoundException(id)
}
  • findByIdOrNull は Spring Data が用意している Kotlin 用の拡張関数です。Java の findById(id).orElseThrow(...) が、?: throw で1行になります。
  • コンストラクタの引数に書いたものが、そのまま注入されます(コンストラクタが1つなら @Autowired は不要)。
  • @Transactional が付いたクラスは kotlin-spring で open になるので、open を書かずにプロキシが作れます。

UserController.kt(Controller)

package com.example.demo.user

import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import java.net.URI

@RestController
@RequestMapping("/api/users")
class UserController(
    private val userService: UserService,
) {
    @GetMapping
    fun list(): List<UserResponse> = userService.findAll()

    @GetMapping("/{id}")
    fun get(@PathVariable id: Long): UserResponse = userService.findById(id)

    @PostMapping
    fun create(@Valid @RequestBody request: UserRequest): ResponseEntity<UserResponse> {
        val created = userService.create(request)
        return ResponseEntity.created(URI.create("/api/users/${created.id}")).body(created)
    }

    @PutMapping("/{id}")
    fun update(@PathVariable id: Long, @Valid @RequestBody request: UserRequest): UserResponse =
        userService.update(id, request)

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

ApiExceptionHandler.kt(エラーの形をそろえる)

package com.example.demo.common

import com.example.demo.user.DuplicateEmailException
import com.example.demo.user.UserNotFoundException
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice

@RestControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler(UserNotFoundException::class)
    fun handleNotFound(e: UserNotFoundException): ProblemDetail =
        ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.message ?: "")

    @ExceptionHandler(DuplicateEmailException::class)
    fun handleDuplicate(e: DuplicateEmailException): ProblemDetail =
        ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.message ?: "")

    @ExceptionHandler(MethodArgumentNotValidException::class)
    fun handleInvalid(e: MethodArgumentNotValidException): ProblemDetail =
        ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "入力に誤りがあります").apply {
            setProperty("errors", e.bindingResult.fieldErrors.associate { it.field to it.defaultMessage })
        }
}

ProblemDetail は、エラーを RFC 9457 の形(type・title・status・detail)の JSON で返すための Spring のクラスです。@ControllerAdvice の書き方の詳細は、関連記事「Spring Boot例外処理|@ControllerAdvice完全版」で扱っています。

application.properties

spring.datasource.url=jdbc:h2:mem:demo
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.open-in-view=false

このコードが返すステータス

リクエスト 返すもの
GET /api/users 200・一覧
GET /api/users/1(あり) 200・1件
GET /api/users/99(なし) 404・UserNotFoundException の ProblemDetail
POST /api/users(正しい入力) 201・Location: /api/users/{id} と登録した内容
POST /api/users(name が空など) 400・errors に項目ごとのメッセージ
POST /api/users(登録済みのメール) 409・DuplicateEmailException の ProblemDetail
DELETE /api/users/1 204・本文なし
./gradlew bootRun

curl -i -X POST localhost:8080/api/users \
  -H 'Content-Type: application/json' \
  -d '{"name":"Ito","email":"ito@example.com"}'

Java で同じ API を作る流れは、関連記事「Spring BootでREST APIを作る|入力チェック・エラー処理・テストまで」で扱っています。

data class をエンティティにしない理由(kotlinc で再現)

data class は equals・hashCode・toString を、コンストラクタの全プロパティから自動で作ります。 DTO には便利ですが、エンティティでは次の3つの問題が出ました。Hibernate を使わず、「保存で ID が入る」動きを id に値を入れて再現しています。

data class BookD(var title: String, var id: Long? = null)

① ID が入ると hashCode が変わり、Set から消える

val b = BookD("Kotlin")
val set = hashSetOf(b)
b.id = 1L   // save() で ID が入った想定
set.contains(b)
before save: hash=1131585279 contains=true
after  save: hash=1131585280 contains=false size=1
after remove size=1

ID が入った後は、同じオブジェクトなのに contains が false になり、remove もできませんでした。 @OneToMany の Set に入れたエンティティを保存すると、この状態になります。

② 中身が同じ新規データが、1件にまとめられる

hashSetOf(BookD("Kotlin"), BookD("Kotlin")).size   // 1

まだ ID の無い2件が equals で等しいと判定され、Set のサイズが 1 になりました。別々に登録したい2件が、1件に減ります。

③ 双方向の関連で toString が無限に続く

著者(Author)が本の一覧を持ち、本(data class)が著者を持つ形で toString を呼ぶと、StackOverflowError になりました。data class の toString が関連先を出し、関連先がまた元を出すためです。ログに出しただけで落ちます。JPA では、遅延読み込みの関連を toString で触ると、意図しない SQL が出ることもあります。

普通の class + ID で比べると、3つとも起きない

上の User と同じ書き方(ID で equals、hashCode は固定値)で同じことをしました。

確かめたこと data class 普通の class + ID で比較
ID が入った後に contains false true
中身が同じ新規2件を Set へ size=1 size=2
同じ ID の2つを == true true
双方向の関連で toString StackOverflowError 自分で書くので起きない

hashCode を固定値にするのは、保存の前後で値を変えないためです。 同じクラスのエンティティが同じバケットに入るので、何万件も Set に入れる場面では遅くなります。エンティティを大量に Set へ入れる設計自体を避けるのが無難です。

data class の equals と copy のしくみは、関連記事「Kotlin data classの使い方|copy・equalsとclass差」で扱っています。

用途 向いている書き方
エンティティ(@Entity) 普通の class。var のプロパティ、ID で equals
リクエスト・レスポンスの DTO data class
値オブジェクト(@Embeddable) 普通の class(kotlin-jpa と allOpen の対象)

null 安全とバリデーション:@field: は要るか

注釈は「引数」「フィールド」のどちらに付くか(kotlinc で再現)

Kotlin のコンストラクタで @NotBlank val name: String と書くと、注釈は コンストラクタの引数・プロパティ・フィールド のどこにでも付けられます。Bean Validation(Hibernate Validator)が見るのはフィールド(または getter)です。引数にしか付かないと、検証されません。

jakarta.validation の @NotBlank と同じ @Target を持つ注釈を作り、どこに付くかをリフレクションで確かめました。

class CreateUserRequest(
    @NotBlank val name: String,
    @field:NotBlank val email: String,
)
コンパイルの設定 @NotBlank val name @field:NotBlank val email
Kotlin 2.4 の既定 引数とフィールドの両方 フィールドだけ
-language-version=2.3(2.2・2.3 の既定) 引数だけ(警告が出る) フィールドだけ
-language-version=2.3 + -Xannotation-default-target=param-property 引数とフィールドの両方 フィールドだけ
-language-version=2.1(2.1 以前の既定) 引数だけ(警告なし) フィールドだけ

Kotlin 2.3 の設定でコンパイルしたときの警告です。

warning: this annotation is currently applied to the value parameter only, but in the future it will also be applied to field.
- To opt in to applying to both value parameter and field, add '-Xannotation-default-target=param-property' to your compiler arguments.
- To keep applying to the value parameter only, use the '@param:' annotation target.

kotlinc のヘルプにも、既定は「2.4 以上は param-property、2.2・2.3 は first-only-warn、2.1 以前は first-only」と書かれています。

まとめると、次のどちらかで検証が効きます。

  • @field:NotBlank と明示する(Kotlin の版に左右されない)
  • Kotlin 2.2・2.3 なら -Xannotation-default-target=param-property を付ける(上の build.gradle.kts と、Spring のチュートリアルの設定)。Kotlin 2.4 以上は既定でこの動き

この記事のコードは、版をまたいでも読み違えないように @field: を明示しています。バリデーションの注釈の一覧は、関連記事「Spring Bootバリデーション一覧|@Validと@Validated」で扱っています。

リクエストのプロパティを String? にする理由

書き方 name が無い JSON を送ると 向いている場面
val name: String JSON から作る段階で失敗し、400(HttpMessageNotReadableException)。どの項目かは返らない 項目ごとのエラー表示が要らない API
@field:NotBlank val name: String? いったん null で作られ、検証で 400(MethodArgumentNotValidException)。項目ごとのメッセージを返せる 画面に項目ごとのエラーを出す API

null 許容にすると、検証の後の request.name!! のように !! が要ります。検証を通った後なので null は来ませんが、どちらを取るかは API の使われ方で決めます。Kotlin の ?.・?:・!! の違いは、関連記事「Kotlin null安全の使い方|?. ?: !! letの違い早見表」で扱っています。

Spring の API の null は型で分かる

Spring の多くのプロジェクトは JSpecify の注釈で null の可否を示していて、ドキュメントでは「Kotlin 2.1 から org.jspecify.annotations の注釈を厳密に扱う」とされています。たとえば null を返しうる Spring のメソッドは、Kotlin では T? として見えます。

Java → Kotlin 書き方対応表(Spring Boot でよく使うもの)

書きたいこと Java Kotlin
起動 SpringApplication.run(App.class, args) runApplication<App>(*args)
依存の注入 コンストラクタ+private final class S(private val repo: Repo)
フィールド注入 @Autowired private Repo repo; @Autowired lateinit var repo: Repo
1件取得・無ければ例外 findById(id).orElseThrow(() -> new X()) findByIdOrNull(id) ?: throw X()
配列の注釈属性 method = RequestMethod.GET method = [RequestMethod.GET]
設定値の埋め込み @Value("${app.name}") @Value("\${app.name}")($ をエスケープ)
クラスの参照 UserController.class UserController::class
検証の注釈 @NotBlank private String name; @field:NotBlank val name: String?
Lombok の @Getter・@Setter 注釈で生成 var name: String(プロパティ)
Lombok の @Data(DTO) 注釈で生成 data class
ロガー LoggerFactory.getLogger(X.class) LoggerFactory.getLogger(javaClass)
設定クラス @ConfigurationProperties+getter/setter @ConfigurationProperties("app") data class AppProps(val name: String)
Bean の定義 @Bean public X x() { return new X(); } @Bean fun x() = X()
テストのモック when(s.find(1L)).thenReturn(u) every { s.find(1) } returns u(MockK)
MockMvc perform(get("/x")).andExpect(status().isOk()) mockMvc.get("/x").andExpect { status { isOk() } }
例外のテスト assertThrows(X.class, () -> …) assertFailsWith<X> { … }
@BeforeAll static メソッド @TestInstance(PER_CLASS) を付けて普通のメソッド

@RequestMapping の配列属性は、Kotlin では [ ] で囲みます。Spring Framework のドキュメントの例では @RequestMapping("/toys", method = [RequestMethod.GET]) です。よく使う method は、@GetMapping などの短縮形で置き換えられます。

言語そのものの対応表は、関連記事「Kotlin と Java の違い 書き方対応表とコード比較30項目」で扱っています。

テスト:MockMvc の Kotlin DSL と SpringMockK

Controller のテスト(@WebMvcTest)

package com.example.demo.user

import com.ninjasquad.springmockk.MockkBean
import io.mockk.every
import io.mockk.verify
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.post

@WebMvcTest(UserController::class)
class UserControllerTest(
    @Autowired private val mockMvc: MockMvc,
) {
    @MockkBean
    private lateinit var userService: UserService

    @Test
    fun `1件取得すると200で名前が返る`() {
        every { userService.findById(1) } returns UserResponse(1, "Ito", "ito@example.com")

        mockMvc.get("/api/users/1").andExpect {
            status { isOk() }
            content { contentType(MediaType.APPLICATION_JSON) }
            jsonPath("$.name") { value("Ito") }
        }
    }

    @Test
    fun `無いIDは404`() {
        every { userService.findById(99) } throws UserNotFoundException(99)

        mockMvc.get("/api/users/99").andExpect {
            status { isNotFound() }
        }
    }

    @Test
    fun `入力が空なら400で項目ごとのエラーが返りServiceは呼ばれない`() {
        mockMvc.post("/api/users") {
            contentType = MediaType.APPLICATION_JSON
            content = """{"name":"","email":"not-mail"}"""
        }.andExpect {
            status { isBadRequest() }
            jsonPath("$.errors.name") { exists() }
            jsonPath("$.errors.email") { exists() }
        }

        verify(exactly = 0) { userService.create(any()) }
    }
}
ポイント 説明
mockMvc.get(…)・mockMvc.post(…) { … } org.springframework.test.web.servlet の Kotlin 拡張関数。static import が要らない
andExpect { status { isOk() } } 期待値もブロックで書く
fun `日本語のテスト名`() バッククォートで囲むと、空白や日本語を名前に使える
@MockkBean SpringMockK の注釈。Mockito の @MockitoBean に当たる
"$.name" $ の後が . なので文字列テンプレートにならない。"$name" と書くと変数の埋め込みになる

Mockito を使う場合は、@MockkBean の代わりに Spring の @MockitoBean を使い、every { … } returns … を `when`(…).thenReturn(…) に置き換えます(Kotlin では when が予約語のため、バッククォートで囲みます)。Spring Boot のドキュメントは、Kotlin のクラスのモックには MockK を推奨しています。

Repository のテスト(@DataJpaTest)

package com.example.demo.user

import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest
import kotlin.test.assertFalse
import kotlin.test.assertTrue

@DataJpaTest
class UserRepositoryTest(
    @Autowired private val userRepository: UserRepository,
) {
    @Test
    fun `登録したメールはexistsByEmailでtrue`() {
        userRepository.save(User(name = "Ito", email = "ito@example.com"))

        assertTrue(userRepository.existsByEmail("ito@example.com"))
        assertFalse(userRepository.existsByEmail("sato@example.com"))
    }
}

@DataJpaTest は、Javadoc によると、組み込みのインメモリ DB を使い、テストごとにトランザクションをロールバックします。@WebMvcTest との使い分けは、関連記事「Spring Bootテスト入門|@WebMvcTestとMockMvcの使い分け」で扱っています。

よくあるつまずき

症状 原因 直し方
@Transactional・@Configuration のクラスで起動に失敗する クラスが final(kotlin-spring が無い) kotlin("plugin.spring") を入れる
エンティティの生成で「引数なしコンストラクタが無い」例外 kotlin-jpa が無い kotlin("plugin.jpa") を入れる
@Transient などのフィールドが null で NullPointerException kotlin-jpa のコンストラクタは初期値を実行しない noArg { invokeInitializers = true } か、初期値に頼らない
@ManyToOne(fetch = LAZY) が遅延読み込みにならないことがある エンティティが final で、継承で作るプロキシが作れない allOpen に jakarta.persistence.Entity などを足す
@NotBlank を付けたのに検証されない 注釈が引数にしか付いていない(Kotlin 2.1 以前など) @field:NotBlank と書く
必須項目が無い JSON で、項目ごとのエラーが返らない 非 null 型のため JSON の変換で失敗している String? + @field:NotBlank
@Value("${app.name}") がコンパイルエラー $ が文字列テンプレートと解釈される "\${app.name}"
保存したエンティティが Set から消える data class の hashCode が ID で変わる 普通の class + ID で equals

よくある質問

Q. Maven でも同じように書けますか。 A. 書けます。kotlinlang.org のドキュメントでは、kotlin-maven-plugin の compilerPlugins に spring と jpa を並べ、kotlin-maven-allopen と kotlin-maven-noarg を依存に足す形です。start.spring.io で Project を Maven にすると、その形で作られます。

Q. Spring Data JDBC なら kotlin-jpa は要りますか。 A. Spring Framework のドキュメントでは、Spring Data のオブジェクトマッピングを使うモジュールは Kotlin の不変クラス(val だけのクラス)を扱えて、no-arg プラグインは要らないとされています。kotlinlang.org のチュートリアルも、Spring Data JDBC で data class Message(val text: String, @Id val id: String? = null) を使っています。kotlin-jpa が要るのは JPA(Hibernate)のときです。

Q. エンティティのプロパティは val にできますか。 A. JPA は、生成した後でフィールドに値を入れて使います。val は final のフィールドになり、コードから値を変えることもできません。この記事の User は、プロパティをすべて var にしています。

Q. N+1 問題は Kotlin でも起きますか。 A. 起きます。JPA のしくみは Java と同じです。対策は、関連記事「JPAのN+1問題の解決|JOIN FETCH等4手法の比較とコード全文」で扱っています。

まとめ

  • Spring Boot 4.1 は Kotlin 2.2 以上。start.spring.io で Language を Kotlin にすると kotlin-spring が入る
  • kotlin-spring:Spring の注釈が付いたクラスを open に。無いと Java からの継承もコンパイルエラーになった
  • kotlin-jpa:引数なしコンストラクタを足す。無いと NoSuchMethodException。初期値は実行されず、非 null 型でも null になった
  • エンティティは 普通の class + ID で equals。data class は ID が入ると Set から消え、双方向の関連で StackOverflowError
  • 検証の注釈は @field:。Kotlin 2.3 以前の既定では引数にしか付かなかった
  • テストは MockMvc の Kotlin DSL と SpringMockK の @MockkBean

参考・出典

確認日はいずれも 2026年10月12日です。

  • Spring Boot ドキュメント「Kotlin Support」:https://docs.spring.io/spring-boot/reference/features/kotlin.html
  • Spring Boot ドキュメント「Build Systems」(Starters):https://docs.spring.io/spring-boot/reference/using/build-systems.html
  • Spring Boot API「DataJpaTest」:https://docs.spring.io/spring-boot/api/java/org/springframework/boot/data/jpa/test/autoconfigure/DataJpaTest.html
  • Spring Framework ドキュメント「Spring Projects in Kotlin」:https://docs.spring.io/spring-framework/reference/languages/kotlin/spring-projects-in.html
  • Spring Framework ドキュメント「Web」(MockMvc DSL):https://docs.spring.io/spring-framework/reference/languages/kotlin/web.html
  • Spring ガイド「Building web applications with Spring Boot and Kotlin」:https://spring.io/guides/tutorials/spring-boot-kotlin
  • start.spring.io:https://start.spring.io/
  • Kotlin ドキュメント「Create a Spring Boot project with Kotlin」:https://kotlinlang.org/docs/jvm-create-project-with-spring-boot.html
  • Kotlin ドキュメント「All-open compiler plugin」:https://kotlinlang.org/docs/all-open-plugin.html
  • Kotlin ドキュメント「No-arg compiler plugin」:https://kotlinlang.org/docs/no-arg-plugin.html
  • Kotlin ドキュメント「Annotations」(use-site targets):https://kotlinlang.org/docs/annotations.html
  • Hibernate ORM 7.0「An Introduction to Hibernate 7」(Entity classes):https://docs.hibernate.org/orm/7.0/introduction/html_single/
  • SpringMockK(GitHub):https://github.com/Ninja-Squad/springmockk

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Boot Kotlin|REST APIの完成コードとJPAの注意点」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?