Skip to main content

September 15, 2025

Why I Chose Kotlin Over Java for New Spring Boot Projects

After years of writing Java Spring Boot backends, I switched to Kotlin. Here's what I've learned, the gotchas no one tells you, and whether it's worth the migration.

September 15, 2025·4 min read·
backendjavakotlinspring-boot
Why I Chose Kotlin Over Java for New Spring Boot Projects

When I joined my current team, the codebase was Java 17 Spring Boot. Standard setup. Annotations everywhere, verbose getters/setters, the works. Six months in, when we started greenfielding a new microservice, I pushed to use Kotlin. The team was skeptical.

Here's what I told them, and what I've learned since.


The Honest Sales Pitch

Kotlin is not a revolution. It's Java, refined. If you know Spring Boot in Java, you can be productive in Kotlin within a week. The interoperability is genuinely excellent: you can mix Kotlin and Java files in the same project, call each other's code, share the same build toolchain.

What Kotlin does give you is less ceremony for the same intent:

// Java
public class CreateOrderRequest {
    private final String customerId;
    private final List<String> productIds;
 
    public CreateOrderRequest(String customerId, List<String> productIds) {
        this.customerId = customerId;
        this.productIds = productIds;
    }
 
    public String getCustomerId() { return customerId; }
    public List<String> getProductIds() { return productIds; }
}
// Kotlin
data class CreateOrderRequest(
    val customerId: String,
    val productIds: List<String>
)

Same semantics. One line vs. twenty. And the data class gives you equals, hashCode, toString, and copy for free.

When your codebase has fifty DTOs and request/response classes, this adds up.


Null Safety is the Real Win

Java's @NotNull / @Nullable annotations are documentation. They don't prevent NPEs. The compiler doesn't care.

Kotlin's null safety is enforced at the type level:

fun processUser(user: User?) {
    // user?.name is null-safe access
    // user!!.name throws NPE if null — compiler forces you to acknowledge the risk
    val name = user?.name ?: "Anonymous"
    println("Hello, $name")
}

After six months of production Kotlin code, I've had zero NPEs in the Kotlin services. Not because I'm particularly careful, but because the compiler makes it structurally hard to be careless.


Extension Functions Change Everything

Spring Boot relies heavily on annotations. That's fine. But sometimes you want utility functions without polluting your classes or creating SomeUtil singletons.

Extension functions let you add behavior to existing types:

fun String.toSlug(): String =
    this.lowercase()
        .replace(Regex("[^a-z0-9\\s-]"), "")
        .replace(Regex("\\s+"), "-")
        .trim('-')
 
// Usage
val slug = "Why I Chose Kotlin".toSlug() // "why-i-chose-kotlin"

I use this constantly for transformations tied to the domain.


The Gotchas

1. Spring AOP and open classes

By default, Kotlin classes are final. Spring's AOP proxying (which powers @Transactional, @Cacheable, etc.) requires classes to be open. The fix is the kotlin-spring compiler plugin, which automatically opens classes annotated by Spring. Add it to your build, but know why you need it.

// build.gradle.kts
plugins {
    kotlin("plugin.spring") version "1.9.25"
}

2. JPA and the kotlin-jpa plugin

JPA requires no argument constructors. Kotlin data classes don't have them by default. Same solution: kotlin-jpa plugin.

3. Jackson serialization quirks

The jackson-module-kotlin dependency is not negotiable. Without it, you'll get mysterious serialization errors on data classes. Add it, never think about it again.

implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

4. Coroutines are a different mental model

If you want to use Kotlin coroutines with Spring WebFlux (the reactive stack), there's a learning curve. Coroutine code that looks sequential hides asynchronous behavior. Make sure your team understands what suspend actually means before committing to the pattern.


Is It Worth It for a Migration?

For a migration of an existing Java codebase: probably not, unless you're doing a major rewrite anyway. The interop works, but you'll accumulate inconsistency.

For a new service in a shop that mostly uses Java: yes. The incremental productivity gain is real. Onboarding a Kotlin service alongside existing Java services is straightforward.

For a greenfield project with no legacy: yes, and pick the Spring Boot Kotlin starter from initializr.


My Setup

Here's what I use in production Kotlin Spring Boot services:

  • Kotlin 1.9.x + Spring Boot 3.x
  • jackson-module-kotlin for serialization
  • kotlin-spring and kotlin-jpa compiler plugins
  • Exposed or Spring Data JPA (prefer Exposed for greenfield, since the DSL is more idiomatic)
  • Coroutines only where the service is genuinely I/O bound; otherwise synchronous code reads better

The philosophy: use Kotlin features where they reduce noise. Don't force coroutines where blocking is fine. Don't contort your domain model to be "more functional" if OOP serves it better.

Use the language. Don't perform it.


Discussion

Discussion is unavailable; Giscus is not configured for this build.