wandres.dev
KSP Y PLUGINS · generar en vez de reflexionar

De kapt a KSP: el fin de los stubs

Por qué el procesamiento de anotaciones heredado de Java resultaba tan caro en un proyecto Kotlin, qué era exactamente un stub y cuánto costaba generarlo, qué hace KSP en su lugar y cómo es el modelo de símbolos que expone. Incluye el estado real de KSP2 y de kapt en 2026.

⏱ 22 min

Durante casi una década, procesar anotaciones en un proyecto Kotlin significaba pedirle prestada a Java una herramienta que no sabía nada de Kotlin. El mecanismo se llamaba kapt, funcionaba razonablemente bien y sostuvo a toda una generación de librerías, pero escondía una maniobra costosísima que muy pocos usuarios llegaron a ver: para que un procesador escrito contra la API de Java pudiera examinar clases Kotlin, el compilador tenía que fabricar antes una versión ficticia de todo tu código en forma de Java. Esa maniobra explica por qué las construcciones eran lentas, por qué la información que llegaba al procesador estaba empobrecida y por qué determinadas preguntas sobre nulabilidad o sobre propiedades sencillamente no tenían respuesta. KSP nació para eliminar ese paso intermedio, y en 2026 la migración ya no es una recomendación sino un hecho consumado: KSP2 es lo que hay, y kapt está efectivamente retirado.

🎯 Al terminar esta lección sabrás
  • Explicar qué es un stub, por qué lo necesitaba kapt y cuánto costaba producirlo.
  • Identificar qué información de Kotlin se perdía al pasar por el modelo de Java.
  • Describir el modelo de símbolos de KSP y sus piezas principales.
  • Situar el estado actual de KSP2, la retirada de kapt y lo que implica para un proyecto vivo.

El precio de hablar en Java

La API de procesamiento de anotaciones de Java razona sobre elementos de Java: clases, métodos, campos y parámetros, con el sistema de tipos de Java y sin ninguna noción de propiedad, de función de extensión, de nulabilidad declarada ni de clase de datos. Un procesador escrito contra esa API espera que le entreguen ese modelo, y no hay forma de darle otro.

La solución de kapt fue tan directa como cara. Antes de compilar de verdad, arrancaba el compilador de Kotlin en un modo especial que generaba, para cada fichero Kotlin, un fichero Java equivalente con las firmas pero con los cuerpos vacíos. Eso son los stubs. Después invocaba al procesador de anotaciones de Java sobre esos ficheros ficticios, recogía lo que el procesador hubiera generado y solo entonces compilaba el proyecto entero de verdad.

flowchart TD
A[Fuentes Kotlin] --> B[kapt genera stubs Java]
B --> C[Procesador de anotaciones de Java]
C --> D[Fuentes generadas]
D --> E[Compilacion real de Kotlin]
A --> E
B -. coste dominante .-> F[Analisis casi completo por adelantado]

Generar un stub no es un volcado de texto: exige resolver todos los tipos, todas las importaciones y todas las firmas del proyecto, es decir, hacer buena parte del trabajo del compilador para luego tirarlo y volver a empezar. En proyectos grandes la fase de stubs llegaba a consumir más tiempo que la propia compilación, y como cualquier cambio en una firma invalidaba stubs, la construcción incremental se degradaba con facilidad.

El coste de rendimiento no era ni siquiera lo peor. Al proyectar Kotlin sobre Java se perdía información que el procesador habría querido tener. Una propiedad se convertía en un par de métodos de acceso y el procesador ya no sabía que eran una propiedad. La nulabilidad declarada quedaba reducida a una anotación que había que interpretar. Los tipos suspendidos aparecían con un parámetro de continuación extra. Los valores por defecto, las funciones de extensión y las clases de valor se representaban de formas que solo se entienden conociendo las convenciones internas del compilador.

// Lo que escribias
data class Punto(val x: Int, val y: Int? = null) {
    fun desplazado(d: Int) = Punto(x + d, y)
}

// Lo que el procesador veia, aproximadamente
// public final class Punto {
//   public final int getX();
//   @Nullable public final Integer getY();
//   @NotNull public final Punto desplazado(int d);
//   @NotNull public final Punto copy$default(...);
// }
// No hay propiedades, no hay valores por defecto,
// y la nulabilidad es una anotacion que hay que descifrar.

Esa pérdida obligaba a los autores de procesadores a reconstruir a mano el modelo de Kotlin a partir de las convenciones del bytecode, leyendo metadatos auxiliares y aplicando heurísticas frágiles. Buena parte del código de las librerías de aquella época consistía justamente en deshacer la traducción que el propio mecanismo acababa de hacer.

Qué hace KSP distinto

KSP invierte la relación. En lugar de traducir Kotlin a un modelo ajeno, define su propio modelo de símbolos, escrito en Kotlin y pensado para Kotlin, y lo alimenta directamente con la información que el compilador ya tiene. No hay stubs, no hay una fase previa que rehaga el análisis y no hay pérdida de información en la traducción. El resultado práctico es que las construcciones con KSP son notablemente más rápidas, con mejoras que en proyectos reales se sitúan típicamente alrededor del doble frente a kapt, y que crecen cuando el proyecto es grande y el ciclo es incremental.

La otra diferencia importante es la deliberada limitación de alcance. KSP no ve los cuerpos de las funciones: ve declaraciones, firmas, tipos y anotaciones. Esta restricción no es una carencia sino una decisión de diseño, porque permite que el análisis sea barato y estable frente a cambios internos. Un procesador solo necesita saber qué hay declarado, y si necesitara saber qué hace un cuerpo estaría intentando hacer el trabajo de un plugin de compilador, que es el tema del cuarto capítulo.

// Lo que ve un procesador KSP de esta declaracion
@Serializable
data class Usuario(
    val id: Long,
    val nombre: String,
    val apodo: String? = null,
)
// KSClassDeclaration con: modificadores data, tipo de clase, anotaciones,
// tres KSPropertyDeclaration con nombre, KSType, nulabilidad y valor por defecto.
// El cuerpo de cualquier funcion queda fuera del modelo por diseno.

El modelo de símbolos

El vocabulario de KSP es pequeño y conviene aprenderlo de memoria, porque casi todo procesador se escribe combinando media docena de tipos. En la raíz está KSAnnotated, que es todo aquello a lo que se puede poner una anotación. De ahí desciende KSDeclaration, la familia de cosas declaradas, y sus miembros concretos: KSClassDeclaration para clases, interfaces y objetos, KSFunctionDeclaration para funciones y constructores, KSPropertyDeclaration para propiedades y KSTypeParameter para los parámetros de tipo.

🔎

Resolver

El punto de entrada a todo. Permite buscar los símbolos anotados con un nombre concreto, localizar declaraciones por su nombre completo y obtener tipos internos como el de una cadena.

🧬

KSType

La referencia a un tipo ya resuelto, con su nulabilidad, sus argumentos de tipo y su declaración de origen. Es lo que se consulta para decidir cómo tratar cada propiedad.

🧭

KSVisitor

El recorrido del árbol de declaraciones sin escribir condicionales sobre el tipo de cada nodo. Existe también una variante que acumula datos mientras recorre.

📄

CodeGenerator

La única puerta legítima para escribir ficheros nuevos, con la declaración de dependencias que hace posible la construcción incremental.

Hay una distinción que causa confusión al principio y que conviene fijar cuanto antes: la que separa una referencia a un tipo de un tipo resuelto. Cuando lees la firma de una propiedad obtienes una referencia, que es lo que aparece escrito en el fuente; para saber a qué apunta realmente hay que resolverla, y resolver cuesta. Un procesador que resuelve todo lo que toca es un procesador lento, y la disciplina de resolver solo lo necesario es la primera optimización que aprende cualquiera que escriba uno.

// Referencia: barato, es lo que pone en el fuente
val ref = propiedad.type

// Resolucion: caro, averigua a que apunta de verdad
val tipo = ref.resolve()
val esNulo = tipo.nullability == Nullability.NULLABLE
val nombre = tipo.declaration.qualifiedName?.asString()
val args = tipo.arguments.map { it.type?.resolve() }

El modelo conserva además cosas que en el mundo de Java simplemente no existían. Un procesador puede preguntar si una clase lleva el modificador de datos, si una función es de suspensión, si una propiedad tiene campo de respaldo, cuál es el receptor de una extensión o qué documentación acompaña a una declaración. Todo eso estaba en el fuente desde el principio y solo hacía falta un modelo dispuesto a nombrarlo.

⚠️
Resolver no es gratis

La llamada que convierte una referencia de tipo en un tipo resuelto es, con diferencia, la operación más cara de la API. Resuelve fuera de los bucles, guarda el resultado si vas a consultarlo varias veces y evita resolver tipos que solo necesitas para imprimir un nombre, porque para eso suele bastar la referencia.

Dónde estamos en 2026

La situación actual es nítida y no admite mucho matiz. KSP2 es la implementación por defecto desde la serie 2.0.0 de la herramienta, KSP1 quedó marcado como obsoleto a partir de Kotlin 2.2 y ya no es compatible con Kotlin 2.3 ni posterior, y kapt está a efectos prácticos retirado: no recibe soporte para características nuevas del lenguaje y ninguna librería mantenida lo recomienda ya. Un proyecto que en 2026 siga dependiendo de kapt está atado a versiones antiguas del compilador y pagando un impuesto de construcción sin obtener nada a cambio.

plugins {
    id("com.google.devtools.ksp")
}

dependencies {
    // antes: kapt("com.ejemplo:procesador:1.0")
    ksp("com.ejemplo:procesador:1.0")
}

La migración, cuando la librería ofrece variante KSP, suele reducirse a ese cambio de configuración más la eliminación del plugin antiguo. Los problemas aparecen cuando alguna dependencia no tiene versión KSP, y entonces la decisión pasa a ser de gestión y no de sintaxis: buscar sustituto, escribir el procesador equivalente o aislar ese módulo mientras se decide.

Conviene tener presente un detalle de versionado que despista a mucha gente. El identificador de una versión de la herramienta se compone del número de Kotlin al que acompaña y del número propio de la herramienta, unidos por un guion. Eso significa que actualizar Kotlin obliga a actualizar también el procesador, y que una discrepancia entre ambos produce un fallo temprano y bastante explícito. Es una molestia menor comparada con la alternativa, que sería descubrir la incompatibilidad a mitad del análisis.

📝
Qué cambia al escribir un procesador propio

Si vienes de haber escrito procesadores contra la API de Java, el cambio mental principal es que ya no manipulas elementos y espejos de tipos, sino declaraciones de Kotlin y tipos de Kotlin. Desaparecen las heurísticas sobre nombres de métodos de acceso, desaparece la lectura de metadatos auxiliares y desaparece la necesidad de distinguir lo que el compilador generó de lo que la persona escribió.

Cada capa de traducción que se elimina devuelve información que nadie sabía que faltaba

La historia de kapt merece leerse como algo más que una anécdota de rendimiento, porque ilustra un patrón que reaparece constantemente en el diseño de sistemas. Cuando una tecnología nueva necesita aprovechar el ecosistema de una anterior, la salida más rápida es siempre construir un adaptador que presente lo nuevo con la forma de lo viejo. Esa decisión es correcta al principio, porque desbloquea de golpe un catálogo enorme de herramientas que de otro modo habría que reescribir, y kapt hizo exactamente eso: dio a Kotlin acceso instantáneo a todo el ecosistema de procesadores de anotaciones de Java el día uno. Pero un adaptador de este tipo tiene dos costes que solo se hacen visibles con los años. El primero es el coste de ejecución, que en el caso de los stubs consistía en rehacer casi todo el análisis del proyecto para tirarlo después. El segundo, más insidioso, es el coste de expresividad: el adaptador solo puede transmitir aquello que el modelo antiguo sabe nombrar, de modo que cualquier concepto propio de la tecnología nueva se aplana o se codifica mediante convenciones frágiles. Durante años se dio por natural que un procesador no pudiera distinguir una propiedad de un par de métodos, o que la nulabilidad llegara como una anotación que había que descifrar, simplemente porque nadie recordaba que esa pérdida no era inherente al problema sino al puente. Lo que enseña KSP es que la generación siguiente de una herramienta rara vez consiste en optimizar el puente: consiste en atreverse a definir el modelo propio y aceptar el coste de que el ecosistema tenga que migrar. Ese coste es real y explica por qué la transición llevó años, pero el resultado es que hoy se pueden hacer preguntas que antes ni siquiera se podían formular.

⚔️ Mide la diferencia
  1. Busca un módulo tuyo o de ejemplo que aún use kapt y mide su tiempo de construcción limpia y su tiempo incremental tras tocar una firma.
  2. Migra ese módulo a KSP y repite ambas mediciones, anotando la proporción de mejora.
  3. Escribe en papel qué información de una clase de datos tuya se perdería al proyectarla al modelo de Java.
  4. Dibuja la jerarquía de tipos de símbolos de KSP desde KSAnnotated hasta las declaraciones concretas.
  5. Revisa tus dependencias y elabora la lista de las que todavía no ofrecen variante KSP, con una decisión propuesta para cada una.