Kotlin và Java chung một dự án Spring Boot với Maven
1. Tổng quan
Nhiều đội muốn dùng Kotlin nhưng không thể viết lại toàn bộ codebase Java. May mắn là Kotlin chạy trên JVM, nên hai ngôn ngữ có thể nằm chung một module và gọi lẫn nhau trực tiếp.
Trong bài này, bạn sẽ cấu hình Maven để biên dịch cả Kotlin lẫn Java. Sau đó bạn sẽ xử lý chuyện nullability khi Kotlin gọi Java và dùng các annotation @JvmStatic, @JvmOverloads, @JvmName để Java gọi Kotlin dễ hơn. Cuối bài là cách tích hợp với Spring Boot 3.x và những lỗi hay gặp.
2. Cấu hình Maven
2.1. Dependency
Dự án dùng spring-boot-starter-parent 3.x và Java 21. Bạn thêm thư viện chuẩn của Kotlin và kotlin-reflect. Spring cần kotlin-reflect để đọc tham số constructor và giá trị mặc định của lớp 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 đã quản lý version cho các artifact Kotlin. Property kotlin.version chỉ dùng để ghi đè lên version mặc định.
2.2. Thứ tự biên dịch
Đây là phần quan trọng nhất. Kotlin phải được biên dịch trước Java. Trình biên dịch Kotlin hiểu được mã nguồn Java. Nó đọc file .java để resolve kiểu, nhưng không biên dịch các file đó. Ngược lại, javac hoàn toàn không đọc được file .kt. Java chỉ thấy lớp Kotlin khi các file .class của Kotlin đã có sẵn.
<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>
Một vài điểm cần chú ý trong cấu hình trên:
sourceDirskhai báo cả thư mụcjava. Nhờ vậy Kotlin compiler đọc được lớp Java khi Kotlin gọi sang Java.- Hai execution
default-compilevàdefault-testCompilebị tắt bằng<phase>none</phase>. Sau đó chúng được khai báo lại với id mới. Maven chạy các plugin cùng phase theo thứ tự khai báo, nênjavacsẽ chạy sau Kotlin. -Xjsr305=strictbật kiểm tra nullability nghiêm ngặt cho các annotation của Spring (xem mục 3).<plugin>spring</plugin>là plugin all-open dành cho Spring (xem mục 5).
Cấu trúc thư mục sẽ như sau:
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. Gọi Java từ Kotlin
3.1. Getter trở thành property
Giả sử bạn có một lớp Java cũ:
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;
}
}
Từ Kotlin, cặp getX()/setX() được truy cập như property:
package com.nonla.shop
fun greeting(customer: Customer): String {
val contact = customer.email ?: "chưa có email"
return "Xin chào ${customer.name} ($contact)"
}
Kotlin gọi customer.name nhưng thực chất là gọi getName(). Với Customer("Lan", null), hàm trả về:
Xin chào Lan (chưa có email)
3.2. Platform type và nullability
Java không có khái niệm nullable trong hệ thống kiểu. Vì vậy, khi gặp một kiểu Java không có annotation, Kotlin coi nó là platform type, được viết là String!. Với platform type, compiler không bắt lỗi. Nếu giá trị là null và bạn dùng như non-null, chương trình sẽ ném NullPointerException lúc runtime.
@Nullable và @NonNull của Spring được meta-annotate bằng JSR-305. Khi bật -Xjsr305=strict, Kotlin hiểu getEmail() trả về String?. Đoạn code sau sẽ không biên dịch được:
val length = customer.email.length // lỗi: Only safe (?.) or non-null asserted (!!.) calls are allowed
Bạn sửa bằng customer.email?.length ?: 0.
Lưu ý: Bạn không muốn gắn annotation cho từng method? Hãy đặt
@NonNullApicủa Spring trongpackage-info.java. Annotation này coi mọi tham số và giá trị trả về trong package là non-null, trừ những chỗ có@Nullable.
4. Gọi Kotlin từ Java
Kotlin có nhiều tính năng mà Java không có: top-level function, tham số mặc định, companion object. Một số annotation giúp API Kotlin trông “tự nhiên” hơn khi gọi từ Java.
4.1. Data class và top-level function
@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
Từng annotation có vai trò riêng:
@file:JvmName("OrderPricing"): Mặc định, các top-level function trongOrder.ktđược đưa vào lớpOrderKt. Annotation này đổi tên lớp đó thànhOrderPricing.@JvmOverloadstrên constructor: Java không hiểu tham số mặc định. Annotation này sinh thêm overloadOrder(String, long), nhờ vậy Java không phải truyềnnullchocouponCode.@JvmOverloadstrên hàm: sinh ra ba overload, lần lượt nhận 1, 2 và 3 tham số.
Code Java gọi sang Kotlin như sau:
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)
Property val amount của Kotlin hiện ra trong Java dưới dạng getAmount(). Các phương thức toString(), equals(), hashCode() của data class dùng được bình thường. Lưu ý rằng Order là immutable: Java chỉ có getter, không có setter.
4.2. Companion object và @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()
}
}
Từ Java:
String id = OrderIdGenerator.next(); // "ORD-1"
String prefix = OrderIdGenerator.PREFIX; // hằng số static nhờ const
Nếu thiếu @JvmStatic, Java phải gọi OrderIdGenerator.Companion.next(). Cách gọi này chạy được nhưng dài và lộ chi tiết cài đặt. Với property không phải const, bạn dùng @JvmField để expose nó thành field public static.
4.3. Checked exception và @Throws
Kotlin không có checked exception. Nếu hàm Kotlin ném IOException mà bạn không khai báo, Java sẽ không thể catch (IOException e) vì javac báo rằng exception đó không bao giờ được ném ra. Bạn dùng @Throws để giải quyết:
@Throws(java.io.IOException::class)
fun loadInvoice(path: String): String = java.io.File(path).readText()
5. Tích hợp với Spring Boot
Lớp và method trong Kotlin mặc định là final. Trong khi đó, Spring tạo proxy (CGLIB) cho các bean có @Transactional, @Async, @Cacheable hoặc @Configuration. Proxy CGLIB cần kế thừa lớp gốc. Plugin spring mà bạn đã khai báo ở mục 2 tự động open mọi lớp được đánh dấu @Component, @Service, @Transactional… cùng các method của chúng.
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 "Đã thu $total VND của ${customer.name} cho ${order.id}"
}
}
Một controller Java inject bean Kotlin này bình thường qua 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));
}
}
Kết quả khi gọi POST /checkout:
Đã thu 600000 VND của Lan cho ORD-1
Đơn hàng vượt ngưỡng 500.000 nên được miễn phí ship.
Nếu không có plugin spring, ứng dụng sẽ không khởi động được nếu bạn có lớp @Configuration viết bằng Kotlin. Với các bean khác, @Transactional có thể bị bỏ qua mà không có cảnh báo nào.
6. Viết test
Test viết bằng Java gọi code Kotlin là cách tốt để kiểm tra API Kotlin có thân thiện với Java hay không. Nếu test khó viết, người dùng Java của bạn cũng sẽ gặp khó.
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));
}
}
Chạy mvn test. Trong log, bạn sẽ thấy Kotlin được biên dịch trước 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. Lỗi hay gặp
Java báo cannot find symbol với lớp Kotlin. Nguyên nhân gần như luôn là javac chạy trước Kotlin compiler. Bạn hãy kiểm tra lại việc tắt default-compile và thứ tự khai báo plugin ở mục 2.2.
NullPointerException khi Kotlin gọi Java. Đây là hậu quả của platform type. Bạn nên thêm @Nullable/@NonNull (hoặc @NonNullApi) vào code Java và bật -Xjsr305=strict. Một cách khác là khai báo kiểu tường minh ở biên, ví dụ val email: String? = customer.email.
Kotlin không thấy getter do Lombok sinh ra. Kotlin compiler đọc mã nguồn Java trước khi Lombok chạy. Vì vậy, nó không biết @Getter hay @Data sẽ sinh ra method nào. Bạn có thể thêm compiler plugin lombok cho Kotlin (artifact kotlin-maven-lombok). Cách gọn hơn là chuyển các lớp Lombok mà Kotlin cần dùng sang Java record hoặc Kotlin data class.
JPA entity viết bằng Kotlin. Hibernate cần entity có constructor không tham số và không final. Bạn thêm plugin jpa (no-arg, artifact kotlin-maven-noarg) bên cạnh plugin spring. Ngoài ra, bạn không nên dùng data class cho entity, vì equals/hashCode sinh tự động sẽ gây vấn đề với lazy loading.
Lưu ý: Khi nào không nên trộn hai ngôn ngữ? Nếu đội chỉ có một hai người biết Kotlin, chi phí review và bảo trì sẽ tăng. Thời gian build cũng chậm hơn vì có hai compiler. Một lựa chọn khác là tách Kotlin ra module Maven riêng. Ranh giới giữa hai ngôn ngữ khi đó sẽ rõ ràng, và bạn tránh được chuyện phụ thuộc vòng giữa file
.ktvà.java.
Một quy tắc thực tế: hãy bắt đầu bằng code Kotlin ít phụ thuộc, như DTO, hàm tiện ích và test. Đừng bắt đầu bằng việc viết lại lõi nghiệp vụ.
8. Kết luận
Kotlin và Java có thể sống chung trong một module Maven. Bạn chỉ cần một điều kiện: Kotlin compiler phải chạy trước javac và đọc được cả hai thư mục mã nguồn. Khi Kotlin gọi Java, hãy chú ý platform type và dùng annotation nullability kết hợp -Xjsr305=strict. Khi Java gọi Kotlin, @JvmName, @JvmOverloads, @JvmStatic và @Throws giúp API dễ dùng hơn nhiều. Với Spring Boot, plugin spring (all-open) là bắt buộc để proxy hoạt động đúng. Cuối cùng, viết test bằng Java cho code Kotlin là cách nhanh nhất để biết API của bạn có thật sự thân thiện với Java hay không.