Spring Boot con Kotlin: lo bueno, lo incómodo y lo que nadie te cuenta al principio
Experiencia real con Spring Boot y Kotlin: configuración, JPA, null safety, testing y los problemas que aparecen de verdad.

La primera vez que arranqué un proyecto Spring Boot con Kotlin pensé que todo iba a ser como Java pero con menos código. Técnicamente no estaba equivocado, pero esa frase omite la mitad de la historia. Hay cosas que mejoran de forma espectacular, hay fricciones que no esperas, y hay trampas que solo descubres cuando el proyecto ya tiene cierta complejidad y empiezas a pelear con JPA, con los tests o con el propio framework intentando hacer cosas que Kotlin no quiere que hagas.
Este artículo no es un tutorial de “cómo empezar con Spring Boot y Kotlin”. Es lo que me hubiera gustado leer después de llevar un par de meses trabajando con la combinación y empezar a notar dónde duele.
La configuración inicial: Gradle con Kotlin DSL
El primer contacto suele ser el build.gradle.kts. Si vienes de Gradle con Groovy, el cambio a Kotlin DSL es bienvenido: autocompletado real en el IDE, tipado, refactoring que funciona. Pero la configuración de Spring Boot con Kotlin requiere algunos plugins que en Java no necesitas.
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")
}
}Hay tres plugins que son críticos y que si no los pones, vas a tener errores confusos:
kotlin("plugin.spring"): Abre automáticamente las clases anotadas con@Component,@Service,@Configuration, etc. Sin esto, Spring no puede crear proxies y los beans no se inyectan.kotlin("plugin.jpa"): Genera constructores sin argumentos para las entidades JPA. Sin esto, Hibernate no puede instanciar tus entidades.-Xjsr305=strict: Hace que las anotaciones de nullabilidad de Java (@Nullable,@NonNull) se respeten en el tipo de Kotlin. Sin esto, un método de Spring que devuelvenullno te da warning.
Si vienes de Java, estos plugins son invisibles porque Java ya cumple esas condiciones por defecto. En Kotlin, tienes que declarar explícitamente que quieres ese comportamiento.
Data classes y JPA: el conflicto que nadie te avisa
Este es probablemente el punto donde más gente se frustra. Kotlin te empuja hacia data classes inmutables. JPA necesita entidades mutables con un constructor sin argumentos y propiedades que se puedan modificar. Son dos filosofías opuestas.
Tu primer instinto es escribir algo como esto:
@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
)Funciona. Compila. Arranca. Y tres semanas después empiezas a tener problemas.
El primero: equals() y hashCode() generados por data class incluyen todos los campos, incluido id. Eso significa que una entidad sin persistir (id = 0) y la misma entidad ya persistida (id = 47) son “diferentes” según equals. Si las metes en un Set o las comparas en tests, tienes bugs silenciosos.
El segundo: las relaciones lazy. Si category es val y lazy, Hibernate necesita un proxy para cargarla después. Pero data class genera equals y hashCode que acceden a category, lo que puede disparar la carga lazy en momentos inesperados. En el peor caso, fuera de una transacción, y te salta una LazyInitializationException.
La solución que uso después de probar varias:
@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()
}No es bonito. Parece Java con ropa de Kotlin. Pero es lo que funciona sin sorpresas. Uso var porque Hibernate necesita mutar los campos, id es nullable porque antes de persistir no tiene valor, y equals/hashCode se basan solo en el identificador.
La regla que sigo: data classes para DTOs y value objects. Clases normales para entidades JPA. Mezclar ambos mundos genera más problemas de los que resuelve.
Null safety con Spring: la promesa y la realidad
El sistema de nullabilidad de Kotlin es una de las mejores razones para usarlo. Pero cuando lo juntas con Spring, hay zonas grises.
Spring Boot 3 tiene buen soporte para los tipos nullable de Kotlin. Si defines un parámetro de un controlador como String?, Spring lo trata como opcional. Si es String, lo trata como obligatorio y devuelve 400 si no viene. Esto funciona bien:
@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)
)
}
}Donde la cosa se complica es con JPA queries. findById devuelve Optional<T> en Java. En Kotlin puedes definir el método del repositorio así:
interface ArticleRepository : JpaRepository<Article, Long> {
fun findBySlug(slug: String): Article?
fun findAllByCategory(category: Category): List<Article>
}Spring Data entiende que Article? significa que puede devolver null. Pero si te equivocas y pones Article (sin interrogante) y el registro no existe, Spring devuelve null de todas formas. En Java no lo notarías. En Kotlin, tienes un null donde el compilador te prometió que no habría null. Un NullPointerException en Kotlin es siempre una traición.
Por eso la opción -Xjsr305=strict es importante: hace que los tipos de las librerías Java que usan anotaciones JSR-305 se interpreten estrictamente. No cubre todos los casos, pero ayuda bastante.
Testing: MockK vs Mockito
Aquí hay una mejora clara. Testear Spring con Kotlin usando Mockito se puede hacer, pero es incómodo. Mockito fue diseñado para Java y tiene fricciones con las clases de Kotlin (que son final por defecto) y con los tipos nullable.
MockK es la alternativa nativa de Kotlin y la diferencia se nota:
@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()
}
}Comparado con Mockito:
| Aspecto | MockK | Mockito |
|---|---|---|
| Sintaxis | DSL nativo Kotlin (every, verify) | API Java (when, verify) |
| Clases final | Funciona sin configuración | Necesita mockito-extensions o open |
| Null safety | Respeta tipos nullable | Puede devolver null en tipos no-null |
| Coroutines | coEvery, coVerify | No soportado nativamente |
| Captura de argumentos | slot<T>(), captured | ArgumentCaptor, más verboso |
La librería springmockk es el puente para usar MockK con las anotaciones de Spring Test (@MockkBean en lugar de @MockBean). Funciona bien y el código queda más limpio.
Un detalle importante: los nombres de test con backticks `should return article by slug` son una de esas cosas pequeñas de Kotlin que mejoran mucho la legibilidad de los tests. En Java tendrías shouldReturnArticleBySlug y pierdes la claridad de la frase natural.
Los errores que aparecen de verdad
Después de varios meses con Spring Boot y Kotlin, estos son los errores que más tiempo me han costado:
1. Olvidar el plugin all-open
Sin kotlin("plugin.spring"), tus clases @Service y @Configuration son final. Spring necesita crear proxies (con CGLIB) para la inyección de dependencias, transacciones y AOP. Si la clase es final, no puede. El error a veces es claro (“cannot subclass final class”), pero a veces simplemente el bean no se inyecta o las transacciones no funcionan.
2. Lazy loading fuera de transacción
Esto pasa en Java también, pero en Kotlin es más común porque tiendes a acceder a propiedades directamente. Si tienes un article.category.name fuera de una sesión de Hibernate, LazyInitializationException. La solución es usar @Transactional en el servicio o hacer fetch joins en la query:
@Query("SELECT a FROM Article a JOIN FETCH a.category WHERE a.slug = :slug")
fun findBySlugWithCategory(@Param("slug") slug: String): Article?3. Jackson y las data classes
Jackson necesita saber cómo deserializar las data classes de Kotlin. Sin jackson-module-kotlin en las dependencias, Jackson intenta usar un constructor sin argumentos (que las data classes no tienen) y falla con errores crípticos. Asegúrate de tenerlo siempre.
4. Spring y los valores por defecto de Kotlin
Si defines un controlador con parámetros con valores por defecto:
@GetMapping
fun list(@RequestParam page: Int = 0, @RequestParam size: Int = 20): Page<Article>Spring Boot no respeta los valores por defecto de Kotlin. Necesitas usar @RequestParam(required = false, defaultValue = "0") o configurar el parámetro como nullable y manejar el default en el código. Esto está parcialmente resuelto en versiones recientes con el plugin kotlin-reflect, pero no siempre funciona como esperas.
5. Coroutines y Spring WebFlux
Si usas coroutines con Spring WebFlux, todo funciona bastante bien en el camino feliz. Pero el manejo de errores y las transacciones reactivas tienen trampas. @Transactional no funciona directamente con funciones suspend. Necesitas TransactionalOperator o @Transactional con ReactiveTransactionManager. Es uno de esos sitios donde la documentación dice “soportado” pero la realidad tiene matices.
Lo que realmente mejora respecto a Java
Después de usar ambos lenguajes en proyectos Spring Boot reales, estas son las mejoras que noto de verdad:
Null safety en el código de negocio. No en la capa de infraestructura (JPA, Spring), sino en la lógica de negocio. Cuando escribes un servicio que transforma datos, las extension functions y los tipos nullable eliminan una categoría entera de bugs. El típico if (x != null && x.getY() != null && x.getY().getZ() != null) de Java desaparece con x?.y?.z.
DTOs y mappers. Las data classes para DTOs y las funciones de extensión para mapear entre entidades y DTOs son mucho más limpias que en 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 ?: "Sin categoría"
)En Java necesitarías una clase separada, un builder o MapStruct. Aquí son cinco líneas.
Scope functions. let, also, apply, run son útiles para configurar objetos o encadenar operaciones sin variables intermedias. No es revolucionario, pero reduce ruido.
Tests legibles. Los nombres con backticks, las funciones de extensión para builders de test, y la sintaxis general hacen que los tests sean más fáciles de leer y escribir.
Lo que no mejora tanto
La capa de persistencia. JPA fue diseñado para Java. El modelo de entidades mutables con proxies no encaja con Kotlin idiomático. Puedes hacerlo funcionar, pero siempre sientes que estás luchando contra el framework.
La curva de aprendizaje del equipo. Si tu equipo es de Java, la transición no es solo aprender la sintaxis. Es cambiar hábitos: pensar en inmutabilidad, usar extension functions en vez de utility classes, entender las scope functions sin abusar de ellas. He visto código Kotlin que es Java con val y fun. Funciona, pero desaprovecha el lenguaje.
El ecosistema de librerías. La mayoría de librerías del ecosistema Spring son Java-first. Funcionan con Kotlin, pero la experiencia no siempre es nativa. MockK, kotlinx-serialization y las coroutines son excepciones, pero muchas librerías de infraestructura siguen siendo más naturales en Java.
Tiempo de compilación. El compilador de Kotlin es más lento que el de Java. En proyectos grandes, la diferencia es notable. El compilador K2 mejora esto, pero sigue siendo un factor.
Entonces, merece la pena?
Sí, pero con matices. Si empiezas un proyecto nuevo con Spring Boot y tienes experiencia con Kotlin, la productividad mejora en la capa de negocio, los DTOs, los tests y el código general. Pero la capa de persistencia con JPA va a requerir compromisos.
Si estás migrando un proyecto existente de Java, hazlo gradual. Kotlin y Java conviven bien en el mismo proyecto. Empieza por los tests, luego los DTOs, luego los servicios nuevos. Dejar las entidades JPA en Java y escribir el resto en Kotlin es una estrategia perfectamente válida.
Lo que no haría es migrar entidades JPA existentes a data classes de Kotlin “porque sí”. El beneficio es mínimo y los riesgos de introducir bugs sutiles con equals, hashCode y lazy loading son reales.
Spring Boot con Kotlin es una combinación productiva, pero no mágica. Lo que más mejora es la calidad del código de negocio. Lo que menos mejora es la relación con JPA. Saber dónde están los límites es lo que te ahorra tiempo de verdad.


