Lesson 76 of 83 advanced

KSP vs KAPT, Code Generation & Serialization

Understand annotation processing, build performance, and serialization strategies in modern Android

Open interactive version (quiz + challenge)

Real-world analogy

KAPT is like translating a Kotlin book to Japanese by first translating it to English (Java stubs), then to Japanese — slow and lossy. KSP is like having a translator who reads Kotlin directly — 2x faster and understands all the nuances. kotlinx.serialization is like having the author write the translation guide into the original book at print time (compile-time), while Gson is like hiring a translator who reads the book at runtime using a magnifying glass (reflection) — functional but slower and fragile.

What is it?

This lesson covers the annotation processing and serialization landscape in modern Android development. KAPT processes Kotlin via Java stubs (slow), while KSP processes Kotlin symbols directly (2x faster). For serialization, Gson uses runtime reflection, Moshi offers Kotlin-aware codegen, and kotlinx.serialization uses a compiler plugin for zero-reflection, compile-time serialization. Understanding these tools and their tradeoffs is essential for build performance, runtime performance, and architecture decisions.

Real-world relevance

Every production Android app uses annotation processing (Room, Hilt, Moshi/serialization) and JSON serialization (API responses). Companies like Uber and Airbnb reported 20-30% build time improvements migrating from KAPT to KSP across hundreds of modules. Square created Moshi specifically because Gson's lack of Kotlin null-safety caused production crashes. Netflix uses kotlinx.serialization in their Kotlin Multiplatform SDK that powers Android, iOS, and TV apps with shared serialization logic.

Key points

Code example

// === kotlinx.serialization Setup & Usage ===
// build.gradle.kts
// plugins { kotlin("plugin.serialization") version "1.9.22" }
// dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") }

@Serializable
data class ApiResponse<T : @Serializable Any>(
    @SerialName("status_code")
    val statusCode: Int,
    val message: String,
    val data: T,
    val timestamp: Long = System.currentTimeMillis()
)

@Serializable
data class User(
    val id: Long,
    val name: String,
    val email: String,
    @SerialName("avatar_url")
    val avatarUrl: String? = null,
    val role: UserRole = UserRole.MEMBER
)

@Serializable
enum class UserRole {
    @SerialName("admin") ADMIN,
    @SerialName("member") MEMBER,
    @SerialName("guest") GUEST
}

// Configured Json instance (reuse this!)
val appJson = Json {
    ignoreUnknownKeys = true
    coerceInputValues = true
    encodeDefaults = false
    prettyPrint = false
    isLenient = false
    explicitNulls = false
}

// Usage
fun parseUser(jsonString: String): User {
    return appJson.decodeFromString<User>(jsonString)
}

fun serializeUser(user: User): String {
    return appJson.encodeToString(user)
}

// === Sealed Class Polymorphic Serialization ===
@Serializable
sealed interface NetworkResult<out T> {
    @Serializable
    @SerialName("success")
    data class Success<T : @Serializable Any>(
        val data: T
    ) : NetworkResult<T>

    @Serializable
    @SerialName("error")
    data class Error(
        val code: Int,
        val message: String
    ) : NetworkResult<Nothing>
}

// === Custom Serializer ===
object InstantSerializer : KSerializer<Instant> {
    override val descriptor = PrimitiveSerialDescriptor(
        "Instant",
        PrimitiveKind.LONG
    )

    override fun serialize(
        encoder: Encoder,
        value: Instant
    ) {
        encoder.encodeLong(value.toEpochMilliseconds())
    }

    override fun deserialize(decoder: Decoder): Instant {
        return Instant.fromEpochMilliseconds(
            decoder.decodeLong()
        )
    }
}

@Serializable
data class Event(
    val name: String,
    @Serializable(with = InstantSerializer::class)
    val createdAt: Instant
)

// === Retrofit + kotlinx.serialization ===
val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .addConverterFactory(
        appJson.asConverterFactory(
            "application/json".toMediaType()
        )
    )
    .build()

// === KSP Processor Example ===
// In a separate :processor module
class AutoFactoryProcessorProvider : SymbolProcessorProvider {
    override fun create(
        environment: SymbolProcessorEnvironment
    ): SymbolProcessor = AutoFactoryProcessor(environment)
}

class AutoFactoryProcessor(
    private val environment: SymbolProcessorEnvironment
) : SymbolProcessor {

    override fun process(
        resolver: Resolver
    ): List<KSAnnotated> {
        val symbols = resolver.getSymbolsWithAnnotation(
            "com.example.AutoFactory"
        )

        val unprocessed = mutableListOf<KSAnnotated>()

        symbols.forEach { symbol ->
            if (symbol !is KSClassDeclaration) {
                environment.logger.error(
                    "@AutoFactory must target a class",
                    symbol
                )
                return@forEach
            }

            if (!symbol.validate()) {
                unprocessed.add(symbol)
                return@forEach
            }

            generateFactory(symbol)
        }

        return unprocessed
    }

    private fun generateFactory(
        classDecl: KSClassDeclaration
    ) {
        val packageName = classDecl.packageName.asString()
        val className = classDecl.simpleName.asString()
        val factoryName = "${className}Factory"

        val file = environment.codeGenerator.createNewFile(
            dependencies = Dependencies(
                aggregating = false,
                classDecl.containingFile!!
            ),
            packageName = packageName,
            fileName = factoryName
        )

        val constructor = classDecl
            .primaryConstructor
            ?: return

        val params = constructor.parameters.joinToString(
            separator = ",
    "
        ) { param ->
            val name = param.name?.asString() ?: return
            val type = param.type.resolve()
                .declaration.qualifiedName?.asString()
            "$name: $type"
        }

        file.writer().use { writer ->
            writer.write(
                """
                |package $packageName
                |
                |object $factoryName {
                |    fun create(
                |        $params
                |    ): $className = $className(
                |        ${constructor.parameters.joinToString {
                    it.name?.asString() ?: ""
                }}
                |    )
                |}
                """.trimMargin()
            )
        }
    }
}

// === build.gradle.kts Migration Example ===
// BEFORE (KAPT):
// plugins { id("kotlin-kapt") }
// dependencies {
//     kapt("androidx.room:room-compiler:2.6.1")
//     kapt("com.google.dagger:hilt-compiler:2.50")
//     kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
// }

// AFTER (KSP):
// plugins { id("com.google.devtools.ksp") version "1.9.22-1.0.17" }
// dependencies {
//     ksp("androidx.room:room-compiler:2.6.1")
//     ksp("com.google.dagger:hilt-compiler:2.50")
//     ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
// }
// Note: KSP version must match Kotlin version!

// === Room KSP Configuration ===
// ksp {
//     arg("room.schemaLocation", "$projectDir/schemas")
//     arg("room.incremental", "true")
//     arg("room.generateKotlin", "true")
// }

Line-by-line walkthrough

  1. 1. The @Serializable annotation triggers the kotlinx.serialization compiler plugin to generate a serializer for each class at compile time.
  2. 2. @SerialName('status_code') maps the Kotlin property statusCode to the JSON key 'status_code' — no runtime reflection needed.
  3. 3. Default values (avatarUrl = null, role = MEMBER) are handled natively — Gson would ignore these and set null/first-enum.
  4. 4. The appJson instance configures parsing behavior. ignoreUnknownKeys prevents crashes when the API adds new fields.
  5. 5. explicitNulls = false omits null fields from serialized output, reducing payload size.
  6. 6. Sealed interface NetworkResult with @SerialName on subtypes enables type-safe polymorphic JSON parsing with a discriminator.
  7. 7. InstantSerializer demonstrates custom serialization — implementing KSerializer with descriptor, serialize, and deserialize.
  8. 8. The KSP processor's SymbolProcessorProvider is the entry point, registered via META-INF/services.
  9. 9. resolver.getSymbolsWithAnnotation() finds all @AutoFactory-annotated classes. validate() checks if the symbol is fully resolved.
  10. 10. CodeGenerator.createNewFile with Dependencies enables incremental processing — KSP only re-processes changed files.
  11. 11. The KAPT-to-KSP migration is a dependency-level change: swap 'kapt' for 'ksp' and update the plugin. Library APIs remain identical.
  12. 12. Room KSP args like room.generateKotlin = true enable Kotlin code generation instead of Java, improving null-safety and readability.

Spot the bug

// Gson deserialization
data class UserProfile(
    val id: Long,
    val name: String,
    val email: String,
    val bio: String = "No bio provided"
)

val json = """{"id": 1, "name": "Alice", "email": null}"""
val user = Gson().fromJson(json, UserProfile::class.java)
println(user.email) // What happens here?
println(user.bio)   // And here?
Need a hint?
Gson uses reflection to create objects, bypassing Kotlin's constructor. What happens to non-nullable fields and default values?
Show answer
Bug 1: user.email is null at runtime even though it's declared as non-nullable String. Gson uses Unsafe allocation (bypassing the constructor) and sets null directly via reflection. This will throw a NullPointerException when email is used in Kotlin code expecting non-null. Bug 2: user.bio is null, NOT 'No bio provided' — Gson bypasses the Kotlin constructor so default parameter values are never applied. Fix: Use kotlinx.serialization (@Serializable) or Moshi with codegen, both of which respect Kotlin null-safety and default values.

Explain like I'm 5

Imagine you write a letter in Korean. KAPT is like a translator who first converts it to English, then reads the English to understand what you wrote — it takes twice as long and some Korean jokes get lost in translation. KSP is a translator who reads Korean directly — much faster and they get all the jokes! For serialization: Gson is like taking apart a LEGO set by looking at the finished model (slow, might miss pieces). kotlinx.serialization is like having the instruction booklet built right into the box — it knows exactly how to build and take apart the set because the instructions were printed at the factory.

Fun fact

The KSP project was born from frustration inside Google's own Android team. They were building Kotlin-first libraries (Room, Hilt) but had to maintain Java annotation processors that couldn't understand Kotlin features. When Room encountered a Kotlin data class with default parameter values, it simply couldn't see them through KAPT's Java stubs. KSP was initially an internal project before being open-sourced. The version numbering (e.g., 1.9.22-1.0.17) ties the KSP version to the exact Kotlin compiler version because KSP hooks directly into the compiler — a version mismatch crashes the build instantly.

Hands-on challenge

Build a complete serialization benchmark and migration project: (1) Create identical data models (User, Post, Comment with nested objects and lists) with Gson annotations, Moshi annotations, and @Serializable, (2) Write a benchmark that measures serialization and deserialization times for 1000 objects with each library, (3) Create a custom KSP processor that generates a `toMap()` extension function for any class annotated with @Mappable — the processor should read all properties and generate a Map builder, (4) Demonstrate a Gson null-safety bug where a non-nullable Kotlin field receives null from JSON, and show how both Moshi and kotlinx.serialization handle it correctly, (5) Configure Room with KSP including schema export, and measure the build time difference between KAPT and KSP configurations.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery