Skip to main content
PCSalt
YouTube GitHub
Back to Kotlin
Kotlin · 3 min read

Migrating a Spring Boot Service from Java to Kotlin — A Practical Guide

A step-by-step guide to migrating a Spring Boot application from Java to Kotlin — migration order, build setup, interop gotchas, JPA concerns, and testing with MockK.


Kotlin gives you null safety, data classes, coroutines, and less boilerplate. But migrating a running Spring Boot service isn’t just running IntelliJ’s converter. You need a strategy — what to migrate first, how to handle Java-Kotlin interop, and where the conversion pitfalls are.

Why migrate — and when not to

Migrate when: your team writes new code in Kotlin anyway, you’re losing time to NullPointerExceptions, or the boilerplate ratio is painful.

Don’t migrate when: the service is in maintenance mode with no active development, your team doesn’t know Kotlin yet, or the service is being replaced soon. Migration has a cost — make sure you’ll recoup it.

Migration order

Convert bottom-up: start with classes that have few dependents and work your way to the entry points.

1. DTOs and models first

These are the safest — no Spring annotations, no dependencies on other classes, and IntelliJ’s converter handles them well.

Before (Java):

public class UserResponse {
    private final String id;
    private final String name;
    private final String email;

    public UserResponse(String id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    public String getId() { return id; }
    public String getName() { return name; }
    public String getEmail() { return email; }
}

After (Kotlin):

data class UserResponse(
    val id: String,
    val name: String,
    val email: String
)

One line replaces the constructor, getters, equals(), hashCode(), toString(), and copy().

2. Utility classes

Static utility methods become top-level functions or extension functions:

// Before: StringUtils.capitalize(str)
// After:
fun String.capitalizeFirst(): String =
    replaceFirstChar { it.uppercaseChar() }

3. Services

Services need more care because of Spring proxying and dependency injection:

@Service
class UserService(
    private val userRepository: UserRepository,
    private val eventPublisher: ApplicationEventPublisher
) {

    fun findById(id: String): UserResponse {
        val user = userRepository.findById(id)
            .orElseThrow { NotFoundException("User not found: $id") }
        return user.toResponse()
    }
}

Constructor injection works naturally — no @Autowired needed when there’s a single constructor.

4. Controllers last

Controllers are the entry points. Convert them after everything they depend on is stable:

@RestController
@RequestMapping("/api/v1/users")
class UserController(
    private val userService: UserService
) {

    @GetMapping("/{id}")
    fun getUser(@PathVariable id: String): ResponseEntity<UserResponse> {
        val user = userService.findById(id)
        return ResponseEntity.ok(user)
    }
}

Build changes

Your build.gradle.kts needs the Kotlin Spring plugins:

plugins {
    id("org.springframework.boot") version "4.0.0"
    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" // only if using JPA
}

dependencies {
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
}
  • kotlin-spring (plugin.spring) — opens classes annotated with @Component, @Service, @Controller, etc. Spring proxies require open classes, and Kotlin classes are final by default.
  • kotlin-jpa (plugin.jpa) — generates no-arg constructors for @Entity classes. JPA requires a no-arg constructor that Kotlin data classes don’t have.
  • jackson-module-kotlin — enables Jackson to deserialize into Kotlin classes with no default constructor.

Mixed source sets

During migration, Java and Kotlin coexist. Both compile together by default when the Kotlin plugin is applied — no special configuration needed. Java can call Kotlin and vice versa.

Interop gotchas

Platform types

When Kotlin calls Java code, it doesn’t know if a return value is nullable. These are “platform types” — shown as String! in the IDE.

// Java method: public String getName() { ... }

val name: String = javaObj.name // compiles, but NPE if getName() returns null
val safeName: String? = javaObj.name // safe — treats as nullable

Rule: when calling Java code that might return null, always use nullable types. Don’t trust platform types.

@JvmOverloads for Java callers

If Java code calls your Kotlin functions with default parameters, they won’t see the defaults:

@JvmOverloads
fun createUser(
    name: String,
    role: String = "USER",
    active: Boolean = true
): User { ... }

Without @JvmOverloads, Java only sees createUser(String, String, Boolean). With it, Java gets all combinations.

@JvmStatic for companion objects

class DateUtils {
    companion object {
        @JvmStatic
        fun parseDate(input: String): LocalDate { ... }
    }
}

Without @JvmStatic, Java calls DateUtils.Companion.parseDate(). With it, Java calls DateUtils.parseDate().

Data classes and JPA

This is the biggest gotcha. JPA entities need:

  • A no-arg constructor (handled by kotlin-jpa plugin)
  • Mutable state for proxying (data classes encourage immutability)
  • equals()/hashCode() that work with lazy-loaded proxies (data class equals() triggers lazy loading)

The recommendation: don’t use data class for JPA entities. Use a regular class:

@Entity
@Table(name = "users")
class User(
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    var id: String? = null,

    @Column(nullable = false)
    var name: String,

    @Column(nullable = false, unique = true)
    var email: String
) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is User) return false
        return id != null && id == other.id
    }

    override fun hashCode(): Int = javaClass.hashCode()
}

This avoids the proxy and lazy-loading problems while still being much cleaner than Java.

Testing migration

Replace Mockito with MockK — it’s built for Kotlin and handles final classes, companion objects, and coroutines:

import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

class UserServiceTest {

    private val userRepository = mockk<UserRepository>()
    private val eventPublisher = mockk<ApplicationEventPublisher>(relaxed = true)
    private val service = UserService(userRepository, eventPublisher)

    @Test
    fun `findById returns user response`() {
        val user = User(id = "1", name = "Alice", email = "[email protected]")
        every { userRepository.findById("1") } returns Optional.of(user)

        val result = service.findById("1")

        assert(result.name == "Alice")
        verify(exactly = 1) { userRepository.findById("1") }
    }

    @Test
    fun `findById throws when user not found`() {
        every { userRepository.findById("999") } returns Optional.empty()

        assertThrows<NotFoundException> {
            service.findById("999")
        }
    }
}

Note the backtick test names — Kotlin lets you write readable test names without camelCase.

Common mistakes

  • Converting everything at once — migrate file by file, test after each conversion. The codebase stays deployable throughout.
  • Missing kotlin-spring plugin — Spring can’t proxy final classes. You’ll get cryptic runtime errors about CGLIB.
  • Data class for JPA entities — causes proxy issues, triggers unintended lazy loading in equals(). Use regular classes for entities.
  • Ignoring platform types — treating Java return values as non-null without checking leads to NPEs that Kotlin was supposed to prevent.
  • Not adding jackson-module-kotlin — Jackson can’t deserialize request bodies into Kotlin classes without it. You’ll get obscure deserialization errors.

For more on Kotlin language features, see the Kotlin 2.x migration guide. For Java modernization without Kotlin, see Java 17 to 21 migration.