Kotlin and Java in a Single Spring Boot Project with Maven
1. Overview
Many teams want to use Kotlin but can’t rewrite their entire Java codebase. Fortunately, Kotlin runs on the JVM, so both languages can coexist in a single module and call each other directly.
In this article, you’ll configure Maven to compile both Kotlin and Java. Then you’ll handle nullability when Kotlin calls Java and use annotations like @JvmStatic, @JvmOverloads, and @JvmName to make it easier for Java to call Kotlin. Finally, we’ll cover integration with Spring Boot 3.x and common pitfalls.
2. Maven Configuration
2.1. Dependencies
The project uses spring-boot-starter-parent 3.x and Java 21. You add Kotlin’s standard library and kotlin-reflect. Spring needs kotlin-reflect to read constructor parameters and default values from Kotlin classes.
<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 manages versions for Kotlin artifacts. The kotlin.version property is used only to override the default version.
2.2. Compilation Order
This is the most important part. Kotlin must be compiled before Java. The Kotlin compiler understands Java source code. It reads .java files to resolve types, but doesn’t compile them. Conversely, javac can’t read .kt files at all. Java only sees Kotlin classes once Kotlin’s .class files are ready.
<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>
A few points to note about the configuration above:
sourceDirsdeclares both thejavadirectory. This way, the Kotlin compiler can read Java classes when Kotlin calls Java.- Two executions,
default-compileanddefault-testCompile, are disabled with<phase>none</phase>. They’re then redeclared with new IDs. Maven runs plugins in the same phase in the order they’re declared, sojavacruns after Kotlin. -Xjsr305=strictenables strict nullability checking for Spring annotations (see section 3).<plugin>spring</plugin>is the all-open plugin for Spring (see section 5).
Your directory structure will look like this:
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. Calling Java from Kotlin
3.1. Getters become properties
Suppose you have an old Java class:
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;
}
}
From Kotlin, getX()/setX() pairs are accessed as properties:
package com.nonla.shop
fun greeting(customer: Customer): String {
val contact = customer.email ?: "no email yet"
return "Hello ${customer.name} ($contact)"
}
Kotlin calls customer.name but actually calls getName(). With Customer("Lan", null), the function returns:
Hello Lan (no email yet)
3.2. Platform types and nullability
Java doesn’t have the concept of nullability in its type system. So, when encountering a Java type without annotations, Kotlin treats it as a platform type, written as String!. With platform types, the compiler doesn’t enforce type safety. If the value is null and you use it as non-null, the program will throw NullPointerException at runtime.
Spring’s @Nullable and @NonNull are meta-annotated with JSR-305. When -Xjsr305=strict is enabled, Kotlin understands that getEmail() returns String?. The following code will not compile:
val length = customer.email.length // error: Only safe (?.) or non-null asserted (!!.) calls are allowed
You fix it with customer.email?.length ?: 0.
Note: Don’t want to annotate every method? Put Spring’s
@NonNullApiinpackage-info.java. This annotation treats all parameters and return values in the package as non-null, except those marked@Nullable.
4. Calling Kotlin from Java
Kotlin has many features Java lacks: top-level functions, default parameters, companion objects. Several annotations help Kotlin APIs look more “natural” when called from Java.
4.1. Data classes and top-level functions
@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
Each annotation has its own role:
@file:JvmName("OrderPricing"): By default, top-level functions inOrder.ktare placed in a class namedOrderKt. This annotation changes that class name toOrderPricing.@JvmOverloadson constructor: Java doesn’t understand default parameters. This annotation generates an additional overloadOrder(String, long), so Java doesn’t have to passnullforcouponCode.@JvmOverloadson function: generates three overloads, taking 1, 2, and 3 parameters respectively.
Java code calling Kotlin looks like this:
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)
Kotlin’s val amount property appears in Java as getAmount(). The data class’s toString(), equals(), and hashCode() methods work normally. Note that Order is immutable: Java only has getters, no setters.
4.2. Companion objects and @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()
}
}
From Java:
String id = OrderIdGenerator.next(); // "ORD-1"
String prefix = OrderIdGenerator.PREFIX; // constant thanks to const
Without @JvmStatic, Java would have to call OrderIdGenerator.Companion.next(). This works but is verbose and exposes implementation details. For non-const properties, use @JvmField to expose them as public static fields.
4.3. Checked exceptions and @Throws
Kotlin doesn’t have checked exceptions. If a Kotlin function throws IOException but you don’t declare it, Java won’t be able to catch (IOException e) because javac reports that exception is never thrown. You use @Throws to solve this:
@Throws(java.io.IOException::class)
fun loadInvoice(path: String): String = java.io.File(path).readText()
5. Integration with Spring Boot
Classes and methods in Kotlin are final by default. Meanwhile, Spring creates proxies (CGLIB) for beans with @Transactional, @Async, @Cacheable, or @Configuration. CGLIB proxies need to inherit from the base class. The spring plugin you declared in section 2 automatically opens all classes marked with @Component, @Service, @Transactional, etc., along with their methods.
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 "Charged $total VND from ${customer.name} for ${order.id}"
}
}
A Java controller injects this Kotlin bean normally via 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));
}
}
Result when calling POST /checkout:
Charged 600000 VND from Lan for ORD-1
The order exceeds the 500,000 threshold, so it gets free shipping.
Without the spring plugin, the application won’t start if you have a @Configuration class written in Kotlin. With other beans, @Transactional might be silently ignored.
6. Writing Tests
Writing tests in Java that call Kotlin code is a good way to verify whether your Kotlin API is Java-friendly. If tests are hard to write, Java users of your code will also struggle.
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));
}
}
Run mvn test. In the logs, you’ll see Kotlin compiled before 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. Common Errors
Java reports cannot find symbol for Kotlin classes. The cause is almost always that javac runs before the Kotlin compiler. Double-check that you’ve disabled default-compile and verify the plugin declaration order in section 2.2.
NullPointerException when Kotlin calls Java. This is a consequence of platform types. You should add @Nullable/@NonNull (or @NonNullApi) to your Java code and enable -Xjsr305=strict. Another approach is to declare the type explicitly at the boundary, e.g., val email: String? = customer.email.
Kotlin can’t see getters generated by Lombok. The Kotlin compiler reads Java source before Lombok runs, so it doesn’t know what methods @Getter or @Data will generate. You can add the lombok compiler plugin for Kotlin (artifact kotlin-maven-lombok). A simpler approach is to convert Lombok classes that Kotlin needs to use into Java records or Kotlin data classes.
JPA entities written in Kotlin. Hibernate needs entities with no-arg constructors and non-final classes. Add the jpa plugin (no-arg, artifact kotlin-maven-noarg) alongside the spring plugin. Also, avoid using data class for entities, as auto-generated equals/hashCode will cause issues with lazy loading.
Note: When shouldn’t you mix both languages? If your team has only one or two people who know Kotlin, code review and maintenance costs will increase. Build times are also slower because of two compilers. An alternative is to separate Kotlin into its own Maven module. The boundary between the two languages becomes clear, and you avoid circular dependencies between
.ktand.javafiles.
A practical rule: start with low-dependency Kotlin code, like DTOs, utility functions, and tests. Don’t start by rewriting your business logic.
8. Conclusion
Kotlin and Java can coexist in a single Maven module. There’s only one requirement: the Kotlin compiler must run before javac and be able to read both source directories. When Kotlin calls Java, watch out for platform types and use nullability annotations combined with -Xjsr305=strict. When Java calls Kotlin, @JvmName, @JvmOverloads, @JvmStatic, and @Throws make your API much easier to use. With Spring Boot, the spring plugin (all-open) is required for proxies to work correctly. Finally, writing Java tests for Kotlin code is the quickest way to know whether your API is truly Java-friendly.