Kotlin para backend con Spring Boot
1. Descripción general
Kotlin es un lenguaje soportado oficialmente por Spring. Con Kotlin escribes menos código repetitivo, tienes null safety en tiempo de compilación y tienes data class para DTO. Este artículo te guía para construir una REST API de gestión de pedidos (Order) con Kotlin y Spring Boot 3.x, incluyendo entidades JPA, configuración type-safe y pruebas. Al terminar, podrás inicializar tu propio proyecto Kotlin backend y evitar los errores más comunes al migrar desde Java.
2. Configuración del proyecto Maven
2.1. Dependencias
Además de los starters habituales, un proyecto Kotlin necesita tres cosas adicionales. kotlin-stdlib es la biblioteca estándar. kotlin-reflect es usado por Spring para leer metadatos de Kotlin. jackson-module-kotlin ayuda a Jackson a entender data classes y valores por defecto 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>
No necesitas especificar la versión para los artefactos de Kotlin. El parent de Spring Boot ya gestiona kotlin.version. El parent también establece automáticamente kotlin.compiler.jvmTarget según java.version, por lo que el bytecode generado apuntará a Java 21.
2.2. Configuración de kotlin-maven-plugin
Esta es la parte más importante. En Kotlin, las clases y métodos son final por defecto. Spring necesita crear proxies (CGLIB) para @Configuration, @Transactional, @Async…, así que estas clases deben ser abiertas (open). Hibernate necesita un constructor sin parámetros. Dos compiler plugins, allopen y noarg, resuelven ambos 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>
Significado de cada parte:
spring: abre automáticamente las clases con@Component,@Configuration,@Transactional,@Async,@Cacheabley anotaciones derivadas como@Service,@RestController.jpa: genera un constructor sin parámetros para clases con@Entity,@Embeddable,@MappedSuperclass.all-opencon tres opciones: abre también las entidades para que Hibernate pueda crear proxies lazy loading. El pluginspringno se ocupa de esto.-Xjsr305=strict: Kotlin interpreta las anotaciones de nullability de Spring (@Nullable,@NonNullApi) como tipos Kotlin reales. Una API de Spring que devuelve@Nullablese convierte enT?, y se te obliga a manejar null.
3. Clase de arranque de la aplicación
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> es una función de extensión con genéricos reificados. Es equivalente a SpringApplication.run(ShopApplication::class.java, *args). La función main está a nivel superior, no necesita companion object. @ConfigurationPropertiesScan se usará en la sección 7.
4. Entidades JPA y repositorio
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,
)
Algunos puntos a tener en cuenta:
- Todos los campos se declaran en el constructor principal. No necesitas escribir getters/setters.
idtiene tipoLong?y por defectonull, porque la entidad solo tiene id después de ser guardada.- El constructor sin parámetros que Hibernate necesita es generado por el plugin
jpa. No lo ves en el código Kotlin. - Esta es una
classnormal, no unadata class. La razón está en la sección 9.
El repositorio se escribe tan conciso como en 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 con data class y null safety
DTO es el lugar ideal para usar data class. Kotlin genera automáticamente equals, hashCode, toString y 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) { "El pedido aún no ha sido guardado" },
customerName = customerName,
amount = amount,
status = status,
)
Debes escribir @field:NotBlank, no simplemente @NotBlank. Si no tienes el use-site target field:, Kotlin adhiere la anotación al parámetro constructor. Bean Validation entonces no ve la anotación, y la validación silenciosamente no se ejecuta.
La función de extensión Order.toResponse() ayuda a que la entidad no dependa del DTO, pero aún pueda ser llamada como método: order.toResponse(). requireNotNull lanza IllegalArgumentException si id es null. Después de esa línea, el compilador considera que id es Long, ya no es Long?.
6. Service y 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()
}
El bean se inyecta a través del constructor principal. Con una clase que tiene un solo constructor, no necesitas @Autowired. El campo es private val, por lo que no puede ser reasignado.
findByIdOrNull es una extensión de Spring Data específica para Kotlin. Devuelve Order? en lugar de Optional<Order>. Combinado con el operador ?., la función findById cabe en una línea. El tipo de retorno OrderResponse? también deja claro al que llama que el resultado puede no existir.
Gracias al plugin spring, OrderService se abre automáticamente. Spring puede crear un proxy para @Transactional sin que tengas que escribir 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()
}
El operador Elvis ?: reemplaza a if (x != null) … else …: si hay pedido devuelve 200, si no devuelve 404. Puedes probarlo con 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"}
Si la solicitud le falta completamente customerName, Jackson (con jackson-module-kotlin) reporta un error inmediatamente al deserializar, porque el parámetro no nulo no tiene valor. Spring devuelve 400 Bad Request antes del paso de validación. Si envías "customerName": " ", la solicitud pasa la deserialización pero es bloqueada por @field:NotBlank, y también recibes 400.
7. Configuración type-safe con @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 de Spring Boot 3, una clase con un único constructor se vinculará a través del constructor. No necesitas @ConstructorBinding. Las propiedades que no están en el archivo properties (en este caso retryCount) reciben el valor por defecto de Kotlin. Como es val, el objeto de configuración es inmutable después del arranque.
Inyecta en el servicio como un bean normal:
@Service
class PaymentService(private val props: PaymentProperties) {
fun describe() = "Pago a través de ${props.provider}, timeout ${props.timeout.seconds}s"
}
Pago a través de vnpay, timeout 3s
Si falta payment.provider (sin valor por defecto), la aplicación no podrá arrancar. Este es el comportamiento que quieres: un error de configuración debería exponerse inmediatamente durante el despliegue, no esperando hasta el tiempo de ejecución.
8. Escribir pruebas
spring-boot-starter-test ya incluye JUnit 5 y AssertJ. En Kotlin, nombras las pruebas con una frase descriptiva 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 `crear un nuevo pedido con estado NEW y nombre ya recortado`() {
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 `devuelve null cuando no encuentra el pedido`() {
assertThat(orderService.findById(9_999)).isNull()
}
}
@DataJpaTest construye un contexto pequeño, solo con JPA e H2 en memoria. @Import agrega OrderService a ese contexto. La clase de prueba también usa inyección por constructor: en las pruebas, JUnit 5 necesita @Autowired en el parámetro para saber que obtenga el valor de Spring. Cada prueba se ejecuta en una transacción y se revierte al finalizar, por lo que los datos no se filtran entre pruebas.
OrderServiceTest > crear un nuevo pedido con estado NEW y nombre ya recortado() PASSED
OrderServiceTest > devuelve null cuando no encuentra el pedido() PASSED
9. Errores comunes
Usar data class para entidades. equals/hashCode se generan automáticamente basados en todos los campos, incluido id que puede ser nulo y las relaciones lazy. Como resultado, la entidad “cambia de hash” después de ser guardada, rompiendo HashSet. toString() puede activar lazy loading o recursión infinita con relaciones bidireccionales.
Nota: Solo usa
data classpara DTO y configuración. Para entidades, usaclassnormal. Si es necesario, escribe tu propioequals/hashCodebasado enid.
Olvidar el plugin spring o all-open. Sin el plugin, Spring reporta un error como @Configuration class may not be final. Peor aún, @Transactional en un método final se ignora silenciosamente. Con las entidades, sin all-open, Hibernate no puede crear un proxy, y las relaciones LAZY silenciosamente se vuelven EAGER.
Falta de field: en anotación de validación. La validación no se ejecuta y no se reportan errores. Siempre escribe una prueba para datos inválidos para detectar este error.
Usar lateinit var para inyectar. Esta forma funciona, pero pierdes inmutabilidad y es fácil encontrar UninitializedPropertyAccessException. Prefiere la inyección por constructor con private val.
Olvidar -Xjsr305=strict. Sin esta bandera, el tipo de retorno de la API de Spring es platform type (T!). El compilador no te obliga a verificar null, y NullPointerException puede seguir ocurriendo como en Java.
¿Cuándo aún no deberías cambiar a Kotlin? Si tu equipo no está familiarizado con Kotlin y el proyecto necesita entregarse pronto, el costo de aprendizaje (corrutinas, extensiones, scope functions) puede ser mayor que el beneficio. El código Kotlin también se compila un poco más lentamente que Java. Además, algunas bibliotecas que dependen mucho de reflection, como Lombok o MapStruct en algunas configuraciones, se integran mal con Kotlin. En este caso, puedes introducir Kotlin gradualmente: escribiendo pruebas o nuevos módulos en Kotlin mientras mantienes el código Java antiguo.
10. Conclusión
Has construido un backend Spring Boot 3 con Kotlin, que incluye:
- configuración de
kotlin-maven-pluginconspring,jpa,all-openy-Xjsr305=strict; - entidades JPA usando
classnormal, DTO usandodata classcon@field:; - servicios y controladores aprovechando null safety,
findByIdOrNully el operador Elvis; - configuración inmutable usando
@ConfigurationPropertiesendata class; - pruebas con JUnit 5 y AssertJ, nombradas con frases descriptivas en backticks.
La mayoría de los conocimientos de Spring que ya tienes siguen siendo aplicables. Lo que debes recordar son tres diferencias al cambiar a Kotlin: las clases son final por defecto, las anotaciones necesitan use-site target, y los tipos nullables deben ser declarados explícitamente. Dominar estos tres puntos te ayudará a evitar la mayoría de los errores difíciles de encontrar al migrar de Java a Kotlin.