How to Migrate Java Spring to Kotlin Spring Boot

How do I migrate Java Spring to Kotlin Spring Boot?

TL;DR

Constraints

Quick Reference

Java Spring PatternKotlin EquivalentExample
public class UserServiceclass UserService (open via plugin)kotlin-spring plugin auto-opens @Service classes
POJO with getters/settersdata class UserDto(val name: String, val email: String)DTOs become one-line data classes
@Autowired field injectionConstructor injection (default)class UserService(private val repo: UserRepository)
Optional<User> returnUser? nullable typefun 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 pluginPlugin 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/constantscompanion objectcompanion object { const val MAX_RETRIES = 3 }
Lombok @Datadata classNo 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 @NotNullNon-nullable type val name: StringKotlin null safety replaces many annotations
org.springframework.lang.Nullableorg.jspecify.annotations.NullableSpring 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

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

VersionStatusBreaking ChangesMigration Notes
Spring Boot 4.0 (4.0.5, Mar 2026) + Kotlin 2.2Current 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 addedSpring/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.2Final 3.x minor, supported to 2026-06-30NoneRecommended last 3.x stop before Spring Boot 4.0. Stable combination for new migrations
Spring Boot 3.4 + Kotlin 2.1EOL 2025-12-31 (final 3.4.13)NoneNo longer receives OSS patches -- move to 3.5.11 or 4.0.x
Spring Boot 3.0 + Kotlin 1.9EOL 2023-12-31javax.* to jakarta.*, Java 17 minimumNamespace migration required if upgrading from 2.x
Spring Boot 2.7 + Kotlin 1.8EOL 2023-06-30 (final 2.7.18)Last javax.* supportUpgrade to 3.5.x before Kotlin migration
Kotlin 2.3 (2.3.20)Latest stable (2026)Continued K2 refinementsCompatible with Spring Boot 4.0; BOM pins 2.2.21 but newer 2.3.x can be set explicitly
Kotlin 2.0Stable (2024)New K2 compiler (faster, stricter), kapt not enabled by defaultK2 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 WhenDon't Use WhenUse Instead
Team wants null safety, coroutines, and concise syntaxTeam has zero Kotlin experience and a critical deadlineTrain first, migrate later
Starting new Spring Boot microservicesCodebase is scheduled for retirement within 12 monthsKeep Java, invest in tests
Using Kotlin on Android and want shared languageProject is a library consumed by Java-only downstream teamsStay Java or add @JvmStatic/@JvmOverloads everywhere
Eliminating Lombok dependency (maintenance burden)Heavy use of annotation processors (MapStruct, Dagger) not yet KSP-compatibleWait for KSP support or use kapt transitionally
Code reduction is a priority (20-40% fewer lines)Team resists change and migration would cause frictionIntroduce 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