Spring Boot funciona con Kotlin. Nadie lo niega. Pero cuando lo usas con Kotlin acabas cargando con un contexto de aplicación que tarda en arrancar, anotaciones que hacen cosas por detrás y una inyección de dependencias que se configura prácticamente sola... hasta que no funciona y no sabes por qué.
Ktor parte de otra base: todo está en código Kotlin explícito, las rutas son funciones, los plugins se instalan a mano y las coroutines son ciudadanas de primera clase desde el día uno. No hay magia. Lo que ves es lo que hay.
¿Cuándo tiene sentido usar Ktor? Principalmente en microservicios pequeños, funciones serverless y proyectos donde el tiempo de arranque importa. Si necesitas Spring Security, Spring Data o Spring Batch, quédate con Spring Boot. Si tu equipo ya conoce Spring, también. Pero si empiezas desde cero con Kotlin y no necesitas ese ecosistema, Ktor arranca más rápido y el código es más directo.
La estructura básica de un servidor Ktor
Un servidor Ktor mínimo tiene esta pinta:
fun main() {
embeddedServer(Netty, port = 8080) {
configureRouting()
}.start(wait = true)
}
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Hola")
}
}
}
No hay @Controller, no hay @GetMapping. Las rutas son funciones de extensión sobre Application. Puedes moverlas a archivos separados y componerlas como quieras.
El engine por defecto es Netty, aunque también puedes usar CIO (el motor basado en coroutines propio de Ktor). CIO arranca algo más rápido y es útil en entornos serverless donde cada milisegundo de cold start cuenta.
El sistema de plugins
En Ktor todo lo que no es el núcleo del servidor se añade como plugin. Antes se llamaban features; desde Ktor 2 se llaman plugins. La idea es que nada está activo por defecto: si quieres serialización JSON, la instalas. Si quieres CORS, lo instalas. Cero sorpresas.
fun Application.configurePlugins() {
install(ContentNegotiation) {
json()
}
install(CORS) {
anyHost()
}
install(Authentication) {
jwt("auth-jwt") {
// configuración JWT
}
}
}
Algunos plugins útiles:
- ContentNegotiation: serialización y deserialización JSON con
kotlinx.serialization. - CORS: control de orígenes permitidos.
- Authentication: JWT, OAuth, Basic Auth y sesiones.
- Sessions: sesiones en cookie o en cabecera.
- StatusPages: gestión centralizada de errores y excepciones.
La ventaja respecto a Spring Boot es que sabes exactamente qué tienes activo porque tú lo has puesto. No hay auto-configuration que decida por ti.
Routing: definir las rutas
El DSL de rutas de Ktor es sencillo y se lee bien:
routing {
get("/users") {
val users = userService.getAll()
call.respond(users)
}
post("/users") {
val user = call.receive<User>()
val created = userService.create(user)
call.respond(HttpStatusCode.Created, created)
}
get("/users/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest)
val user = userService.getById(id)
?: return@get call.respond(HttpStatusCode.NotFound)
call.respond(user)
}
}
Para proyectos con varias versiones de API, puedes agrupar rutas con route():
routing {
route("/api/v1") {
get("/users") { /* ... */ }
post("/users") { /* ... */ }
route("/users/{id}") {
get { /* ... */ }
put { /* ... */ }
delete { /* ... */ }
}
}
}
Cada bloque se puede extraer a su propia función de extensión, lo que hace que los ficheros de rutas sean fáciles de leer incluso cuando el proyecto crece.
Coroutines en los handlers
Aquí está una de las diferencias más prácticas con otros frameworks. Los handlers de Ktor son suspend functions, así que puedes usar coroutines directamente sin envolver nada:
get("/data") {
val result = withContext(Dispatchers.IO) {
database.fetchAll()
}
call.respond(result)
}
Sin callbacks, sin Mono, sin Flux. El código es secuencial y se lee de arriba abajo, aunque por dentro sea asíncrono. Si vienes de Spring WebFlux y te ha costado depurar cadenas reactivas, esto se nota.
Para operaciones de base de datos puedes usar cualquier librería compatible con coroutines: Exposed con su DSL suspendible, r2dbc-kotlin o simplemente JDBC en un Dispatchers.IO.
Ktor 3.0: qué cambió en octubre de 2024
Ktor 3.0 llegó en octubre de 2024 con varios cambios que afectan al día a día:
- DSL de rutas más claro: la API se simplificó para reducir la ambigüedad entre
RoutingCallyApplicationCall. El acceso al body y a los parámetros es más consistente. - TypedApplicationCall: permite acceder al body ya deserializado de forma tipada sin llamar a
receive<T>()manualmente en cada handler. - Server-Sent Events y WebSockets mejorados: mejor integración con coroutines en conexiones de larga duración.
- Cliente HTTP Kotlin Multiplatform: el mismo código del cliente HTTP funciona en JVM, Android e iOS. Esto es especialmente útil si tienes una app móvil y un backend en el mismo proyecto KMP.
La migración de Ktor 2.x a 3.0 no es trivial si usas muchos plugins, pero la documentación oficial tiene guías de migración bastante detalladas.
Testing: levantar el servidor en memoria
Ktor tiene soporte de testing integrado que levanta el servidor en memoria sin abrir ningún puerto:
class UserRoutesTest {
@Test
fun `GET users devuelve lista`() = testApplication {
application {
configurePlugins()
configureRouting()
}
val response = client.get("/users")
assertEquals(HttpStatusCode.OK, response.status)
val users = response.body<List<User>>()
assertTrue(users.isNotEmpty())
}
}
El cliente de test usa la misma API que el cliente HTTP normal de Ktor, así que no tienes que aprender una API diferente para los tests. Puedes probar autenticación, cabeceras, cookies y cuerpos de respuesta sin levantar un servidor real.
Para tests de integración con base de datos puedes combinar esto con Testcontainers, que levanta un contenedor Docker con tu base de datos durante el test.
Despliegue: fat JAR y Docker
El flujo más habitual para desplegar Ktor en producción:
# Generar el fat JAR (incluye todas las dependencias)
./gradlew shadowJar
# Ejecutar
java -jar build/libs/mi-app-all.jar
Para Docker, una imagen base razonable con JVM 21 y tamaño contenido:
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY build/libs/mi-app-all.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Si quieres ir más allá, GraalVM Native Image con Kotlin es experimental pero funciona para proyectos simples. El beneficio es un tiempo de arranque prácticamente instantáneo, lo que tiene mucho valor en funciones serverless donde pagas por el tiempo de ejecución. La configuración es más compleja y algunas librerías no son compatibles todavía, así que conviene probar antes de asumir que tu proyecto encaja.
Para Kubernetes o cualquier plataforma cloud, el fat JAR dentro de una imagen Alpine es la opción más sencilla y la que menos sorpresas da en producción.
Por dónde seguir
Si quieres profundizar en Kotlin para el servidor, estos artículos te pueden venir bien:
Imagen: Pexels / panumas nikhomkhai
