How to Migrate Java Spring to Kotlin Spring Boot
How do I migrate Java Spring to Kotlin Spring Boot?
TL;DR
- Bottom line: Migrate incrementally -- add the
kotlin-springandkotlin-jpaGradle/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. - Key tool/command: IntelliJ IDEA
Code > Convert Java File to Kotlin File(Ctrl+Alt+Shift+K) for automated conversion, then manual cleanup for idiomatic Kotlin. - Watch out for: JPA entities must NOT be
data class-- use regular classes withvarproperties, becausedata classgenerates brokenequals/hashCodefor mutable entities and prevents lazy-loading proxies. - Works with: Spring Boot 3.5.x / 4.0.x (4.0.5 current, May 2026), Kotlin 2.2+ (2.3.20 latest), Gradle (Kotlin DSL) or Maven with kotlin-maven-plugin. Spring Boot 4.0 pins Kotlin 2.2.21 in its BOM and uses JSpecify null-safety.
Constraints
- Never use
data classfor JPA entities --data classgeneratesequals/hashCodeusing ALL fields, breaking identity tracking when fields change after persist. Also prevents Hibernate lazy-loading proxies because data classes arefinal. Use regular classes with ID-basedequals/hashCode. [src4] - Both
kotlin-springandkotlin-jpaplugins are mandatory -- Withoutkotlin-spring(allopen), all Kotlin classes arefinaland Spring cannot create CGLIB proxies, causingBeanCreationException. Withoutkotlin-jpa(noarg), JPA cannot instantiate entities, causingInstantiationException. [src1] - Spring Boot 4.0 requires Kotlin 2.2+ -- The managed BOM pins Kotlin 2.2.x. Do not attempt to use Kotlin 1.x or 2.0/2.1 with Spring Boot 4. JSpecify annotations may cause new compilation failures for previously nullable-ambiguous platform types. [src6, src8]
- Upgrade path must be sequential: 2.x → 3.5 → 4.0 -- Direct jumps from 2.x to 4.x skip the javax.* to jakarta.* namespace migration and are unsupported. [src8]
- Migrate from kapt to KSP before Kotlin 2.2 -- kapt is deprecated and generates Java stubs that add 30-50% build overhead. KSP processes Kotlin symbols directly and is up to 2x faster. [src2]
- In Maven, Kotlin must compile before Java -- Mixed-language projects require kotlin-maven-plugin to run in the
compilephase beforemaven-compiler-plugin, otherwise Java code cannot reference Kotlin classes. [src7]
Quick Reference
| 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] |
Decision Tree
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)
Decision Logic
If the project is on Spring Boot 2.x
→ Do NOT migrate language yet. Upgrade Spring Boot 2.x to 3.5.11 first (javax.* to jakarta.* namespace migration), then convert to Kotlin. Direct 2.x to 4.x jumps are unsupported. [src8]
If the project is on Spring Boot 3.4 or earlier 3.x
→ Spring Boot 3.4 is EOL (OSS support ended 2025-12-31). Move to 3.5.11 (supported until 2026-06-30) or 4.0.5 before investing in a Kotlin migration. [src6, src8]
If you are targeting Spring Boot 4.0/4.1
→ Use Kotlin 2.2+ (the 4.0 BOM pins 2.2.21; 2.3.20 is the latest stable). Expect JSpecify-driven null-safety refinements -- Spring, Reactor, and Micrometer APIs now expose Kotlin null-safe types instead of platform types, so audit call sites for new ? requirements. [src8, src9]
If you are converting JPA/Hibernate entities
→ Never use data class. Use a regular class with var properties, the kotlin-jpa (noarg) plugin, and ID-based equals/hashCode with hashCode() derived from javaClass. [src4]
If the project still uses kapt for annotation processing
→ Migrate to KSP (latest 2.3.7) before going to Kotlin 2.2+. kapt is in maintenance mode and is not enabled by default from Kotlin 2.0; KSP and kapt can run side-by-side so migrate module-by-module. [src2]
If coroutines are the primary motivation for adopting Kotlin
→ Use Spring WebFlux, not Spring MVC -- only WebFlux controllers support suspend functions and Flow. On Spring Boot 4.0 enable automatic context propagation with spring.reactor.context-propagation=auto. [src5, src9]
If the codebase is large with heavy Lombok use
→ Remove Lombok first (convert @Data to Java records or plain classes), then convert file-by-file: DTOs -> Services -> Controllers -> Config -> Entities, converting tests first to validate Java/Kotlin interop. [src2, src3]
Step-by-Step Guide
1. Configure build system for Kotlin
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.
2. Convert the main application class
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.
3. Convert DTOs and value objects first
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.
4. Convert service classes
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.
5. Convert controllers
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.
6. Convert JPA entities (last)
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.
7. Convert test classes
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.
Code Examples
Gradle Kotlin DSL: Complete build configuration
// 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() }
Maven: Kotlin configuration for Spring Boot
<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>
Kotlin: Spring Data Repository with custom queries
// 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") }
Kotlin: Coroutines with Spring WebFlux
// 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>
}
Anti-Patterns
Wrong: Using data class for JPA entities
// ❌ 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
)
Correct: Use regular class with manual equals/hashCode
// ✅ 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()
}
Wrong: Field injection with lateinit var
// ❌ BAD -- hides dependencies, makes testing harder
@Service
class OrderService {
@Autowired lateinit var orderRepo: OrderRepository
@Autowired lateinit var notificationService: NotificationService
}
Correct: Constructor injection via primary constructor
// ✅ GOOD -- explicit, immutable, fail-fast at startup
@Service
class OrderService(
private val orderRepo: OrderRepository,
private val notificationService: NotificationService
)
Wrong: Not escaping $ in @Value annotations
// ❌ BAD -- Kotlin interprets ${...} as string template interpolation
@Value("${app.max-retries}") // Compile error!
lateinit var maxRetries: String
Correct: Escape the dollar sign in @Value
// ✅ 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)
Wrong: Converting Java streams literally
// ❌ BAD -- verbose, non-idiomatic
val names = users.stream()
.filter { it.isActive }
.map { it.name }
.collect(Collectors.toList())
Correct: Use Kotlin standard library collection operators
// ✅ 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()
Wrong: Using companion object for static utility methods
// ❌ BAD -- creates synthetic class, requires @JvmStatic for Java interop
class DateUtils {
companion object {
fun formatDate(date: LocalDate): String = date.format(DateTimeFormatter.ISO_DATE)
}
}
Correct: Use top-level functions or @JvmStatic
// ✅ GOOD -- top-level functions compile to real static methods
fun formatDate(date: LocalDate): String = date.format(DateTimeFormatter.ISO_DATE)
Wrong: Using kapt with Kotlin 2.2+
// ❌ 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")
}
Correct: Use KSP for annotation processing
// ✅ 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")
}
Common Pitfalls
- Missing
kotlin-springplugin: All Kotlin classes arefinalby default. Without the plugin, Spring cannot create CGLIB proxies, causingBeanCreationException. Fix: Addkotlin("plugin.spring")to Gradle or<plugin>spring</plugin>to Maven. [src1] - Missing
kotlin-jpaplugin: JPA requires a no-argument constructor. Fix: Addkotlin("plugin.jpa")which generates synthetic no-arg constructors for@Entity,@MappedSuperclass,@Embeddable. [src1, src4] - Missing
jackson-module-kotlin: Without this module, Jackson cannot deserialize into Kotlin data classes. Fix: Addcom.fasterxml.jackson.module:jackson-module-kotlin-- Spring Boot auto-registers it. [src7] - Using
valfor entity properties: Hibernate needs to modify entity fields. Fix: Usevarfor all@Entityproperties. Reservevalfor DTOs. [src4] - Nullable ID causes issues with collections: JPA entity ID
Long? = nullchanges hash afterpersist(). Fix: UsehashCode()based onjavaClass.hashCode(), notid. [src3, src4] - Kotlin compilation order in mixed projects: Kotlin must compile before Java. In Maven, configure kotlin-maven-plugin in the
compilephase beforemaven-compiler-plugin. Gradle handles this automatically. [src7] - Mockito
any()returns null in Kotlin: Crashes non-nullable parameters. Fix: Usemockito-kotlinor switch tomockk. [src3] @RequestParamwithout nullable type: Spring throwsMissingServletRequestParameterException. Fix: Declare optional params asname: String? = nullorname: String = "default". [src7]- JSpecify compilation failures on Spring Boot 4.0 upgrade: Spring Boot 4.0 adds JSpecify
@NullMarkedannotations 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]
Diagnostic Commands
# 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 History & Compatibility
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Spring Boot 4.0 (4.0.5, Mar 2026) + Kotlin 2.2 | Current GA (OSS support to 2026-12-31) | Kotlin 2.2.21 BOM baseline, JSpecify null-safety, org.springframework.lang.Nullable removed, spring-boot-starter-kotlinx-serialization-json added | Spring/Reactor/Micrometer types are null-aware in Kotlin; audit all API call sites. Upgrade via 3.5 first. 4.1 due May 2026 (4.1.0-M4). [src6, src8, src9] |
| Spring Boot 3.5 (3.5.11, Feb 2026) + Kotlin 2.1/2.2 | Final 3.x minor, supported to 2026-06-30 | None | Recommended last 3.x stop before Spring Boot 4.0. Stable combination for new migrations |
| Spring Boot 3.4 + Kotlin 2.1 | EOL 2025-12-31 (final 3.4.13) | None | No longer receives OSS patches -- move to 3.5.11 or 4.0.x |
| Spring Boot 3.0 + Kotlin 1.9 | EOL 2023-12-31 | javax.* to jakarta.*, Java 17 minimum | Namespace migration required if upgrading from 2.x |
| Spring Boot 2.7 + Kotlin 1.8 | EOL 2023-06-30 (final 2.7.18) | Last javax.* support | Upgrade to 3.5.x before Kotlin migration |
| Kotlin 2.3 (2.3.20) | Latest stable (2026) | Continued K2 refinements | Compatible with Spring Boot 4.0; BOM pins 2.2.21 but newer 2.3.x can be set explicitly |
| Kotlin 2.0 | Stable (2024) | New K2 compiler (faster, stricter), kapt not enabled by default | K2 enabled by default; up to 94% faster compilation. Use -Xjsr305=strict |
| KSP (2.3.7) | Default annotation processor (2026) | Replaces kapt (now in maintenance mode) | Most libraries ship KSP artifacts. KSP and kapt can co-exist -- migrate module-by-module. |
When to Use / When Not to Use
| 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 |
Important Caveats
- IntelliJ's auto-converter (
Ctrl+Alt+Shift+K) produces functional but non-idiomatic Kotlin. Plan for manual cleanup: replace!!with safe calls, convert streams to collection operators, introducewhenexpressions. Budget 2-3x the auto-conversion time for cleanup. - Kotlin compilation is 10-30% slower than Java in mixed projects. The K2 compiler (Kotlin 2.0+) reduces this gap -- benchmarks show up to 94% faster clean builds and 376% faster incremental analysis compared to K1. Build caching and incremental compilation further mitigate the impact. [src2]
- Spring Boot 4.0 reached GA in November 2025; the current stable release is 4.0.5 (March 2026) and Spring Boot 4.1 is due May 2026 (4.1.0-M4 in milestone). It makes Kotlin 2.2 the baseline (BOM pins 2.2.21; 2.3.20 is the latest stable Kotlin) and adopts JSpecify null-safety across Spring, Reactor, and Micrometer -- there are no unsafe platform types in those APIs anymore, just Kotlin null-safe types. Kotlin callers may see new compiler warnings or errors after upgrading. The migration path is Spring Boot 3.5.11 → 4.0. [src5, src6, src8, src9]
- Spring Boot 4.0 changes serialization defaults: when both Kotlin Serialization and Jackson are on the classpath, Kotlin Serialization handles only types annotated with
@Serializable(at root or generic-type level) and everything else falls through to Jackson. Add the newspring-boot-starter-kotlinx-serialization-jsonstarter if you want Kotlin Serialization as the primary mapper. [src9] - Coroutines support in Spring WebFlux is mature, but Spring MVC (blocking) does not support
suspendfunctions in controllers. Use WebFlux if coroutines are a primary motivation. On Spring Boot 4.0, enable automatic coroutine context propagation withspring.reactor.context-propagation=auto. [src9] - Code coverage metrics typically drop 5-15% after converting to Kotlin because generated
data classmethods inflate the uncovered method count. Adjust coverage thresholds or exclude generated methods. - 27% of Spring developers now use Kotlin (JetBrains/Spring 2025 survey), and the JetBrains-Spring strategic partnership ensures continued first-class Kotlin support. [src5]