Kotlin y Java en un mismo proyecto Spring Boot con Maven
1. Descripción general
Muchos equipos quieren usar Kotlin pero no pueden reescribir toda la base de código Java. Por suerte, Kotlin se ejecuta en la JVM, así que los dos lenguajes pueden estar en el mismo módulo e invocarse mutuamente de forma directa.
En este artículo, configurarás Maven para compilar tanto Kotlin como Java. Luego, manejarás la nullability cuando Kotlin llama a Java, y usarás anotaciones @JvmStatic, @JvmOverloads y @JvmName para que Java pueda llamar a Kotlin más fácilmente. Al final, veremos cómo integrar con Spring Boot 3.x y los errores más comunes.
2. Configuración de Maven
2.1. Dependencias
El proyecto usa spring-boot-starter-parent 3.x y Java 21. Añades la biblioteca estándar de Kotlin y kotlin-reflect. Spring necesita kotlin-reflect para leer los parámetros del constructor y los valores por defecto de las clases Kotlin.
<properties>
<java.version>21</java.version>
<kotlin.version>2.1.0</kotlin.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Spring Boot ya gestiona las versiones de los artefactos de Kotlin. La propiedad kotlin.version solo se usa para sobrescribir la versión por defecto.
2.2. Orden de compilación
Esta es la parte más importante. Kotlin debe compilarse antes que Java. El compilador de Kotlin entiende código fuente Java. Lee archivos .java para resolver tipos, pero no compila esos archivos. Por el contrario, javac no puede leer archivos .kt en absoluto. Java solo ve las clases Kotlin cuando los archivos .class de Kotlin ya están generados.
<build>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<configuration>
<jvmTarget>21</jvmTarget>
<args>
<arg>-Xjsr305=strict</arg>
</args>
<compilerPlugins>
<plugin>spring</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
<executions>
<execution>
<id>compile</id>
<goals><goal>compile</goal></goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
<sourceDir>${project.basedir}/src/main/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
<execution>
<id>test-compile</id>
<goals><goal>test-compile</goal></goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
<sourceDir>${project.basedir}/src/test/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<executions>
<execution>
<id>default-compile</id>
<phase>none</phase>
</execution>
<execution>
<id>default-testCompile</id>
<phase>none</phase>
</execution>
<execution>
<id>java-compile</id>
<phase>compile</phase>
<goals><goal>compile</goal></goals>
</execution>
<execution>
<id>java-test-compile</id>
<phase>test-compile</phase>
<goals><goal>testCompile</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Algunos puntos importantes en la configuración anterior:
sourceDirsdeclara también el directoriojava. Gracias a esto, el compilador de Kotlin puede leer las clases Java cuando Kotlin llama a Java.- Las dos ejecuciones
default-compileydefault-testCompilese desactivan con<phase>none</phase>. Luego se declaran nuevamente con un id diferente. Maven ejecuta los plugins de la misma fase en el orden declarado, así quejavacse ejecutará después de Kotlin. -Xjsr305=stricthabilita la comprobación estricta de nullability para las anotaciones de Spring (ver sección 3).<plugin>spring</plugin>es el complemento all-open para Spring (ver sección 5).
La estructura de directorios será la siguiente:
src/main/java/com/nonla/shop/Customer.java
src/main/java/com/nonla/shop/CheckoutController.java
src/main/kotlin/com/nonla/shop/Order.kt
src/main/kotlin/com/nonla/shop/PaymentService.kt
src/test/java/com/nonla/shop/OrderPricingTest.java
3. Llamar a Java desde Kotlin
3.1. Los getters se convierten en propiedades
Supongamos que tienes una clase Java antigua:
package com.nonla.shop;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
public class Customer {
private final String name;
private final String email;
public Customer(String name, String email) {
this.name = name;
this.email = email;
}
@NonNull
public String getName() {
return name;
}
@Nullable
public String getEmail() {
return email;
}
}
Desde Kotlin, los pares getX()/setX() se acceden como propiedades:
package com.nonla.shop
fun greeting(customer: Customer): String {
val contact = customer.email ?: "sin correo aún"
return "Hola ${customer.name} ($contact)"
}
Kotlin llama a customer.name pero en realidad está llamando a getName(). Con Customer("Lan", null), la función devuelve:
Hola Lan (sin correo aún)
3.2. Platform type y nullability
Java no tiene el concepto de nullable en su sistema de tipos. Por lo tanto, cuando encuentras un tipo Java sin anotación, Kotlin lo considera un platform type, escrito como String!. Con un platform type, el compilador no genera errores. Si el valor es null y lo usas como non-null, el programa lanzará una NullPointerException en tiempo de ejecución.
@Nullable y @NonNull de Spring están meta-anotados con JSR-305. Cuando activas -Xjsr305=strict, Kotlin entiende que getEmail() devuelve String?. El siguiente código no compilará:
val length = customer.email.length // error: Solo se permiten llamadas seguras (?.) o aserciones de non-null (!!.)
Lo corriges con customer.email?.length ?: 0.
Nota: ¿No quieres anotar cada método? Coloca
@NonNullApide Spring enpackage-info.java. Esta anotación considera que todos los parámetros y valores de retorno en el paquete son non-null, excepto donde haya@Nullable.
4. Llamar a Kotlin desde Java
Kotlin tiene muchas características que Java no tiene: funciones top-level, parámetros por defecto, companion objects. Algunas anotaciones ayudan a que la API de Kotlin sea más “natural” cuando se llama desde Java.
4.1. Data class y funciones top-level
@file:JvmName("OrderPricing")
package com.nonla.shop
data class Order @JvmOverloads constructor(
val id: String,
val amount: Long,
val couponCode: String? = null
)
@JvmOverloads
fun totalWithShipping(
order: Order,
shippingFee: Long = 30_000,
freeShipThreshold: Long = 500_000
): Long =
if (order.amount >= freeShipThreshold) order.amount
else order.amount + shippingFee
Cada anotación tiene su propósito:
@file:JvmName("OrderPricing"): Por defecto, las funciones top-level enOrder.ktse colocan en la claseOrderKt. Esta anotación cambia el nombre de esa clase aOrderPricing.@JvmOverloadsen el constructor: Java no entiende los parámetros por defecto. Esta anotación genera una sobrecarga adicionalOrder(String, long), para que Java no tenga que pasarnullacouponCode.@JvmOverloadsen la función: genera tres sobrecargas, que aceptan 1, 2 y 3 parámetros respectivamente.
El código Java que llama a Kotlin es así:
var order = new Order("ORD-1", 200_000L);
long total = OrderPricing.totalWithShipping(order);
System.out.println(order.getAmount() + " -> " + total);
System.out.println(order);
200000 -> 230000
Order(id=ORD-1, amount=200000, couponCode=null)
La propiedad val amount de Kotlin aparece en Java como getAmount(). Los métodos toString(), equals() y hashCode() de la data class funcionan normalmente. Ten en cuenta que Order es inmutable: Java solo tiene getters, sin setters.
4.2. Companion object y @JvmStatic
package com.nonla.shop
import java.util.concurrent.atomic.AtomicLong
class OrderIdGenerator {
companion object {
const val PREFIX = "ORD-"
private val counter = AtomicLong()
@JvmStatic
fun next(): String = PREFIX + counter.incrementAndGet()
}
}
Desde Java:
String id = OrderIdGenerator.next(); // "ORD-1"
String prefix = OrderIdGenerator.PREFIX; // constante static gracias a const
Si falta @JvmStatic, Java tendría que llamar a OrderIdGenerator.Companion.next(). Esta forma funciona pero es larga y expone detalles de implementación. Para propiedades que no son const, uses @JvmField para exponerlas como un campo public static.
4.3. Excepciones checked y @Throws
Kotlin no tiene excepciones checked. Si una función Kotlin lanza IOException pero no la declaras, Java no podrá hacer catch (IOException e) porque javac dirá que esa excepción nunca se lanza. Usas @Throws para resolver esto:
@Throws(java.io.IOException::class)
fun loadInvoice(path: String): String = java.io.File(path).readText()
5. Integración con Spring Boot
Las clases y métodos en Kotlin son final por defecto. Mientras tanto, Spring crea proxies (CGLIB) para los beans con @Transactional, @Async, @Cacheable o @Configuration. Los proxies CGLIB necesitan heredar de la clase original. El plugin spring que declaraste en la sección 2 automáticamente hace open todas las clases marcadas con @Component, @Service, @Transactional, etc., junto con sus métodos.
package com.nonla.shop
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class PaymentService {
@Transactional
fun pay(order: Order, customer: Customer): String {
val total = totalWithShipping(order)
return "Se cobraron $total VND a ${customer.name} para ${order.id}"
}
}
Un controlador Java inyecta este bean Kotlin normalmente a través del constructor:
package com.nonla.shop;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CheckoutController {
private final PaymentService paymentService;
public CheckoutController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@PostMapping("/checkout")
public String checkout() {
var order = new Order(OrderIdGenerator.next(), 600_000L);
return paymentService.pay(order, new Customer("Lan", null));
}
}
El resultado al llamar a POST /checkout:
Se cobraron 600000 VND a Lan para ORD-1
El pedido excede el umbral de 500.000 así que obtiene envío gratis.
Sin el plugin spring, la aplicación no se iniciará si tienes una clase @Configuration escrita en Kotlin. Con otros beans, @Transactional podría ignorarse sin ninguna advertencia.
6. Escribir tests
Los tests escritos en Java que llaman a código Kotlin son una buena forma de verificar si la API de Kotlin es amigable con Java. Si el test es difícil de escribir, los usuarios Java también tendrán dificultades.
package com.nonla.shop;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class OrderPricingTest {
@Test
void addsDefaultShippingFeeBelowThreshold() {
var order = new Order("ORD-1", 200_000L);
assertThat(OrderPricing.totalWithShipping(order)).isEqualTo(230_000L);
}
@Test
void freeShippingWhenAmountReachesThreshold() {
var order = new Order("ORD-2", 500_000L);
assertThat(OrderPricing.totalWithShipping(order)).isEqualTo(500_000L);
}
@Test
void customShippingFeeViaOverload() {
var order = new Order("ORD-3", 100_000L, "TET2026");
assertThat(OrderPricing.totalWithShipping(order, 15_000L)).isEqualTo(115_000L);
assertThat(order.getCouponCode()).isEqualTo("TET2026");
}
@Test
void dataClassEqualityWorksFromJava() {
assertThat(new Order("ORD-4", 1L)).isEqualTo(new Order("ORD-4", 1L));
}
}
Ejecuta mvn test. En el log, verás que Kotlin se compila antes que Java:
[INFO] --- kotlin:2.1.0:compile (compile) @ shop ---
[INFO] --- compiler:3.13.0:compile (java-compile) @ shop ---
[INFO] --- kotlin:2.1.0:test-compile (test-compile) @ shop ---
[INFO] --- compiler:3.13.0:testCompile (java-test-compile) @ shop ---
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
7. Errores comunes
Java reporta cannot find symbol con una clase Kotlin. La causa casi siempre es que javac se ejecuta antes que el compilador de Kotlin. Verifica que hayas desactivado default-compile y el orden de declaración de plugins en la sección 2.2.
NullPointerException cuando Kotlin llama a Java. Esta es una consecuencia del platform type. Deberías añadir @Nullable/@NonNull (o @NonNullApi) al código Java y activar -Xjsr305=strict. Otra forma es declarar el tipo explícitamente en el límite, por ejemplo val email: String? = customer.email.
Kotlin no ve los getters generados por Lombok. El compilador de Kotlin lee el código fuente Java antes de que Lombok se ejecute. Por lo tanto, no sabe qué métodos generará @Getter o @Data. Puedes añadir el plugin compilador lombok para Kotlin (artefacto kotlin-maven-lombok). Una forma más limpia es convertir las clases Lombok que Kotlin necesita a Java records o data classes de Kotlin.
Entidades JPA escritas en Kotlin. Hibernate necesita que la entidad tenga un constructor sin argumentos y que no sea final. Añades el plugin jpa (no-arg, artefacto kotlin-maven-noarg) junto al plugin spring. Además, no deberías usar data class para entidades, ya que los equals/hashCode generados automáticamente causarán problemas con lazy loading.
Nota: ¿Cuándo no deberías mezclar los dos lenguajes? Si tu equipo solo tiene una o dos personas que conocen Kotlin, el costo de revisión y mantenimiento aumentará. El tiempo de compilación también será más lento porque hay dos compiladores. Otra opción es separar Kotlin en su propio módulo Maven. En ese caso, el límite entre los dos lenguajes será claro, y evitarás dependencias circulares entre archivos
.kty.java.
Una regla práctica: comienza con código Kotlin de bajo acoplamiento, como DTOs, funciones utilitarias y tests. No empieces intentando reescribir el núcleo del negocio.
8. Conclusión
Kotlin y Java pueden convivir en un módulo Maven. Solo necesitas una cosa: el compilador de Kotlin debe ejecutarse antes que javac y poder leer ambos directorios de código fuente. Cuando Kotlin llama a Java, ten cuidado con los platform types y usa anotaciones de nullability combinadas con -Xjsr305=strict. Cuando Java llama a Kotlin, @JvmName, @JvmOverloads, @JvmStatic y @Throws hacen que la API sea mucho más fácil de usar. Con Spring Boot, el plugin spring (all-open) es obligatorio para que los proxies funcionen correctamente. Finalmente, escribir tests en Java para código Kotlin es la forma más rápida de saber si tu API es realmente amigable con Java.