Kotlin e Java no mesmo projeto Spring Boot com Maven
1. Visão geral
Muitas equipes querem usar Kotlin, mas não conseguem reescrever toda a base de código Java. Felizmente, Kotlin roda na JVM, então os dois idiomas podem ficar no mesmo módulo e se chamar diretamente.
Neste artigo, você vai configurar Maven para compilar tanto Kotlin quanto Java. Depois você vai lidar com nullability quando Kotlin chama Java e usar as anotações @JvmStatic, @JvmOverloads, @JvmName para facilitar a chamada de Kotlin por Java. No final, como integrar com Spring Boot 3.x e os erros mais comuns.
2. Configuração do Maven
2.1. Dependências
O projeto usa spring-boot-starter-parent 3.x e Java 21. Você adiciona a biblioteca padrão do Kotlin e kotlin-reflect. Spring precisa de kotlin-reflect para ler os parâmetros do construtor e valores padrão da classe 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>
O Spring Boot já gerencia a versão dos artefatos Kotlin. A propriedade kotlin.version é apenas para sobrescrever a versão padrão.
2.2. Ordem de compilação
Esta é a parte mais importante. Kotlin deve ser compilado antes de Java. O compilador Kotlin entende o código-fonte Java. Ele lê arquivos .java para resolver tipos, mas não compila esses arquivos. Por outro lado, javac não consegue ler arquivos .kt. Java só vê classes Kotlin quando os arquivos .class do Kotlin já existem.
<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>
Alguns pontos a observar na configuração acima:
sourceDirsdeclara também o diretóriojava. Assim, o compilador Kotlin consegue ler as classes Java quando Kotlin chama Java.- As duas execuções
default-compileedefault-testCompilesão desativadas com<phase>none</phase>. Depois elas são declaradas novamente com um novo id. Maven executa plugins na mesma phase na ordem declarada, entãojavacvai rodar depois de Kotlin. -Xjsr305=strictativa verificação rigorosa de nullability para as anotações do Spring (veja a seção 3).<plugin>spring</plugin>é o plugin all-open para Spring (veja a seção 5).
A estrutura de diretórios ficará assim:
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. Chamando Java a partir de Kotlin
3.1. Getter vira property
Suponha que você tenha uma classe Java legada:
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;
}
}
Do Kotlin, os pares getX()/setX() são acessados como properties:
package com.nonla.shop
fun greeting(customer: Customer): String {
val contact = customer.email ?: "sem email"
return "Olá ${customer.name} ($contact)"
}
Kotlin chama customer.name, mas na verdade está chamando getName(). Com Customer("Lan", null), a função retorna:
Olá Lan (sem email)
3.2. Platform type e nullability
Java não tem o conceito de nullable no sistema de tipos. Por isso, quando encontra um tipo Java sem anotação, Kotlin o trata como platform type, escrito como String!. Com platform type, o compilador não lança erro. Se o valor é null e você o usa como non-null, o programa vai lançar NullPointerException em tempo de execução.
@Nullable e @NonNull do Spring são meta-anotadas com JSR-305. Quando você ativa -Xjsr305=strict, Kotlin entende que getEmail() retorna String?. O código a seguir não vai compilar:
val length = customer.email.length // erro: Apenas chamadas seguras (?.) ou asseguradas non-null (!!.) são permitidas
Você corrige usando customer.email?.length ?: 0.
Nota: Você não quer anotar cada método? Coloque
@NonNullApido Spring empackage-info.java. Esta anotação considera todos os parâmetros e valores de retorno do pacote como non-null, exceto onde há@Nullable.
4. Chamando Kotlin a partir de Java
Kotlin tem muitos recursos que Java não tem: funções de nível superior, parâmetros padrão, companion object. Algumas anotações ajudam a API Kotlin parecer mais “natural” quando chamada de Java.
4.1. Data class e funções de nível superior
@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 anotação tem um papel:
@file:JvmName("OrderPricing"): Por padrão, as funções de nível superior emOrder.ktvão para uma classe chamadaOrderKt. Esta anotação muda o nome dessa classe paraOrderPricing.@JvmOverloadsno construtor: Java não entende parâmetros padrão. Esta anotação gera um overload adicionalOrder(String, long), então Java não precisa passarnullparacouponCode.@JvmOverloadsna função: gera três overloads que recebem 1, 2 e 3 parâmetros, respectivamente.
O código Java chama Kotlin assim:
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)
A property val amount do Kotlin aparece em Java como getAmount(). Os métodos toString(), equals(), hashCode() da data class funcionam normalmente. Note que Order é imutável: Java tem apenas getter, sem setter.
4.2. Companion object e @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()
}
}
De Java:
String id = OrderIdGenerator.next(); // "ORD-1"
String prefix = OrderIdGenerator.PREFIX; // constante static graças a const
Sem @JvmStatic, Java teria que chamar OrderIdGenerator.Companion.next(). Essa forma funciona, mas é longa e expõe detalhes de implementação. Para properties que não são const, use @JvmField para expô-lo como um campo public static.
4.3. Exceção verificada e @Throws
Kotlin não tem exceções verificadas. Se uma função Kotlin lança IOException e você não declara, Java não conseguirá fazer catch (IOException e) porque javac diz que essa exceção nunca é lançada. Use @Throws para resolver:
@Throws(java.io.IOException::class)
fun loadInvoice(path: String): String = java.io.File(path).readText()
5. Integração com Spring Boot
Classes e métodos em Kotlin são final por padrão. Enquanto isso, Spring cria proxies (CGLIB) para beans com @Transactional, @Async, @Cacheable ou @Configuration. Proxies CGLIB precisam herdar a classe original. O plugin spring que você declarou na seção 2 automaticamente faz open todas as classes marcadas com @Component, @Service, @Transactional… e seus 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 "Cobrado $total VND de ${customer.name} para ${order.id}"
}
}
Um controller Java injeta esse bean Kotlin normalmente via construtor:
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));
}
}
O resultado ao chamar POST /checkout:
Cobrado 600000 VND de Lan para ORD-1
O pedido ultrapassa o limite de 500.000, então tem frete grátis.
Sem o plugin spring, a aplicação não vai iniciar se você tiver uma classe @Configuration escrita em Kotlin. Com outros beans, @Transactional pode ser ignorado sem nenhum aviso.
6. Escrevendo testes
Escrever testes em Java que chamam código Kotlin é uma forma útil de verificar se a API Kotlin é amigável com Java. Se o teste é difícil de escrever, seus usuários Java também enfrentarão dificuldades.
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));
}
}
Execute mvn test. No log, você verá Kotlin compilado antes de 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. Erros comuns
Java relata cannot find symbol para a classe Kotlin. A causa quase sempre é que javac roda antes do compilador Kotlin. Verifique se default-compile foi desativado corretamente e a ordem de declaração dos plugins na seção 2.2.
NullPointerException quando Kotlin chama Java. Essa é uma consequência do platform type. Você deve adicionar @Nullable/@NonNull (ou @NonNullApi) ao código Java e ativar -Xjsr305=strict. Outra forma é declarar o tipo explicitamente no limite, por exemplo val email: String? = customer.email.
Kotlin não vê os getters gerados pelo Lombok. O compilador Kotlin lê o código-fonte Java antes do Lombok rodar. Por isso, ele não sabe quais métodos @Getter ou @Data vão gerar. Você pode adicionar o plugin compiler lombok para Kotlin (artefato kotlin-maven-lombok). Uma forma mais elegante é converter as classes Lombok que Kotlin precisa usar para Java record ou Kotlin data class.
Entidade JPA escrita em Kotlin. Hibernate precisa que a entidade tenha um construtor sem parâmetros e não seja final. Você adiciona o plugin jpa (no-arg, artefato kotlin-maven-noarg) junto com o plugin spring. Além disso, você não deve usar data class para entidades, porque o equals/hashCode gerado automaticamente pode causar problemas com lazy loading.
Nota: Quando não misturar os dois idiomas? Se o time tem apenas uma ou duas pessoas que conhecem Kotlin, o custo de revisão e manutenção aumenta. O tempo de build também é mais lento porque há dois compiladores. Outra opção é separar Kotlin em um módulo Maven diferente. O limite entre os dois idiomas fica claro, e você evita dependências circulares entre arquivos
.kte.java.
Uma regra prática: comece com código Kotlin com poucas dependências, como DTOs, funções utilitárias e testes. Não comece reescrevendo a lógica de negócios central.
8. Conclusão
Kotlin e Java podem coexistir no mesmo módulo Maven. Há apenas uma condição: o compilador Kotlin deve rodar antes de javac e conseguir ler os dois diretórios de código-fonte. Quando Kotlin chama Java, preste atenção ao platform type e use anotações de nullability com -Xjsr305=strict. Quando Java chama Kotlin, @JvmName, @JvmOverloads, @JvmStatic e @Throws tornam a API muito mais fácil de usar. Com Spring Boot, o plugin spring (all-open) é obrigatório para que os proxies funcionem corretamente. Finalmente, escrever testes em Java para código Kotlin é a forma mais rápida de saber se sua API é realmente amigável com Java.