Kotlin para backend com Spring Boot

1. Visão geral

Kotlin é uma linguagem oficialmente suportada pelo Spring. Com Kotlin, você escreve menos boilerplate, tem null safety em tempo de compilação e data classes para DTOs. Este artigo orienta a construir uma REST API para gerenciar pedidos (Order) com Kotlin e Spring Boot 3.x, incluindo entity JPA, configuração type-safe e testes. Após a leitura, você conseguirá inicializar um projeto Kotlin backend por conta própria e evitará os erros mais comuns ao migrar do Java.

2. Configuração do projeto Maven

2.1. Dependências

Além dos starters conhecidos, um projeto Kotlin precisa de três coisas adicionais. kotlin-stdlib é a biblioteca padrão. kotlin-reflect é usado pelo Spring para ler metadados Kotlin. jackson-module-kotlin ajuda Jackson a entender data classes e valores padrão de parâmetros.

<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>

Você não precisa especificar a versão para os artefatos Kotlin. O Spring Boot parent já gerencia kotlin.version. O parent também define automaticamente kotlin.compiler.jvmTarget conforme java.version, então o bytecode gerado terá como alvo Java 21.

2.2. Configuração do kotlin-maven-plugin

Esta é a parte mais importante. Em Kotlin, classes e métodos são final por padrão. O Spring precisa criar proxies (CGLIB) para @Configuration, @Transactional, @Async…, então essas classes precisam ser abertas (open). Hibernate precisa de um constructor sem parâmetros. Dois plugins do compilador, allopen e noarg, resolvem ambos os problemas:

<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>

O significado de cada parte:

  • spring: abre automaticamente classes com @Component, @Configuration, @Transactional, @Async, @Cacheable e anotações derivadas como @Service, @RestController.
  • jpa: gera um constructor sem parâmetros para classes com @Entity, @Embeddable, @MappedSuperclass.
  • all-open com três opções: abre também as entities para que Hibernate possa criar proxies de lazy loading. O plugin spring não cuida dessa parte.
  • -Xjsr305=strict: Kotlin interpreta as anotações de nulabilidade do Spring (@Nullable, @NonNullApi) como tipos Kotlin reais. Uma API Spring que retorna @Nullable se torna T?, e você é obrigado a lidar com null.

3. Classe de inicialização da aplicação

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> é uma extension function com generic reificado. Equivale a SpringApplication.run(ShopApplication::class.java, *args). A função main está no nível superior, não precisa de companion object. @ConfigurationPropertiesScan será usado na seção 7.

4. Entity JPA e 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,
)

Alguns pontos a observar:

  • Todos os campos são declarados diretamente no construtor principal. Você não precisa escrever getters/setters.
  • id tem tipo Long? e padrão null, porque a entity só tem id após ser salva.
  • O constructor sem parâmetros que Hibernate precisa é gerado pelo plugin jpa. Você não vê isso no código Kotlin.
  • Essa é uma class comum, não uma data class. A razão está na seção 9.

Repository é tão conciso quanto em 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 com data class e null safety

DTO é o lugar ideal para usar data class. Kotlin gera automaticamente equals, hashCode, toString e 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,
)

Deve-se escrever @field:NotBlank, não apenas @NotBlank. Se não houver o use-site target field:, Kotlin anexa a anotação ao parâmetro do construtor. Bean Validation não vê a anotação então, e a validação não executa silenciosamente.

A extension function Order.toResponse() ajuda a entity a não depender do DTO, e ainda assim pode ser chamada como um método: order.toResponse(). requireNotNull lança IllegalArgumentException se id for null. Após isso, o compilador trata id como Long, não mais como Long?.

6. Service e 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()
}

O bean é injetado pelo construtor principal. Com uma classe que tem um único construtor, você não precisa de @Autowired. O campo é private val, então não pode ser reatribuído.

findByIdOrNull é uma extension do Spring Data feita especificamente para Kotlin. Retorna Order? em vez de Optional<Order>. Combinado com o operador ?., a função findById cabe em uma única linha. O tipo de retorno OrderResponse? também deixa claro para o chamador que o resultado pode não existir.

Graças ao plugin spring, OrderService é aberta automaticamente. O Spring consegue criar um proxy para @Transactional sem você precisar escrever 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()
}

O operador Elvis ?: substitui if (x != null) … else …: se houver pedido, retorna 200, caso contrário retorna 404. Você pode testar com 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"}

Se a requisição não tiver customerName, Jackson (com jackson-module-kotlin) reporta um erro durante a desserialização, pois o parâmetro non-null não tem valor. O Spring retorna 400 Bad Request antes mesmo da validação. Se você enviar "customerName": " ", a requisição passa pela desserialização mas é bloqueada por @field:NotBlank, e você também recebe 400.

7. Configuração type-safe com @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,
)

A partir do Spring Boot 3, uma classe com um único construtor será vinculada pelo construtor. Você não precisa de @ConstructorBinding. Propriedades não presentes no arquivo de propriedades (neste caso retryCount) recebem o valor padrão do Kotlin. Como é val, o objeto de configuração é imutável após a inicialização.

Injetar no serviço como um bean normal:

@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

Se payment.provider estiver faltando (sem valor padrão), a aplicação não iniciará. Esse é o comportamento que você quer: um erro de configuração deve ser descoberto na hora do deploy, não em tempo de execução.

8. Escrevendo testes

spring-boot-starter-test já inclui JUnit 5 e AssertJ. Em Kotlin, você nomeias testes com uma frase descritiva entre backticks:

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 constrói um contexto pequeno, contendo apenas JPA e H2 em memória. @Import adiciona OrderService a esse contexto. A classe de teste também usa constructor injection: em testes, JUnit 5 precisa de @Autowired no parâmetro para saber buscar o valor do Spring. Cada teste roda em uma transação e é feito rollback ao finalizar, então os dados não vazam para outros testes.

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. Erros comuns

Usar data class para entity. equals/hashCode é gerado automaticamente com base em todos os campos, incluindo id que pode ser null e relacionamentos lazy. A consequência é que a entity “muda de hash” após ser salva, danificando HashSet. toString() pode ativar lazy loading ou recursão infinita com relacionamentos bidirecionais.

Nota: Use data class apenas para DTO e configuração. Para entities, use class comum. Se necessário, escreva você mesmo equals/hashCode com base em id.

Esquecer o plugin spring ou all-open. Sem o plugin, o Spring reporta erros como @Configuration class may not be final. Pior, @Transactional em um método final é ignorado sem aviso algum. Para entities, sem all-open, Hibernate não consegue criar proxies, e relacionamentos LAZY silenciosamente se tornam EAGER.

Falta de field: em anotações de validação. Validação não executa e nenhum erro é reportado. Sempre escreva testes para casos com dados inválidos para capturar esse erro.

Usar lateinit var para injeção. Isso funciona, mas você perde a imutabilidade e pode facilmente encontrar UninitializedPropertyAccessException. Prefira injeção pelo construtor com private val.

Esquecer -Xjsr305=strict. Sem essa flag, tipos de retorno de APIs do Spring são platform types (T!). O compilador não força você a verificar null, e NullPointerException ainda pode ocorrer como em Java.

Quando não deve mudar para Kotlin ainda? Se ninguém no seu time está familiarizado com Kotlin e o projeto precisa ser entregue rápido, o custo de aprendizado (coroutines, extensions, scope functions) pode ser maior que o benefício. Código Kotlin também compila um pouco mais lentamente que Java. Além disso, algumas bibliotecas que dependem fortemente de reflection, como Lombok ou MapStruct em certas configurações, funcionam mal com Kotlin. Nesse caso, você pode introduzir Kotlin gradualmente: escrever testes ou novos módulos em Kotlin enquanto mantém o código Java existente.

10. Conclusão

Você construiu um backend Spring Boot 3 com Kotlin, incluindo:

  • configuração de kotlin-maven-plugin com spring, jpa, all-open e -Xjsr305=strict;
  • entity JPA usando class comum, DTO usando data class com @field:;
  • service e controller aproveitando null safety, findByIdOrNull e o operador Elvis;
  • configuração imutável com @ConfigurationProperties em data class;
  • testes com JUnit 5 e AssertJ, nomeados com frases descritivas entre backticks.

A maior parte do conhecimento Spring que você já tem ainda se aplica. O que você precisa lembrar são três diferenças ao mudar para Kotlin: classes são final por padrão, anotações precisam de use-site target, e tipos nullable devem ser declarados explicitamente. Dominando esses três pontos, você evita a maioria dos erros difíceis de encontrar ao migrar do Java para Kotlin.

kotlinspring-bootjparest-apibackend