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,@Cacheablee anotações derivadas como@Service,@RestController.jpa: gera um constructor sem parâmetros para classes com@Entity,@Embeddable,@MappedSuperclass.all-opencom três opções: abre também as entities para que Hibernate possa criar proxies de lazy loading. O pluginspringnã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@Nullablese tornaT?, 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.
idtem tipoLong?e padrãonull, 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
classcomum, não umadata 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 classapenas para DTO e configuração. Para entities, useclasscomum. Se necessário, escreva você mesmoequals/hashCodecom base emid.
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-plugincomspring,jpa,all-opene-Xjsr305=strict; - entity JPA usando
classcomum, DTO usandodata classcom@field:; - service e controller aproveitando null safety,
findByIdOrNulle o operador Elvis; - configuração imutável com
@ConfigurationPropertiesemdata 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.