Kotlin cho backend với Spring Boot

1. Tổng quan

Kotlin là ngôn ngữ được Spring hỗ trợ chính thức. Dùng Kotlin, bạn viết ít boilerplate hơn, có null safety ngay lúc biên dịch và có data class cho DTO. Bài này hướng dẫn dựng một REST API quản lý đơn hàng (Order) bằng Kotlin và Spring Boot 3.x, gồm entity JPA, cấu hình type-safe và test. Đọc xong, bạn sẽ tự khởi tạo được một dự án Kotlin backend và tránh được những lỗi phổ biến nhất khi chuyển từ Java sang.

2. Thiết lập dự án Maven

2.1. Dependency

Ngoài các starter quen thuộc, một dự án Kotlin cần thêm ba thứ. kotlin-stdlib là thư viện chuẩn. kotlin-reflect được Spring dùng để đọc metadata Kotlin. jackson-module-kotlin giúp Jackson hiểu data class và giá trị mặc định của tham số.

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.0</version>
</parent>

<properties>
    <java.version>21</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.module</groupId>
        <artifactId>jackson-module-kotlin</artifactId>
    </dependency>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-reflect</artifactId>
    </dependency>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Bạn không cần ghi version cho các artifact Kotlin. Spring Boot parent đã quản lý kotlin.version. Parent cũng tự đặt kotlin.compiler.jvmTarget theo java.version, nên bytecode sinh ra sẽ nhắm tới Java 21.

2.2. Cấu hình kotlin-maven-plugin

Phần này quan trọng nhất. Trong Kotlin, class và method mặc định là final. Spring lại cần tạo proxy (CGLIB) cho @Configuration, @Transactional, @Async… nên các class này phải mở (open). Hibernate thì cần constructor không tham số. Hai compiler plugin allopen và noarg giải quyết cả hai vấn đề:

<build>
    <sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
    <testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
        <plugin>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-plugin</artifactId>
            <configuration>
                <args>
                    <arg>-Xjsr305=strict</arg>
                </args>
                <compilerPlugins>
                    <plugin>spring</plugin>
                    <plugin>jpa</plugin>
                    <plugin>all-open</plugin>
                </compilerPlugins>
                <pluginOptions>
                    <option>all-open:annotation=jakarta.persistence.Entity</option>
                    <option>all-open:annotation=jakarta.persistence.MappedSuperclass</option>
                    <option>all-open:annotation=jakarta.persistence.Embeddable</option>
                </pluginOptions>
            </configuration>
            <dependencies>
                <dependency>
                    <groupId>org.jetbrains.kotlin</groupId>
                    <artifactId>kotlin-maven-allopen</artifactId>
                    <version>${kotlin.version}</version>
                </dependency>
                <dependency>
                    <groupId>org.jetbrains.kotlin</groupId>
                    <artifactId>kotlin-maven-noarg</artifactId>
                    <version>${kotlin.version}</version>
                </dependency>
            </dependencies>
        </plugin>
    </plugins>
</build>

Ý nghĩa từng phần:

  • spring: tự mở các class có @Component, @Configuration, @Transactional, @Async, @Cacheable và các annotation dẫn xuất như @Service, @RestController.
  • jpa: sinh constructor không tham số cho class có @Entity, @Embeddable, @MappedSuperclass.
  • all-open với ba option: mở cả entity để Hibernate tạo được proxy lazy loading. Plugin spring không lo phần này.
  • -Xjsr305=strict: Kotlin đọc các annotation nullability của Spring (@Nullable, @NonNullApi) như kiểu Kotlin thật. Một API Spring trả về @Nullable sẽ thành T?, và bạn bị bắt phải xử lý null.

3. Lớp khởi động ứng dụng

package dev.nonla.shop

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication

@SpringBootApplication
@ConfigurationPropertiesScan
class ShopApplication

fun main(args: Array<String>) {
    runApplication<ShopApplication>(*args)
}

runApplication<ShopApplication> là extension function có reified generic. Nó tương đương SpringApplication.run(ShopApplication::class.java, *args). Hàm main nằm ở top-level, không cần companion object. @ConfigurationPropertiesScan sẽ được dùng ở mục 7.

4. Entity JPA và repository

package dev.nonla.shop.order

import jakarta.persistence.*
import java.math.BigDecimal

enum class OrderStatus { NEW, PAID, CANCELLED }

@Entity
@Table(name = "orders")
class Order(
    var customerName: String,
    var amount: BigDecimal,

    @Enumerated(EnumType.STRING)
    var status: OrderStatus = OrderStatus.NEW,

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,
)

Một số điểm cần để ý:

  • Mọi field được khai báo ngay trong primary constructor. Bạn không phải viết getter/setter.
  • id có kiểu Long? và mặc định null, vì entity chỉ có id sau khi được lưu.
  • Constructor không tham số mà Hibernate cần do plugin jpa sinh ra. Bạn không nhìn thấy nó trong code Kotlin.
  • Đây là class thường, không phải data class. Lý do nằm ở mục 9.

Repository viết ngắn như Java:

package dev.nonla.shop.order

import org.springframework.data.jpa.repository.JpaRepository

interface OrderRepository : JpaRepository<Order, Long> {
    fun findByStatus(status: OrderStatus): List<Order>
}

5. DTO với data class và null safety

DTO là chỗ hợp nhất để dùng data class. Kotlin tự sinh equals, hashCode, toString và copy.

package dev.nonla.shop.order

import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Positive
import java.math.BigDecimal

data class CreateOrderRequest(
    @field:NotBlank
    val customerName: String,

    @field:Positive
    val amount: BigDecimal,
)

data class OrderResponse(
    val id: Long,
    val customerName: String,
    val amount: BigDecimal,
    val status: OrderStatus,
)

fun Order.toResponse() = OrderResponse(
    id = requireNotNull(id) { "Order chưa được lưu" },
    customerName = customerName,
    amount = amount,
    status = status,
)

Phải viết @field:NotBlank chứ không phải @NotBlank. Nếu không có use-site target field:, Kotlin gắn annotation vào tham số constructor. Bean Validation khi đó không thấy annotation, và validation lặng lẽ không chạy.

Extension function Order.toResponse() giúp entity không phụ thuộc vào DTO, mà vẫn gọi được như method: order.toResponse(). requireNotNull ném IllegalArgumentException nếu id là null. Sau dòng đó, compiler coi id là Long, không còn là Long?.

6. Service và controller

6.1. Service

package dev.nonla.shop.order

import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional(readOnly = true)
class OrderService(private val orderRepository: OrderRepository) {

    @Transactional
    fun create(request: CreateOrderRequest): OrderResponse {
        val order = Order(
            customerName = request.customerName.trim(),
            amount = request.amount,
        )
        return orderRepository.save(order).toResponse()
    }

    fun findById(id: Long): OrderResponse? =
        orderRepository.findByIdOrNull(id)?.toResponse()
}

Bean được inject qua primary constructor. Với class có một constructor duy nhất, bạn không cần @Autowired. Field là private val, nên không thể bị gán lại.

findByIdOrNull là extension của Spring Data dành riêng cho Kotlin. Nó trả về Order? thay vì Optional<Order>. Kết hợp với toán tử ?., hàm findById gọn trong một dòng. Kiểu trả về OrderResponse? cũng nói rõ với người gọi rằng kết quả có thể không tồn tại.

Nhờ plugin spring, OrderService được mở tự động. Spring tạo được proxy cho @Transactional mà bạn không phải viết open class.

6.2. Controller

package dev.nonla.shop.order

import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/api/orders")
class OrderController(private val orderService: OrderService) {

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun create(@Valid @RequestBody request: CreateOrderRequest): OrderResponse =
        orderService.create(request)

    @GetMapping("/{id}")
    fun get(@PathVariable id: Long): ResponseEntity<OrderResponse> =
        orderService.findById(id)
            ?.let { ResponseEntity.ok(it) }
            ?: ResponseEntity.notFound().build()
}

Toán tử Elvis ?: thay cho if (x != null) … else …: có đơn hàng thì trả 200, không có thì trả 404. Bạn thử bằng curl:

curl -s -X POST localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName":"Nguyễn Văn An","amount":250000}'
{"id":1,"customerName":"Nguyễn Văn An","amount":250000,"status":"NEW"}

Nếu request thiếu hẳn customerName, Jackson (với jackson-module-kotlin) báo lỗi ngay khi deserialize, vì tham số non-null không có giá trị. Spring trả về 400 Bad Request trước cả bước validation. Nếu gửi "customerName": " ", request qua được bước deserialize nhưng bị @field:NotBlank chặn lại, và bạn cũng nhận 400.

7. Cấu hình type-safe với @ConfigurationProperties

payment.provider=vnpay
payment.timeout=3s
package dev.nonla.shop.payment

import org.springframework.boot.context.properties.ConfigurationProperties
import java.time.Duration

@ConfigurationProperties(prefix = "payment")
data class PaymentProperties(
    val provider: String,
    val timeout: Duration = Duration.ofSeconds(5),
    val retryCount: Int = 2,
)

Từ Spring Boot 3, một class có duy nhất một constructor sẽ được bind qua constructor. Bạn không cần @ConstructorBinding. Thuộc tính không có trong file properties (ở đây là retryCount) nhận giá trị mặc định của Kotlin. Vì là val, object cấu hình bất biến sau khi khởi động.

Inject vào service như bean bình thường:

@Service
class PaymentService(private val props: PaymentProperties) {
    fun describe() = "Thanh toán qua ${props.provider}, timeout ${props.timeout.seconds}s"
}
Thanh toán qua vnpay, timeout 3s

Nếu thiếu payment.provider (không có giá trị mặc định), ứng dụng sẽ không khởi động được. Đây là hành vi bạn muốn: sai cấu hình thì nên lộ ra ngay lúc deploy, không đợi tới runtime.

8. Viết test

spring-boot-starter-test đã có JUnit 5 và AssertJ. Trong Kotlin, bạn đặt tên test bằng câu mô tả đặt trong backtick:

package dev.nonla.shop.order

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.math.BigDecimal

@DataJpaTest
@Import(OrderService::class)
class OrderServiceTest(@Autowired private val orderService: OrderService) {

    @Test
    fun `tạo đơn hàng mới với trạng thái NEW và tên đã được trim`() {
        val response = orderService.create(
            CreateOrderRequest(customerName = "  Trần Thị Bình ", amount = BigDecimal("99000"))
        )

        assertThat(response.id).isPositive()
        assertThat(response.customerName).isEqualTo("Trần Thị Bình")
        assertThat(response.status).isEqualTo(OrderStatus.NEW)
    }

    @Test
    fun `trả về null khi không tìm thấy đơn hàng`() {
        assertThat(orderService.findById(9_999)).isNull()
    }
}

@DataJpaTest dựng một context nhỏ, chỉ gồm JPA và H2 in-memory. @Import thêm OrderService vào context đó. Test class cũng dùng constructor injection: trong test, JUnit 5 cần @Autowired trên tham số để biết lấy giá trị từ Spring. Mỗi test chạy trong một transaction và được rollback khi kết thúc, nên dữ liệu không rò sang test khác.

OrderServiceTest > tạo đơn hàng mới với trạng thái NEW và tên đã được trim() PASSED
OrderServiceTest > trả về null khi không tìm thấy đơn hàng() PASSED

9. Lỗi hay gặp

Dùng data class cho entity. equals/hashCode sinh tự động dựa trên mọi field, kể cả id có thể null và các quan hệ lazy. Hệ quả là entity “đổi hash” sau khi được lưu, làm hỏng HashSet. toString() có thể kích hoạt lazy loading hoặc đệ quy vô hạn với quan hệ hai chiều.

Lưu ý: Chỉ dùng data class cho DTO và cấu hình. Với entity, dùng class thường. Nếu cần, tự viết equals/hashCode dựa trên id.

Quên plugin spring hoặc all-open. Thiếu plugin, Spring báo lỗi kiểu @Configuration class may not be final. Tệ hơn, @Transactional trên method final bị bỏ qua mà không có cảnh báo nào. Với entity, thiếu all-open thì Hibernate không tạo được proxy, và quan hệ LAZY âm thầm thành EAGER.

Thiếu field: trên annotation validation. Validation không chạy và không có lỗi nào được báo. Hãy luôn viết test cho trường hợp dữ liệu không hợp lệ để bắt lỗi này.

Dùng lateinit var để inject. Cách này chạy được, nhưng bạn mất tính bất biến và dễ gặp UninitializedPropertyAccessException. Hãy ưu tiên constructor injection với private val.

Quên -Xjsr305=strict. Không có flag này, kiểu trả về từ API Spring là platform type (T!). Compiler không bắt bạn kiểm tra null, và NullPointerException vẫn có thể xảy ra như trong Java.

Khi nào chưa nên chuyển sang Kotlin? Nếu đội của bạn chưa ai quen Kotlin và dự án đang cần giao gấp, chi phí học (coroutine, extension, scope function) có thể lớn hơn lợi ích. Code Kotlin cũng biên dịch chậm hơn Java một chút. Ngoài ra, một số thư viện dựa nặng vào reflection, như Lombok hay MapStruct ở vài cấu hình, phối hợp với Kotlin kém. Trong trường hợp này, bạn có thể đưa Kotlin vào dần: viết test hoặc module mới bằng Kotlin trong khi vẫn giữ phần code Java cũ.

10. Kết luận

Bạn đã dựng một backend Spring Boot 3 bằng Kotlin, gồm:

  • cấu hình kotlin-maven-plugin với spring, jpa, all-open và -Xjsr305=strict;
  • entity JPA dùng class thường, DTO dùng data class với @field:;
  • service và controller tận dụng null safety, findByIdOrNull và toán tử Elvis;
  • cấu hình bất biến bằng @ConfigurationProperties trên data class;
  • test với JUnit 5 và AssertJ, đặt tên bằng câu mô tả trong backtick.

Phần lớn kiến thức Spring bạn đã có vẫn dùng nguyên được. Điều cần nhớ là ba khác biệt khi chuyển sang Kotlin: class mặc định final, annotation cần use-site target, và kiểu nullable phải được khai báo rõ ràng. Nắm vững ba điểm này, bạn tránh được hầu hết các lỗi khó tìm khi chuyển từ Java sang Kotlin.

kotlinspring-bootjparest-apibackend