Spring Boot with Kotlin: the good, the awkward, and what nobody tells you upfront

Real experience with Spring Boot and Kotlin: setup, JPA, null safety, testing, and the problems that actually show up.

Cover for Spring Boot with Kotlin: the good, the awkward, and what nobody tells you upfront
Updated: May 18, 2026

The first time I bootstrapped a Spring Boot project with Kotlin, I thought everything would be like Java but with less code. Technically I wasn’t wrong, but that sentence omits half the story. Some things improve dramatically, there are frictions you don’t expect, and there are traps you only discover once the project has some complexity and you start fighting JPA, tests, or the framework itself trying to do things Kotlin doesn’t want you to do.

This article isn’t a “getting started with Spring Boot and Kotlin” tutorial. It’s what I wish I had read after a couple of months working with the combination and starting to notice where it hurts.


Initial setup: Gradle with Kotlin DSL

The first contact is usually the build.gradle.kts. If you come from Gradle with Groovy, the switch to Kotlin DSL is welcome: real IDE autocompletion, type safety, refactoring that actually works. But the Spring Boot with Kotlin setup requires a few plugins you don’t need in Java.

plugins {
    id("org.springframework.boot") version "3.3.0"
    id("io.spring.dependency-management") version "1.1.5"
    kotlin("jvm") version "1.9.24"
    kotlin("plugin.spring") version "1.9.24"
    kotlin("plugin.jpa") version "1.9.24"
}

group = "tech.oshy"
version = "0.0.1-SNAPSHOT"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    runtimeOnly("org.postgresql:postgresql")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("io.mockk:mockk:1.13.10")
    testImplementation("com.ninja-squad:springmockk:4.0.2")
}

kotlin {
    compilerOptions {
        freeCompilerArgs.addAll("-Xjsr305=strict")
    }
}

There are three plugins that are critical, and if you don’t include them, you’ll get confusing errors:

  • kotlin("plugin.spring"): Automatically opens classes annotated with @Component, @Service, @Configuration, etc. Without this, Spring can’t create proxies and beans don’t get injected.
  • kotlin("plugin.jpa"): Generates no-arg constructors for JPA entities. Without this, Hibernate can’t instantiate your entities.
  • -Xjsr305=strict: Makes Java’s nullability annotations (@Nullable, @NonNull) respected in Kotlin types. Without this, a Spring method returning null won’t give you a warning.

If you come from Java, these plugins are invisible because Java already fulfills those conditions by default. In Kotlin, you have to explicitly declare that you want that behavior.


Data classes and JPA: the conflict nobody warns you about

This is probably where most people get frustrated. Kotlin pushes you toward immutable data classes. JPA needs mutable entities with a no-arg constructor and properties that can be modified. These are two opposing philosophies.

Your first instinct is to write something like this:

@Entity
data class Article(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    val title: String,
    val slug: String,
    val content: String,
    @ManyToOne(fetch = FetchType.LAZY)
    val category: Category
)

It works. It compiles. It starts up. And three weeks later you start running into problems.

The first one: equals() and hashCode() generated by data class include all fields, including id. That means an unpersisted entity (id = 0) and the same entity once persisted (id = 47) are “different” according to equals. If you put them in a Set or compare them in tests, you get silent bugs.

The second one: lazy relationships. If category is val and lazy, Hibernate needs a proxy to load it later. But data class generates equals and hashCode that access category, which can trigger lazy loading at unexpected moments. In the worst case, outside a transaction, and you get a LazyInitializationException.

The solution I use after trying several approaches:

@Entity
class Article(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    var title: String,
    var slug: String,

    @Column(columnDefinition = "TEXT")
    var content: String,

    @ManyToOne(fetch = FetchType.LAZY)
    var category: Category? = null
) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is Article) return false
        return id != null && id == other.id
    }

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

It’s not pretty. It looks like Java wearing Kotlin clothes. But it’s what works without surprises. I use var because Hibernate needs to mutate the fields, id is nullable because it has no value before persisting, and equals/hashCode are based only on the identifier.

The rule I follow: data classes for DTOs and value objects. Regular classes for JPA entities. Mixing both worlds creates more problems than it solves.


Null safety with Spring: the promise and the reality

Kotlin’s nullability system is one of the best reasons to use it. But when you combine it with Spring, there are gray areas.

Spring Boot 3 has good support for Kotlin’s nullable types. If you define a controller parameter as String?, Spring treats it as optional. If it’s String, it treats it as required and returns 400 if it’s missing. This works well:

@RestController
@RequestMapping("/api/articles")
class ArticleController(
    private val articleService: ArticleService
) {
    @GetMapping
    fun search(
        @RequestParam query: String,
        @RequestParam category: String? = null,
        @RequestParam page: Int = 0
    ): ResponseEntity<Page<ArticleDto>> {
        return ResponseEntity.ok(
            articleService.search(query, category, page)
        )
    }
}

Where things get complicated is with JPA queries. findById returns Optional<T> in Java. In Kotlin you can define the repository method like this:

interface ArticleRepository : JpaRepository<Article, Long> {
    fun findBySlug(slug: String): Article?
    fun findAllByCategory(category: Category): List<Article>
}

Spring Data understands that Article? means it can return null. But if you make a mistake and put Article (without the question mark) and the record doesn’t exist, Spring returns null anyway. In Java you wouldn’t notice. In Kotlin, you have a null where the compiler promised there wouldn’t be one. A NullPointerException in Kotlin is always a betrayal.

That’s why the -Xjsr305=strict option is important: it makes the types from Java libraries that use JSR-305 annotations be interpreted strictly. It doesn’t cover all cases, but it helps a lot.


Testing: MockK vs Mockito

Here there’s a clear improvement. Testing Spring with Kotlin using Mockito can be done, but it’s awkward. Mockito was designed for Java and has frictions with Kotlin classes (which are final by default) and with nullable types.

MockK is the Kotlin-native alternative and the difference is noticeable:

@ExtendWith(MockKExtension::class)
class ArticleServiceTest {

    @MockK
    private lateinit var articleRepository: ArticleRepository

    @MockK
    private lateinit var categoryRepository: CategoryRepository

    @InjectMockKs
    private lateinit var articleService: ArticleService

    @Test
    fun `should return article by slug`() {
        val expected = Article(
            id = 1L,
            title = "Test",
            slug = "test-article",
            content = "Content"
        )

        every { articleRepository.findBySlug("test-article") } returns expected

        val result = articleService.findBySlug("test-article")

        assertThat(result).isNotNull
        assertThat(result?.title).isEqualTo("Test")
        verify(exactly = 1) { articleRepository.findBySlug("test-article") }
    }

    @Test
    fun `should return null when article does not exist`() {
        every { articleRepository.findBySlug("nope") } returns null

        val result = articleService.findBySlug("nope")

        assertThat(result).isNull()
    }
}

Compared with Mockito:

AspectMockKMockito
SyntaxNative Kotlin DSL (every, verify)Java API (when, verify)
Final classesWorks without configurationNeeds mockito-extensions or open
Null safetyRespects nullable typesCan return null on non-null types
CoroutinescoEvery, coVerifyNot natively supported
Argument captureslot<T>(), capturedArgumentCaptor, more verbose

The springmockk library is the bridge for using MockK with Spring Test annotations (@MockkBean instead of @MockBean). It works well and the code comes out cleaner.

An important detail: test names with backticks like `should return article by slug` are one of those small Kotlin things that greatly improve test readability. In Java you’d have shouldReturnArticleBySlug and you lose the clarity of the natural sentence.


The errors that actually show up

After several months with Spring Boot and Kotlin, these are the errors that cost me the most time:

1. Forgetting the all-open plugin

Without kotlin("plugin.spring"), your @Service and @Configuration classes are final. Spring needs to create proxies (with CGLIB) for dependency injection, transactions, and AOP. If the class is final, it can’t. The error is sometimes clear (“cannot subclass final class”), but sometimes the bean simply doesn’t get injected or transactions don’t work.

2. Lazy loading outside a transaction

This also happens in Java, but in Kotlin it’s more common because you tend to access properties directly. If you have an article.category.name outside a Hibernate session, LazyInitializationException. The solution is to use @Transactional in the service or do fetch joins in the query:

@Query("SELECT a FROM Article a JOIN FETCH a.category WHERE a.slug = :slug")
fun findBySlugWithCategory(@Param("slug") slug: String): Article?

3. Jackson and data classes

Jackson needs to know how to deserialize Kotlin data classes. Without jackson-module-kotlin in the dependencies, Jackson tries to use a no-arg constructor (which data classes don’t have) and fails with cryptic errors. Make sure you always have it.

4. Spring and Kotlin default values

If you define a controller with parameters that have default values:

@GetMapping
fun list(@RequestParam page: Int = 0, @RequestParam size: Int = 20): Page<Article>

Spring Boot doesn’t respect Kotlin’s default values. You need to use @RequestParam(required = false, defaultValue = "0") or make the parameter nullable and handle the default in code. This is partially fixed in recent versions with the kotlin-reflect plugin, but it doesn’t always work as you’d expect.

5. Coroutines and Spring WebFlux

If you use coroutines with Spring WebFlux, everything works quite well on the happy path. But error handling and reactive transactions have traps. @Transactional doesn’t work directly with suspend functions. You need TransactionalOperator or @Transactional with ReactiveTransactionManager. This is one of those places where the docs say “supported” but reality has nuances.


What actually improves compared to Java

After using both languages in real Spring Boot projects, these are the improvements I genuinely notice:

Null safety in business logic. Not in the infrastructure layer (JPA, Spring), but in business logic. When you write a service that transforms data, extension functions and nullable types eliminate an entire category of bugs. The typical if (x != null && x.getY() != null && x.getY().getZ() != null) from Java disappears with x?.y?.z.

DTOs and mappers. Data classes for DTOs and extension functions for mapping between entities and DTOs are much cleaner than in Java:

data class ArticleDto(
    val id: Long,
    val title: String,
    val slug: String,
    val categoryName: String
)

fun Article.toDto() = ArticleDto(
    id = id ?: 0,
    title = title,
    slug = slug,
    categoryName = category?.name ?: "Uncategorized"
)

In Java you’d need a separate class, a builder, or MapStruct. Here it’s five lines.

Scope functions. let, also, apply, run are useful for configuring objects or chaining operations without intermediate variables. Not revolutionary, but it reduces noise.

Readable tests. Backtick names, extension functions for test builders, and the general syntax make tests easier to read and write.


What doesn’t improve as much

The persistence layer. JPA was designed for Java. The mutable entity model with proxies doesn’t fit idiomatic Kotlin. You can make it work, but it always feels like you’re fighting the framework.

The team’s learning curve. If your team is a Java team, the transition isn’t just about learning syntax. It’s about changing habits: thinking in immutability, using extension functions instead of utility classes, understanding scope functions without overusing them. I’ve seen Kotlin code that’s just Java with val and fun. It works, but it wastes the language’s potential.

The library ecosystem. Most libraries in the Spring ecosystem are Java-first. They work with Kotlin, but the experience isn’t always native. MockK, kotlinx-serialization, and coroutines are exceptions, but many infrastructure libraries remain more natural in Java.

Compilation time. The Kotlin compiler is slower than Java’s. In large projects, the difference is noticeable. The K2 compiler improves this, but it’s still a factor.


So, is it worth it?

Yes, but with caveats. If you’re starting a new project with Spring Boot and you have Kotlin experience, productivity improves in the business layer, DTOs, tests, and general code. But the persistence layer with JPA is going to require compromises.

If you’re migrating an existing Java project, do it gradually. Kotlin and Java coexist well in the same project. Start with tests, then DTOs, then new services. Leaving JPA entities in Java and writing the rest in Kotlin is a perfectly valid strategy.

What I wouldn’t do is migrate existing JPA entities to Kotlin data classes “just because.” The benefit is minimal and the risks of introducing subtle bugs with equals, hashCode, and lazy loading are real.

Spring Boot with Kotlin is a productive combination, but not a magical one. What improves most is the quality of business logic code. What improves least is the relationship with JPA. Knowing where the limits are is what actually saves you time.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved