- Spring
Spring Boot Kotlin|REST APIの完成コードとJPAの注意点
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期連続の黒字決算。