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: Spring Boot 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. Most annotation processors (Dagger, Room, Moshi) now ship KSP artifacts. [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. Gradle handles this automatically. [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 to your build file. 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") // JSON serialization
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
kotlin {
jvmToolchain(17)
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict") // Treat Spring null annotations as 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. The main function must be a package-level function, not inside a class. [src7]
// src/main/kotlin/com/example/Application.kt
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) // Kotlin-idiomatic Spring Boot startup
}
Verify: ./gradlew bootRun starts the application. All existing Java beans are still discovered and initialized.
3. Convert DTOs and value objects first
Data Transfer Objects are the safest starting point -- they have no framework dependencies and benefit most from Kotlin's data class syntax. This typically eliminates 60-80% of boilerplate. [src3]
// BEFORE: Java DTO (30 lines with Lombok, 80+ lines without)
public class UserDto {
private final String name;
private final String email;
private final int age;
public UserDto(String name, String email, int age) {
this.name = name;
this.email = email;
this.age = age;
}
public String getName() { return name; }
public String getEmail() { return email; }
public int getAge() { return age; }
@Override
public boolean equals(Object o) { /* ... */ }
@Override
public int hashCode() { /* ... */ }
@Override
public String toString() { /* ... */ }
}
// 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 (Kotlin primary constructor), replace Optional with nullable types, and use expression body functions for simple methods. [src2, src3]
// BEFORE: Java Service
@Service
@Transactional
public class OrderService {
private final OrderRepository orderRepo;
private final NotificationService notificationService;
@Autowired
public OrderService(OrderRepository orderRepo,
NotificationService notificationService) {
this.orderRepo = orderRepo;
this.notificationService = notificationService;
}
public Optional<Order> findById(Long id) {
return orderRepo.findById(id);
}
public Order createOrder(OrderRequest request) {
Order order = new Order();
order.setProduct(request.getProduct());
order.setQuantity(request.getQuantity());
order.setStatus(OrderStatus.PENDING);
Order saved = orderRepo.save(order);
notificationService.sendConfirmation(saved);
return saved;
}
}
// AFTER: Kotlin Service
@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: Run ./gradlew test -- all service-level tests pass with identical behavior.
5. Convert controllers
Replace @RestController Java classes. Use Kotlin's expression-body functions, nullable parameters for optional query params, and destructuring where useful. [src7]
// Kotlin REST Controller
@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?, // nullable = optional
@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 for JPA entities. Use regular classes with var properties and rely on the kotlin-jpa plugin for no-arg constructors. [src4]
// BEFORE: Java Entity
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String product;
private int quantity;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
// getters, setters, equals, hashCode...
}
// AFTER: Kotlin Entity (NOT a data class)
@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 // nullable until persisted
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Order) return false
return id != null && id == other.id // ID-based equality
}
override fun hashCode(): Int = javaClass.hashCode() // Consistent hash
}
Verify: ./gradlew test passes. Verify lazy loading works: customer field is not eagerly fetched. Check Hibernate SQL logs.
7. Convert test classes
Convert JUnit tests to idiomatic Kotlin. Use backtick-quoted function names for readable test descriptions and leverage mockk or mockito-kotlin for mocking. [src3]
// Kotlin test with readable names
@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
Full script: gradle-kotlin-dsl-complete-build-configuration.txt (45 lines)
// Input: A Java Spring Boot project switching to Kotlin
// Output: Complete build.gradle.kts with all required plugins and dependencies
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "3.4.2"
# ... (see full script)
Maven: Kotlin configuration for Spring Boot
Full script: maven-kotlin-configuration-for-spring-boot.txt (77 lines)
<!-- Input: A Maven-based Java Spring Boot project adding Kotlin support
Output: pom.xml additions for Kotlin compilation with spring/jpa plugins -->
<properties>
<kotlin.version>2.1.10</kotlin.version>
<java.version>17</java.version>
# ... (see full script)
Kotlin: Spring Data Repository with custom queries
Full script: kotlin-spring-data-repository-with-custom-queries.txt (25 lines)
// Input: A repository needing both derived queries and custom JPQL
// Output: Idiomatic Kotlin Spring Data repository
interface OrderRepository : JpaRepository<Order, Long> {
// Derived query -- Spring generates SQL from method name
fun findByStatus(status: OrderStatus): List<Order>
# ... (see full script)
Anti-Patterns
Wrong: Using data class for JPA entities
// BAD -- data class generates equals/hashCode using ALL fields,
// breaking identity tracking when fields change after persist.
// Also prevents Hibernate lazy-loading proxies (data classes are final).
@Entity
data class Order(
@Id @GeneratedValue
val id: Long = 0,
val product: String,
val quantity: Int,
@ManyToOne(fetch = FetchType.LAZY)
val customer: Customer? = null // Lazy proxy creation fails on final class
)
Correct: Use regular class with manual equals/hashCode
// GOOD -- regular class allows Hibernate proxying for lazy loading.
// ID-based equals/hashCode is stable across entity lifecycle.
@Entity
class Order(
var product: String,
var quantity: Int,
@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 -- field injection hides dependencies, makes testing harder,
// and lateinit throws UninitializedPropertyAccessException if Spring wiring fails silently.
@Service
class OrderService {
@Autowired
lateinit var orderRepo: OrderRepository
@Autowired
lateinit var notificationService: NotificationService
}
Correct: Constructor injection via primary constructor
// GOOD -- dependencies are explicit, immutable, and fail-fast at startup.
// No @Autowired needed when there is a single constructor.
@Service
class OrderService(
private val orderRepo: OrderRepository,
private val notificationService: NotificationService
)
Wrong: Not escaping $ in @Value annotations
// BAD -- Kotlin interprets ${...} as string template interpolation,
// causing a compile error or empty value injection.
@Value("${app.max-retries}") // Kotlin tries to interpolate this!
lateinit var maxRetries: String
Correct: Escape the dollar sign in @Value
// GOOD -- backslash escapes the dollar sign 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 -- using Java Stream API in Kotlin instead of native collection operators.
// Verbose, non-idiomatic, and slower (stream creation overhead).
val names = users.stream()
.filter { it.isActive }
.map { it.name }
.collect(Collectors.toList())
Correct: Use Kotlin standard library collection operators
// GOOD -- Kotlin collections are more concise and perform better for small-medium lists.
val names = users.filter { it.isActive }.map { it.name }
// For large collections (100k+), use sequences for lazy evaluation
val names = users.asSequence()
.filter { it.isActive }
.map { it.name }
.toList()
Wrong: Using companion object for static utility methods
// BAD -- companion object methods are not truly static; they create
// a synthetic class and require @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.
// Better for utility functions that don't need class context.
fun formatDate(date: LocalDate): String = date.format(DateTimeFormatter.ISO_DATE)
// If Java interop is required, use @JvmStatic in companion object
class DateUtils {
companion object {
@JvmStatic
fun formatDate(date: LocalDate): String = date.format(DateTimeFormatter.ISO_DATE)
}
}
Wrong: Using kapt with Kotlin 2.2+
// BAD -- kapt generates Java stubs, adding 30-50% build overhead.
// kapt is deprecated as of 2025 and incompatible with some K2 features.
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.
// Most annotation processors now ship KSP-compatible artifacts.
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 thekotlin-spring(allopen) plugin, Spring cannot create CGLIB proxies for@Service,@Configuration, etc., causingBeanCreationException. Fix: Addkotlin("plugin.spring")to Gradle plugins or<plugin>spring</plugin>to Maven kotlin-maven-plugin. [src1] - Missing
kotlin-jpaplugin: JPA requires a no-argument constructor to instantiate entities. Kotlin classes with constructor parameters fail withInstantiationException. 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 (no default no-arg constructor). Fix: Addcom.fasterxml.jackson.module:jackson-module-kotlindependency and ensureKotlinModuleis registered (Spring Boot auto-registers it). [src7] - Using
valfor entity properties: Hibernate may need to modify entity fields after loading (merge, lazy init). Usingval(immutable) prevents this. Fix: Usevarfor all@Entityproperties that Hibernate manages. Reservevalfor DTOs and value objects. [src4] - Nullable ID causes issues with collections: JPA entity ID is
Long? = nullbefore persist, which changes hash afterpersist(). Adding an entity to aSetbefore and after persist results in duplicates. Fix: Use consistenthashCode()based onjavaClass.hashCode(), not onid. [src3, src4] - Kotlin compilation order in mixed projects: Kotlin must compile before Java in mixed-language projects, otherwise Java code cannot reference Kotlin classes. Fix: In Maven, configure kotlin-maven-plugin to run in the
compilephase beforemaven-compiler-plugin. In Gradle, this is handled automatically. [src7] - Mockito
any()returns null in Kotlin: Mockito'sany()returns null, which crashes non-nullable Kotlin parameters. Fix: Usemockito-kotlinlibrary (org.mockito.kotlin:mockito-kotlin) or switch tomockkwhich is Kotlin-native. [src3] @RequestParamwithout nullable type: In Java,@RequestParam(required = false)returns null. In Kotlin, the parameter must be declared nullable (String?) or have a default value; otherwise, 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, which may cause compilation failures in Kotlin code that assumed non-null. Fix: Audit Spring API call sites and add?to parameters/return types that are now explicitly@Nullable. [src6, src8]
Diagnostic Commands
# Verify Kotlin compiles alongside Java
./gradlew compileKotlin compileJava --info
# Check for remaining Java files (track migration progress)
find src/main -name "*.java" | wc -l
find src/main -name "*.kt" | wc -l
# Verify kotlin-spring plugin is opening classes correctly
./gradlew dependencies --configuration compileClasspath | grep kotlin
# Check for missing Jackson Kotlin module registration
./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 (no final class errors)
./gradlew bootRun 2>&1 | grep -i "could not make.*final\|proxy\|cglib"
# Check Kotlin compiler version matches Spring Boot requirements
./gradlew kotlinCompilerVersion
# Verify kapt vs KSP status (should show no kapt tasks for KSP-migrated projects)
./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 for new nullability. 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 (Dagger, Room, Moshi) 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 | Project is a library consumed by Java-only downstream teams | Stay Java or add @JvmStatic/@JvmOverloads everywhere |
| Eliminating Lombok dependency (maintenance burden) | Heavy use of annotation processors (MapStruct, Dagger) 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 features extensively 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, and use expression body functions. Budget 2-3x the auto-conversion time for cleanup. - Kotlin compilation is 10-30% slower than Java compilation in mixed projects. The K2 compiler (Kotlin 2.0+) significantly 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 that previously treated platform types as non-nullable 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 for adopting Kotlin. 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 (copy,componentN,toString) 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 in the Spring ecosystem. [src5]