How to Migrate Java Spring to Kotlin Spring Boot

Type: Software Reference Confidence: 0.90 Sources: 8 Verified: 2026-02-23 Freshness: quarterly

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)

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

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

VersionStatusBreaking ChangesMigration Notes
Spring Boot 4.0 + Kotlin 2.2Current (2025)Kotlin 2.2 baseline, JSpecify null-safety, org.springframework.lang.Nullable removedSpring 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.1Active (2024)NoneRecommended stable combination for new migrations
Spring Boot 3.0 + Kotlin 1.9LTSjavax.* to jakarta.*, Java 17 minimumNamespace migration required if upgrading from 2.x
Spring Boot 2.7 + Kotlin 1.8EOL Nov 2023Last javax.* supportUpgrade to 3.x before Kotlin migration
Kotlin 2.0Stable (2024)New K2 compiler (faster, stricter), kapt deprecatedK2 enabled by default; up to 94% faster compilation. Use -Xjsr305=strict
KSP 2.xDefault (2025)Replaces kapt for annotation processingMost libraries ship KSP artifacts. 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 languageLibrary consumed by Java-only downstream teamsStay Java or add @JvmStatic/@JvmOverloads
Eliminating Lombok dependency (maintenance burden)Heavy annotation processors 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 and cannot move to Java 17+Upgrade Java version first

Important Caveats

Related Units