kotlin-spring and kotlin-jpa Gradle/Maven plugins, convert files one at a time starting with tests and DTOs, and let Java and Kotlin coexist in the same project until migration is complete.Code > Convert Java File to Kotlin File (Ctrl+Alt+Shift+K) for automated conversion, then manual cleanup for idiomatic Kotlin.data class -- use regular classes with var properties, because data class generates broken equals/hashCode for mutable entities and prevents lazy-loading proxies.data class for JPA entities -- data class generates equals/hashCode using ALL fields, breaking identity tracking when fields change after persist. Also prevents Hibernate lazy-loading proxies because data classes are final. Use regular classes with ID-based equals/hashCode. [src4]kotlin-spring and kotlin-jpa plugins are mandatory -- Without kotlin-spring (allopen), all Kotlin classes are final and Spring cannot create CGLIB proxies, causing BeanCreationException. Without kotlin-jpa (noarg), JPA cannot instantiate entities, causing InstantiationException. [src1]compile phase before maven-compiler-plugin, otherwise Java code cannot reference Kotlin classes. [src7]| Java Spring Pattern | Kotlin Equivalent | Example |
|---|---|---|
public class UserService | class UserService (open via plugin) | kotlin-spring plugin auto-opens @Service classes |
| POJO with getters/setters | data class UserDto(val name: String, val email: String) | DTOs become one-line data classes |
@Autowired field injection | Constructor injection (default) | class UserService(private val repo: UserRepository) |
Optional<User> return | User? nullable type | fun findById(id: Long): User? |
Stream.map().filter().collect() | list.map { }.filter { } | Native collection operators, no .collect() needed |
try { } catch (Exception e) { } | try { } catch (e: Exception) { } | Also: runCatching { }.getOrElse { default } |
@Entity class with no-arg ctor | @Entity class + kotlin-jpa plugin | Plugin generates synthetic no-arg constructor |
@RequestParam required=false | @RequestParam name: String? | Nullable type replaces required = false |
new ResponseEntity<>(body, OK) | ResponseEntity.ok(body) | Or use @ResponseBody with direct return |
@Value("${prop}") field | @Value("\${prop}") (escaped) | Kotlin string interpolation requires \$ escaping |
static methods/constants | companion object | companion object { const val MAX_RETRIES = 3 } |
Lombok @Data | data class | No Lombok needed -- Kotlin has it built in |
interface FooRepository extends JpaRepository<Foo, Long> | interface FooRepository : JpaRepository<Foo, Long> | Colon replaces extends/implements |
Collections.unmodifiableList(list) | list.toList() or declare as List<T> | Kotlin List is already read-only by default |
Bean validation @NotNull | Non-nullable type val name: String | Kotlin null safety replaces many annotations |
org.springframework.lang.Nullable | org.jspecify.annotations.Nullable | Spring Boot 4.0 migrated to JSpecify annotations [src8] |
START
+-- Is the project using Gradle?
| +-- YES -> Add kotlin("plugin.spring") + kotlin("plugin.jpa") to plugins block
| +-- NO (Maven) -> Add kotlin-maven-plugin with spring + jpa compiler plugins
+-- Does the project use Lombok?
| +-- YES -> Remove Lombok first: convert @Data to records or plain classes, then to Kotlin data classes
| +-- NO v
+-- Does the project have good test coverage?
| +-- YES -> Convert tests to Kotlin first (safe, validates interop)
| +-- NO -> Write Kotlin tests for existing Java code first, then convert production code
+-- Does the project use JPA/Hibernate entities?
| +-- YES -> Do NOT use data class for entities. Use regular class + var properties + kotlin-jpa plugin
| +-- NO v
+-- Is the project Spring Boot 3.x or 4.x?
| +-- 4.x -> Use Kotlin 2.2+ with JSpecify null-safety; replace org.springframework.lang.Nullable with org.jspecify.annotations.Nullable
| +-- 3.x -> Use Kotlin 2.0+ with jakarta.* imports
| +-- 2.x -> Upgrade to Spring Boot 3.5 first, then convert to Kotlin
+-- Is the project using kapt?
| +-- YES -> Migrate to KSP before upgrading to Kotlin 2.2 (kapt is deprecated)
| +-- NO v
+-- DEFAULT -> Convert file-by-file: DTOs -> Services -> Controllers -> Config -> Entities (last)
Add the Kotlin plugin, compiler plugins for Spring and JPA, and required dependencies. The kotlin-spring plugin auto-opens classes annotated with @Component, @Service, @Controller, @Configuration, @Repository, and @Transactional. The kotlin-jpa plugin generates synthetic no-argument constructors for @Entity, @MappedSuperclass, and @Embeddable classes. [src1, src7]
// build.gradle.kts
plugins {
id("org.springframework.boot") version "3.4.2"
id("io.spring.dependency-management") version "1.1.7"
kotlin("jvm") version "2.1.10"
kotlin("plugin.spring") version "2.1.10" // Auto-opens Spring-annotated classes
kotlin("plugin.jpa") version "2.1.10" // Generates no-arg constructors for JPA
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
kotlin {
jvmToolchain(17)
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
Verify: ./gradlew compileKotlin succeeds with no errors. Existing Java files still compile alongside Kotlin sources.
Replace the Java application entry point with a Kotlin top-level function. [src7]
package com.example
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class Application
fun main(args: Array<String>) {
runApplication<Application>(*args)
}
Verify: ./gradlew bootRun starts the application. All existing Java beans are still discovered.
Data Transfer Objects are the safest starting point -- they benefit most from Kotlin's data class syntax. This typically eliminates 60-80% of boilerplate. [src3]
// BEFORE: Java DTO (30+ lines with Lombok, 80+ without)
// public class UserDto { private final String name; ... getters, setters, equals, hashCode }
// AFTER: Kotlin data class (1 line)
data class UserDto(val name: String, val email: String, val age: Int)
Verify: All endpoints returning DTOs serialize to identical JSON. Run existing integration tests.
Replace @Service Java classes with Kotlin. Use constructor injection, replace Optional with nullable types, and use expression body functions for simple methods. [src2, src3]
@Service
@Transactional
class OrderService(
private val orderRepo: OrderRepository,
private val notificationService: NotificationService
) {
fun findById(id: Long): Order? = orderRepo.findById(id).orElse(null)
fun createOrder(request: OrderRequest): Order {
val order = Order(
product = request.product,
quantity = request.quantity,
status = OrderStatus.PENDING
)
return orderRepo.save(order).also {
notificationService.sendConfirmation(it)
}
}
}
Verify: ./gradlew test -- all service-level tests pass with identical behavior.
Replace @RestController Java classes. Use expression-body functions and nullable parameters for optional query params. [src7]
@RestController
@RequestMapping("/api/orders")
class OrderController(private val orderService: OrderService) {
@GetMapping("/{id}")
fun getOrder(@PathVariable id: Long): ResponseEntity<Order> =
orderService.findById(id)
?.let { ResponseEntity.ok(it) }
?: ResponseEntity.notFound().build()
@GetMapping
fun listOrders(
@RequestParam status: OrderStatus?,
@RequestParam(defaultValue = "20") limit: Int
): List<Order> = orderService.findByStatus(status, limit)
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createOrder(@Valid @RequestBody request: OrderRequest): Order =
orderService.createOrder(request)
}
Verify: curl http://localhost:8080/api/orders/1 returns the same JSON as before migration.
Entities require the most care. Do NOT use data class. Use regular classes with var properties and rely on the kotlin-jpa plugin for no-arg constructors. [src4]
@Entity
@Table(name = "orders")
class Order(
@Column(nullable = false)
var product: String,
var quantity: Int,
@Enumerated(EnumType.STRING)
var status: OrderStatus = OrderStatus.PENDING,
@ManyToOne(fetch = FetchType.LAZY)
var customer: Customer? = null,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Order) return false
return id != null && id == other.id
}
override fun hashCode(): Int = javaClass.hashCode()
}
Verify: ./gradlew test passes. Verify lazy loading works correctly.
Convert JUnit tests to idiomatic Kotlin. Use backtick-quoted function names and mockk for mocking. [src3]
@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerTest(@Autowired val mockMvc: MockMvc) {
@Test
fun `should return order by ID`() {
mockMvc.get("/api/orders/1")
.andExpect {
status { isOk() }
jsonPath("$.product") { value("Widget") }
}
}
@Test
fun `should return 404 for unknown order`() {
mockMvc.get("/api/orders/99999")
.andExpect { status { isNotFound() } }
}
}
Verify: ./gradlew test -- all tests pass. Test count should remain the same.
// Input: A Java Spring Boot project switching to Kotlin
// Output: Complete build.gradle.kts with all required plugins and dependencies
plugins {
id("org.springframework.boot") version "3.4.2"
id("io.spring.dependency-management") version "1.1.7"
kotlin("jvm") version "2.1.10"
kotlin("plugin.spring") version "2.1.10"
kotlin("plugin.jpa") version "2.1.10"
}
group = "com.example"
version = "1.0.0"
java { toolchain { languageVersion = JavaLanguageVersion.of(17) } }
repositories { mavenCentral() }
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.mockk:mockk:1.13.13")
testImplementation("com.ninja-squad:springmockk:4.0.2")
}
kotlin { compilerOptions { freeCompilerArgs.addAll("-Xjsr305=strict") } }
tasks.withType<Test> { useJUnitPlatform() }
<properties>
<kotlin.version>2.1.10</kotlin.version>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
</dependency>
</dependencies>
<build><plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<configuration>
<args><arg>-Xjsr305=strict</arg></args>
<compilerPlugins>
<plugin>spring</plugin>
<plugin>jpa</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-noarg</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins></build>
// Input: A repository needing both derived queries and custom JPQL
// Output: Idiomatic Kotlin Spring Data repository
interface OrderRepository : JpaRepository<Order, Long> {
fun findByStatus(status: OrderStatus): List<Order>
fun findByTrackingNumber(trackingNumber: String): Order?
@Query("SELECT o FROM Order o WHERE o.status = :status AND o.createdAt > :since")
fun findRecentByStatus(
@Param("status") status: OrderStatus,
@Param("since") since: LocalDateTime
): List<Order>
fun findByCustomerId(customerId: Long, pageable: Pageable): Page<Order>
}
// Extension function adds custom behavior without impl class
fun OrderRepository.findByIdOrThrow(id: Long): Order =
findById(id).orElseThrow { NotFoundException("Order $id not found") }
// Input: A Java WebFlux controller using Mono/Flux
// Output: Kotlin controller using coroutines and Flow
import kotlinx.coroutines.flow.Flow
@RestController
@RequestMapping("/api/users")
class UserController(private val userService: UserService) {
@GetMapping("/{id}")
suspend fun getUser(@PathVariable id: Long): User =
userService.findById(id) ?: throw NotFoundException("User $id not found")
@GetMapping
fun listUsers(): Flow<User> = userService.findAll()
@PostMapping
suspend fun createUser(@RequestBody dto: CreateUserDto): User =
userService.create(dto)
}
// CoroutineCrudRepository replaces ReactiveCrudRepository
interface UserRepository : CoroutineCrudRepository<User, Long> {
suspend fun findByEmail(email: String): User?
fun findByActiveTrue(): Flow<User>
}
// ❌ BAD -- data class generates equals/hashCode using ALL fields,
// breaking identity tracking. Prevents Hibernate lazy-loading proxies.
@Entity
data class Order(
@Id @GeneratedValue val id: Long = 0,
val product: String,
@ManyToOne(fetch = FetchType.LAZY)
val customer: Customer? = null
)
// ✅ GOOD -- regular class allows Hibernate proxying for lazy loading.
@Entity
class Order(
var product: String,
@ManyToOne(fetch = FetchType.LAZY) var customer: Customer? = null,
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Order) return false
return id != null && id == other.id
}
override fun hashCode(): Int = javaClass.hashCode()
}
// ❌ BAD -- hides dependencies, makes testing harder
@Service
class OrderService {
@Autowired lateinit var orderRepo: OrderRepository
@Autowired lateinit var notificationService: NotificationService
}
// ✅ GOOD -- explicit, immutable, fail-fast at startup
@Service
class OrderService(
private val orderRepo: OrderRepository,
private val notificationService: NotificationService
)
// ❌ BAD -- Kotlin interprets ${...} as string template interpolation
@Value("${app.max-retries}") // Compile error!
lateinit var maxRetries: String
// ✅ GOOD -- backslash escapes $ for Spring property resolution
@Value("\${app.max-retries}")
lateinit var maxRetries: String
// BETTER -- use @ConfigurationProperties for type-safe config
@ConfigurationProperties(prefix = "app")
data class AppConfig(val maxRetries: Int = 3)
// ❌ BAD -- verbose, non-idiomatic
val names = users.stream()
.filter { it.isActive }
.map { it.name }
.collect(Collectors.toList())
// ✅ GOOD -- concise, better performance for small-medium lists
val names = users.filter { it.isActive }.map { it.name }
// For large collections (100k+), use sequences
val names = users.asSequence().filter { it.isActive }.map { it.name }.toList()
// ❌ BAD -- creates synthetic class, requires @JvmStatic for Java interop
class DateUtils {
companion object {
fun formatDate(date: LocalDate): String = date.format(DateTimeFormatter.ISO_DATE)
}
}
// ✅ GOOD -- top-level functions compile to real static methods
fun formatDate(date: LocalDate): String = date.format(DateTimeFormatter.ISO_DATE)
// ❌ BAD -- kapt is deprecated, generates stubs adding 30-50% build overhead
plugins {
kotlin("kapt") version "2.2.0" // Deprecated!
}
dependencies {
kapt("com.google.dagger:dagger-compiler:2.51")
}
// ✅ GOOD -- KSP processes Kotlin symbols directly, up to 2x faster
plugins {
id("com.google.devtools.ksp") version "2.1.10-1.0.29"
}
dependencies {
ksp("com.google.dagger:dagger-compiler:2.51")
}
kotlin-spring plugin: All Kotlin classes are final by default. Without the plugin, Spring cannot create CGLIB proxies, causing BeanCreationException. Fix: Add kotlin("plugin.spring") to Gradle or <plugin>spring</plugin> to Maven. [src1]kotlin-jpa plugin: JPA requires a no-argument constructor. Fix: Add kotlin("plugin.jpa") which generates synthetic no-arg constructors for @Entity, @MappedSuperclass, @Embeddable. [src1, src4]jackson-module-kotlin: Without this module, Jackson cannot deserialize into Kotlin data classes. Fix: Add com.fasterxml.jackson.module:jackson-module-kotlin -- Spring Boot auto-registers it. [src7]val for entity properties: Hibernate needs to modify entity fields. Fix: Use var for all @Entity properties. Reserve val for DTOs. [src4]Long? = null changes hash after persist(). Fix: Use hashCode() based on javaClass.hashCode(), not id. [src3, src4]compile phase before maven-compiler-plugin. Gradle handles this automatically. [src7]any() returns null in Kotlin: Crashes non-nullable parameters. Fix: Use mockito-kotlin or switch to mockk. [src3]@RequestParam without nullable type: Spring throws MissingServletRequestParameterException. Fix: Declare optional params as name: String? = null or name: String = "default". [src7]@NullMarked annotations across the entire API. Previously nullable-ambiguous platform types now have explicit nullability. Fix: Audit Spring API call sites and add ? to parameters/return types now explicitly @Nullable. [src6, src8]# Verify Kotlin compiles alongside Java
./gradlew compileKotlin compileJava --info
# Check migration progress (remaining Java vs Kotlin files)
find src/main -name "*.java" | wc -l
find src/main -name "*.kt" | wc -l
# Verify kotlin-spring plugin is opening classes
./gradlew dependencies --configuration compileClasspath | grep kotlin
# Check for missing Jackson Kotlin module
./gradlew bootRun 2>&1 | grep -i "kotlin\|jackson"
# Run tests and check for Mockito null issues
./gradlew test --info 2>&1 | grep -i "NullPointerException\|UninitializedProperty"
# Verify JPA entity proxy creation
./gradlew bootRun 2>&1 | grep -i "could not make.*final\|proxy\|cglib"
# Check Kotlin compiler version
./gradlew kotlinCompilerVersion
# Verify kapt vs KSP status
./gradlew tasks --all | grep -i "kapt\|ksp"
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Spring Boot 4.0 + Kotlin 2.2 | Current (2025) | Kotlin 2.2 baseline, JSpecify null-safety, org.springframework.lang.Nullable removed | Spring types are null-aware in Kotlin; audit all Spring API call sites. Upgrade via 3.5 first. [src6, src8] |
| Spring Boot 3.4 + Kotlin 2.1 | Active (2024) | None | Recommended stable combination for new migrations |
| Spring Boot 3.0 + Kotlin 1.9 | LTS | javax.* to jakarta.*, Java 17 minimum | Namespace migration required if upgrading from 2.x |
| Spring Boot 2.7 + Kotlin 1.8 | EOL Nov 2023 | Last javax.* support | Upgrade to 3.x before Kotlin migration |
| Kotlin 2.0 | Stable (2024) | New K2 compiler (faster, stricter), kapt deprecated | K2 enabled by default; up to 94% faster compilation. Use -Xjsr305=strict |
| KSP 2.x | Default (2025) | Replaces kapt for annotation processing | Most libraries ship KSP artifacts. Migrate module-by-module. |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Team wants null safety, coroutines, and concise syntax | Team has zero Kotlin experience and a critical deadline | Train first, migrate later |
| Starting new Spring Boot microservices | Codebase is scheduled for retirement within 12 months | Keep Java, invest in tests |
| Using Kotlin on Android and want shared language | Library consumed by Java-only downstream teams | Stay Java or add @JvmStatic/@JvmOverloads |
| Eliminating Lombok dependency (maintenance burden) | Heavy annotation processors not yet KSP-compatible | Wait for KSP support or use kapt transitionally |
| Code reduction is a priority (20-40% fewer lines) | Team resists change and migration would cause friction | Introduce Kotlin in tests first |
| Upgrading to Spring Boot 4.0 (Kotlin is first-class) | Project uses Java 8 and cannot move to Java 17+ | Upgrade Java version first |
Ctrl+Alt+Shift+K) produces functional but non-idiomatic Kotlin. Plan for manual cleanup: replace !! with safe calls, convert streams to collection operators, introduce when expressions. Budget 2-3x the auto-conversion time for cleanup.suspend functions in controllers. Use WebFlux if coroutines are a primary motivation.data class methods inflate the uncovered method count. Adjust coverage thresholds or exclude generated methods.