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, @Cacheable y anotaciones derivadas como @Service, @RestController.
  • jpa: genera un constructor sin parámetros para clases con @Entity, @Embeddable, @MappedSuperclass.
  • all-open con tres opciones: abre también las entidades para que Hibernate pueda crear proxies lazy loading. El plugin spring no 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 @Nullable se convierte en T?, 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.
  • id tiene tipo Long? y por defecto null, 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 class normal, no una data 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 class para DTO y configuración. Para entidades, usa class normal. Si es necesario, escribe tu propio equals/hashCode basado en id.

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-plugin con spring, jpa, all-open y -Xjsr305=strict;
  • entidades JPA usando class normal, DTO usando data class con @field:;
  • servicios y controladores aprovechando null safety, findByIdOrNull y el operador Elvis;
  • configuración inmutable usando @ConfigurationProperties en data 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.

kotlinspring-bootjparest-apibackend