Spring Boot amb Kotlin: el bo, l'incòmode i el que ningú no t'explica al principi
Experiència real amb Spring Boot i Kotlin: configuració, JPA, null safety, testing i els problemes que apareixen de veritat.

La primera vegada que vaig arrencar un projecte Spring Boot amb Kotlin vaig pensar que tot seria com Java però amb menys codi. Tècnicament no anava errat, però aquesta frase omet la meitat de la història. Hi ha coses que milloren de forma espectacular, hi ha friccions que no t’esperes, i hi ha trampes que només descobreixes quan el projecte ja té certa complexitat i comences a barallar-te amb JPA, amb els tests o amb el propi framework intentant fer coses que Kotlin no vol que facis.
Aquest article no és un tutorial de “com començar amb Spring Boot i Kotlin”. És el que m’hauria agradat llegir després de portar un parell de mesos treballant amb la combinació i començar a notar on fa mal.
La configuració inicial: Gradle amb Kotlin DSL
El primer contacte sol ser el build.gradle.kts. Si vens de Gradle amb Groovy, el canvi a Kotlin DSL és benvingut: autocompletat real a l’IDE, tipat, refactoring que funciona. Però la configuració de Spring Boot amb Kotlin requereix alguns plugins que en Java no necessites.
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")
}
}Hi ha tres plugins que són crítics i que si no els poses, tindràs errors confusos:
kotlin("plugin.spring"): Obre automàticament les classes anotades amb@Component,@Service,@Configuration, etc. Sense això, Spring no pot crear proxies i els beans no s’injecten.kotlin("plugin.jpa"): Genera constructors sense arguments per a les entitats JPA. Sense això, Hibernate no pot instanciar les teves entitats.-Xjsr305=strict: Fa que les anotacions de nul·labilitat de Java (@Nullable,@NonNull) es respectin al tipus de Kotlin. Sense això, un mètode de Spring que retornanullno et dona warning.
Si vens de Java, aquests plugins són invisibles perquè Java ja compleix aquestes condicions per defecte. A Kotlin, has de declarar explícitament que vols aquest comportament.
Data classes i JPA: el conflicte que ningú no t’avisa
Aquest és probablement el punt on més gent es frustra. Kotlin t’empeny cap a data classes immutables. JPA necessita entitats mutables amb un constructor sense arguments i propietats que es puguin modificar. Són dues filosofies oposades.
El teu primer instint és escriure alguna cosa així:
@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. Arrenca. I tres setmanes després comences a tenir problemes.
El primer: equals() i hashCode() generats per data class inclouen tots els camps, inclòs id. Això vol dir que una entitat sense persistir (id = 0) i la mateixa entitat ja persistida (id = 47) són “diferents” segons equals. Si les fiques en un Set o les compares en tests, tens bugs silenciosos.
El segon: les relacions lazy. Si category és val i lazy, Hibernate necessita un proxy per carregar-la després. Però data class genera equals i hashCode que accedeixen a category, cosa que pot disparar la càrrega lazy en moments inesperats. En el pitjor cas, fora d’una transacció, i et salta una LazyInitializationException.
La solució que faig servir després de provar-ne varies:
@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 és bonic. Sembla Java amb roba de Kotlin. Però és el que funciona sense sorpreses. Faig servir var perquè Hibernate necessita mutar els camps, id és nullable perquè abans de persistir no té valor, i equals/hashCode es basen només en l’identificador.
La regla que segueixo: data classes per a DTOs i value objects. Classes normals per a entitats JPA. Barrejar ambdós mons genera més problemes dels que resol.
Null safety amb Spring: la promesa i la realitat
El sistema de nul·labilitat de Kotlin és una de les millors raons per fer-lo servir. Però quan el juntes amb Spring, hi ha zones grises.
Spring Boot 3 té bon suport per als tipus nullable de Kotlin. Si defineixes un paràmetre d’un controlador com a String?, Spring el tracta com a opcional. Si és String, el tracta com a obligatori i retorna 400 si no arriba. Això funciona bé:
@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)
)
}
}On la cosa es complica és amb JPA queries. findById retorna Optional<T> a Java. A Kotlin pots definir el mètode del repositori així:
interface ArticleRepository : JpaRepository<Article, Long> {
fun findBySlug(slug: String): Article?
fun findAllByCategory(category: Category): List<Article>
}Spring Data entén que Article? vol dir que pot retornar null. Però si t’equivoques i poses Article (sense interrogant) i el registre no existeix, Spring retorna null de totes maneres. A Java no ho notaries. A Kotlin, tens un null on el compilador et va prometre que no n’hi hauria. Un NullPointerException a Kotlin és sempre una traïció.
Per això l’opció -Xjsr305=strict és important: fa que els tipus de les llibreries Java que fan servir anotacions JSR-305 s’interpretin estrictament. No cobreix tots els casos, però ajuda bastant.
Testing: MockK vs Mockito
Aquí hi ha una millora clara. Testejar Spring amb Kotlin fent servir Mockito es pot fer, però és incòmode. Mockito va ser dissenyat per a Java i té friccions amb les classes de Kotlin (que són final per defecte) i amb els tipus nullable.
MockK és l’alternativa nativa de Kotlin i la diferència es 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()
}
}Comparat amb Mockito:
| Aspecte | MockK | Mockito |
|---|---|---|
| Sintaxi | DSL natiu Kotlin (every, verify) | API Java (when, verify) |
| Classes final | Funciona sense configuració | Necessita mockito-extensions o open |
| Null safety | Respecta tipus nullable | Pot retornar null en tipus no-null |
| Coroutines | coEvery, coVerify | No suportat nativament |
| Captura d’arguments | slot<T>(), captured | ArgumentCaptor, més verbós |
La llibreria springmockk és el pont per fer servir MockK amb les anotacions de Spring Test (@MockkBean en lloc de @MockBean). Funciona bé i el codi queda més net.
Un detall important: els noms de test amb backticks `should return article by slug` són una d’aquelles coses petites de Kotlin que milloren molt la llegibilitat dels tests. A Java tindries shouldReturnArticleBySlug i perds la claredat de la frase natural.
Els errors que apareixen de veritat
Després de diversos mesos amb Spring Boot i Kotlin, aquests són els errors que més temps m’han costat:
1. Oblidar el plugin all-open
Sense kotlin("plugin.spring"), les teves classes @Service i @Configuration són final. Spring necessita crear proxies (amb CGLIB) per a la injecció de dependències, transaccions i AOP. Si la classe és final, no pot. L’error de vegades és clar (“cannot subclass final class”), però de vegades simplement el bean no s’injecta o les transaccions no funcionen.
2. Lazy loading fora de transacció
Això passa a Java també, però a Kotlin és més comú perquè tendeixes a accedir a propietats directament. Si tens un article.category.name fora d’una sessió d’Hibernate, LazyInitializationException. La solució és fer servir @Transactional al servei o fer fetch joins a 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 i les data classes
Jackson necessita saber com deserialitzar les data classes de Kotlin. Sense jackson-module-kotlin a les dependències, Jackson intenta fer servir un constructor sense arguments (que les data classes no tenen) i falla amb errors críptics. Assegura’t de tenir-lo sempre.
4. Spring i els valors per defecte de Kotlin
Si defineixes un controlador amb paràmetres amb valors per defecte:
@GetMapping
fun list(@RequestParam page: Int = 0, @RequestParam size: Int = 20): Page<Article>Spring Boot no respecta els valors per defecte de Kotlin. Necessites fer servir @RequestParam(required = false, defaultValue = "0") o configurar el paràmetre com a nullable i gestionar el default al codi. Això està parcialment resolt en versions recents amb el plugin kotlin-reflect, però no sempre funciona com esperes.
5. Coroutines i Spring WebFlux
Si fas servir coroutines amb Spring WebFlux, tot funciona bastant bé en el camí feliç. Però la gestió d’errors i les transaccions reactives tenen trampes. @Transactional no funciona directament amb funcions suspend. Necessites TransactionalOperator o @Transactional amb ReactiveTransactionManager. És un d’aquells llocs on la documentació diu “suportat” però la realitat té matisos.
El que realment millora respecte a Java
Després de fer servir ambdós llenguatges en projectes Spring Boot reals, aquestes són les millores que noto de veritat:
Null safety al codi de negoci. No a la capa d’infraestructura (JPA, Spring), sinó a la lògica de negoci. Quan escrius un servei que transforma dades, les extension functions i els tipus nullable eliminen tota una categoria de bugs. El típic if (x != null && x.getY() != null && x.getY().getZ() != null) de Java desapareix amb x?.y?.z.
DTOs i mappers. Les data classes per a DTOs i les funcions d’extensió per mapejar entre entitats i DTOs són molt més netes que a 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 ?: "Sense categoria"
)A Java necessitaries una classe separada, un builder o MapStruct. Aquí són cinc línies.
Scope functions. let, also, apply, run són útils per configurar objectes o encadenar operacions sense variables intermèdies. No és revolucionari, però redueix soroll.
Tests llegibles. Els noms amb backticks, les funcions d’extensió per a builders de test, i la sintaxi general fan que els tests siguin més fàcils de llegir i escriure.
El que no millora tant
La capa de persistència. JPA va ser dissenyat per a Java. El model d’entitats mutables amb proxies no encaixa amb Kotlin idiomàtic. Pots fer que funcioni, però sempre sents que estàs lluitant contra el framework.
La corba d’aprenentatge de l’equip. Si el teu equip és de Java, la transició no és només aprendre la sintaxi. És canviar hàbits: pensar en immutabilitat, fer servir extension functions en lloc de utility classes, entendre les scope functions sense abusar-ne. He vist codi Kotlin que és Java amb val i fun. Funciona, però desaprofita el llenguatge.
L’ecosistema de llibreries. La majoria de llibreries de l’ecosistema Spring són Java-first. Funcionen amb Kotlin, però l’experiència no sempre és nativa. MockK, kotlinx-serialization i les coroutines són excepcions, però moltes llibreries d’infraestructura continuen sent més naturals a Java.
Temps de compilació. El compilador de Kotlin és més lent que el de Java. En projectes grans, la diferència és notable. El compilador K2 millora això, però continua sent un factor.
Llavors, val la pena?
Sí, però amb matisos. Si comences un projecte nou amb Spring Boot i tens experiència amb Kotlin, la productivitat millora a la capa de negoci, els DTOs, els tests i el codi general. Però la capa de persistència amb JPA requerirà compromisos.
Si estàs migrant un projecte existent de Java, fes-ho gradual. Kotlin i Java conviuen bé al mateix projecte. Comença pels tests, després els DTOs, després els serveis nous. Deixar les entitats JPA a Java i escriure la resta a Kotlin és una estratègia perfectament vàlida.
El que no faria és migrar entitats JPA existents a data classes de Kotlin “perquè sí”. El benefici és mínim i els riscos d’introduir bugs subtils amb equals, hashCode i lazy loading són reals.
Spring Boot amb Kotlin és una combinació productiva, però no màgica. El que més millora és la qualitat del codi de negoci. El que menys millora és la relació amb JPA. Saber on són els límits és el que t’estalvia temps de veritat.


