---
title: "When Is KSP the Right Tool?"
description: "In this article, you'll start from a KSP processor you might write for this job, then trace inward through KSP's input model, and the conditions where it fits"
language: "en"
publishedAt: "2026-10-27T01:29:41.131Z"
updatedAt: "2026-10-27T01:29:41.131Z"
authors:
  - name: "Jaewoong Eum"
    url: "https://www.revenuecat.com/blog/author/jaewoong-eum"
category: "Engineering"
categoryUrl: "https://www.revenuecat.com/blog/engineering"
canonical: "https://www.revenuecat.com/blog/engineering/when-ksp"
---

# When Is KSP the Right Tool?

In this article, you'll start from a KSP processor you might write for this job, then trace inward through KSP's input model, and the conditions where it fits

## Table of contents

- [The fundamental problem: A generated copy that goes stale](#the-fundamental-problem-a-generated-copy-that-goes-stale)
- [What KSP is: A processor that reads your declarations](#what-ksp-is-a-processor-that-reads-your-declarations)
- [When KSP is the right tool: Three conditions](#when-ksp-is-the-right-tool-three-conditions)
- [Where KSP's model stops: Inputs outside your source files](#where-ksps-model-stops-inputs-outside-your-source-files)
- [Two Gradle tasks: Separating the network from the generator](#two-gradle-tasks-separating-the-network-from-the-generator)
  - [The fetch task: An input Gradle can't fingerprint](#the-fetch-task-an-input-gradle-cant-fingerprint)
  - [The generate task: A pure function of the cache](#the-generate-task-a-pure-function-of-the-cache)
  - [The generator: The same KotlinPoet, a different destination](#the-generator-the-same-kotlinpoet-a-different-destination)
- [Where the plugin meets KSP: Generated sources are KSP inputs](#where-the-plugin-meets-ksp-generated-sources-are-ksp-inputs)
  - [The Variant API alternative: Registering the directory with its producer](#the-variant-api-alternative-registering-the-directory-with-its-producer)
- [Conclusion](#conclusion)

You add `@JsonClass(generateAdapter = true)` to a data class, build the project, and a `JsonAdapter` you never wrote appears under `build/generated/ksp`. KSP, Kotlin Symbol Processing, has become the default way Android libraries turn annotations into generated code, so it's natural to reach for it the moment you want generated code of your own.

The RevenueCat Codegen Gradle Plugin generates type safe Kotlin accessors for your entitlements (what a purchase unlocks) and offerings (the products your paywall shows), yet it contains no KSP processor. KSP appears in its source in one place, a fix for a build failure. So why would a code generator skip KSP, and how could KSP still break its build?

In this article, you'll start from a KSP processor you might write for this job, then trace inward through KSP's input model, the conditions where it fits, the point where it stops, the plugin's two Gradle tasks, and the one place its output meets KSP.

## **The fundamental problem: A generated copy that goes stale**

The [RevenueCat Codegen Gradle Plugin](https://www.revenuecat.com/blog/engineering/android-codegen) reads the entitlements and offerings configured in your RevenueCat dashboard and writes Kotlin accessors for them, so that a string lookup like this one:

```
val isPremium = customerInfo.entitlements["premium_access"]?.isActive == true
```

becomes a property the compiler can check:

```
val isPremium = customerInfo.isPremiumAccessActive
```

Generated code like this is a copy of a source of truth, and a copy is only useful while it matches the original. That gives every generator two questions to answer: where does the input come dfrom, and how does the build find out that the input changed?

If you were building this generator yourself, the obvious first attempt would be a KSP processor. Assume the dashboard's configuration, the schema, was already downloaded to a file. In a small JVM project, a processor reads the file's path from a KSP option and generates a constant, `Schema.VALUE`, from the file's contents. If you've ever passed `room.schemaLocation` to Room this way, the build script looks familiar:

```
ksp {
    arg("schema.path", file("schema.txt").absolutePath)
}
```

The first build generates the right value. Then change only schema.txt from v1 to v2, and build and run the app again. On KSP 2.3.12 with Gradle 9.8.1, the relevant console lines are:

```
> Task :app:kspKotlin UP-TO-DATE
> Task :app:compileKotlin UP-TO-DATE
...
> Task :app:run
Schema.VALUE=v1
```

The processor never ran, and the app printed the old value. The generator answered the first question and got the second one wrong: the build succeeded, and the app ran with data that was already out of date. To see why, you need to look at what KSP actually reads and tracks.

## **What KSP is: A processor that reads your declarations**

KSP's README describes it as "an API that you can use to develop lightweight compiler plugins," and a plugin built with it is called a symbol processor. A symbol is the compiler's description of something you declared, such as a class, a function, or a property, with its name, modifiers, and types. A processor never parses your source text. It receives your program as a tree of symbols, such as `KSClassDeclaration`, `KSFunctionDeclaration`, and `KSPropertyDeclaration`.

It can also ask for any type to be resolved: which class a name refers to, whether it's nullable, and what its type arguments are. It responds by writing new source files, which the Kotlin compiler compiles together with your code in the same build.

To see what KSP hands a processor and what it expects back, imagine a processor the plugin doesn't have, for a world where your entitlements were declared in code instead of in a dashboard:

```
@Target(AnnotationTarget.CLASS)
annotation class Entitlements(vararg val keys: String)

@Entitlements("premium_access", "pro_photo")
object AppEntitlements
```

The processor starts by asking the Resolver, KSP's entry point for querying the code being compiled, for annotated symbols:

```
class EntitlementsProcessor(
    private val codeGenerator: CodeGenerator,
) : SymbolProcessor {

    override fun process(resolver: Resolver): List<KSAnnotated> {
        resolver.getSymbolsWithAnnotation("com.example.Entitlements")
            .filterIsInstance<KSClassDeclaration>()
            .forEach { declaration -> generate(declaration) }
        return emptyList()
    }
```

For each declaration it finds, the processor reads the annotation's arguments. A vararg argument arrives as a List:

```
    private fun generate(declaration: KSClassDeclaration) {
        val keys = declaration.annotations
            .first { it.shortName.asString() == "Entitlements" }
            .arguments.single().value as List<*>
```

Then it builds the file with KotlinPoet, a library that writes Kotlin source from builder calls instead of string templates, and hands the file to KSP:

```
        val idObject = TypeSpec.objectBuilder("RCEntitlementId")
            .addOriginatingKSFile(declaration.containingFile!!)
        for (key in keys) {
            idObject.addProperty(
                PropertySpec.builder(key.toString().uppercase(), String::class, KModifier.CONST)
                    .initializer("%S", key)
                    .build(),
            )
        }

        FileSpec.builder("com.example.rc", "RCEntitlementId")
            .addType(idObject.build())
            .build()
            .writeTo(codeGenerator, aggregating = false)
    }
}
```

Three parts of this code define what KSP is:

- **Input**: `getSymbolsWithAnnotation` returns declarations from the code being compiled. This processor doesn't open any files. It reads declarations that KSP has already parsed, and asks KSP to resolve a type only when it needs one.
- **Output**: the file is written with KSP's `CodeGenerator`, not to a path you choose. KSP places it under `build/generated/ksp/`.
- **Origin**: `addOriginatingKSFile` records which source file the output came from, and `aggregating = false` says the output depends on that file alone. KotlinPoet's KSP module, `kotlinpoet-ksp`, packs both into a KSP `Dependencies` object in `writeTo`.
That object is how KSP keeps incremental builds correct, meaning how it decides what to regenerate when only some files changed. Here is its declaration in KSP's `CodeGenerator.kt`, with the KDoc trimmed. Look at what the public constructor accepts:

```
class Dependencies
private constructor(
    val isAllSources: Boolean,
    val aggregating: Boolean,
    val originatingFiles: List<KSFile>,
) {

    constructor(
        aggregating: Boolean,
        vararg sources: KSFile,
    ) : this(false, aggregating, sources.toList())

    companion object {
        val ALL_FILES = Dependencies(true, true, emptyList())
    }
}
```

The public constructor accepts `KSFile`s, the source files of the current compilation, and nothing else. No other KSP API lets an output depend on a file path, a URL, or an option value either.

The `aggregating` flag chooses between two kinds of output. An **isolating** output (`aggregating = false`) is like the translation of a single page: it needs redoing only when that page changes. An **aggregating** output is like a book's index: any edited or new page might change it, and `ALL_FILES` ties an output to every file at once.

On the next build, KSP compares the changed source files with the origins each output recorded, hands your processor only the files that need processing again, and puts back every other output from a copy it saved on the previous build. You get correct incremental builds without writing any tracking code, as long as each output names the source files it came from and everything it depends on is code this compilation sees. The [KSP incremental processing guide](https://kotlinlang.org/docs/ksp-incremental.html) covers the full rules.

## **When KSP is the right tool: Three conditions**

KSP fits a generator best when three conditions hold:

- **Input declared in your code**: annotations, classes, function signatures, anything the compiler parses as part of the build.
- **Output that needs the compiler's knowledge**: annotation arguments, resolved types, nullability, `suspend` modifiers, supertypes, and type parameters all arrive as symbols, so you never write a Kotlin parser.
- **Regeneration tied to those declarations**: the output should change exactly when the declarations change, and KSP's per file tracking gives you that for free.
The processors you already use are built around all three. Room, for example, has to know whether a DAO method is `suspend` to generate the right implementation, and that information is a property of a `KSFunctionDeclaration`.

KSP also limits what a processor can produce. [Its overview](https://kotlinlang.org/docs/ksp-overview.html) says it directly: "KSP-based processors can't examine expressions or statements, and they can't modify the source code." A processor can't look inside function bodies, and it can only add new files next to yours. A tool that needs to change code you wrote has to work below KSP.

A full Kotlin compiler plugin runs inside the compiler and can change what gets compiled, which is how kotlinx.serialization adds serializers to your classes and how the Compose compiler rewrites `@Composable` functions. A bytecode transform works on compiled classes instead. Hilt's Gradle plugin uses one to swap the superclass of each `@AndroidEntryPoint` class for the generated `Hilt_` class, so your source never has to name it.

## **Where KSP's model stops: Inputs outside your source files**

The `schema.txt` processor fails the first condition, and the stale value follows directly from it. Before running a task, Gradle fingerprints every input the task declared, a hash of each value and file, and compares the result with the previous run. If nothing changed, it prints `UP-TO-DATE` and skips the task. The declared input for an `arg()` option is the string itself: the path, which didn't change.

Gradle never looks inside the file, and KSP's own tracking can't help either, because it runs inside the task, and the task never ran. Switching the output to `aggregating = true` or `Dependencies.ALL_FILES`, or turning off KSP's incremental mode with `ksp.incremental=false`, produced the same stale value. It stayed stale until some unrelated change, such as an edit to a Kotlin file, made Gradle run the task again.

Tying the output to a source file, the way `EntitlementsProcessor` does, makes the failure quieter. Picture a processor that finds an annotated class the same way, reads `schema.txt`, and records the annotated class's file as the origin of its output. Edit `schema.txt` and, in the same build, add a new, unrelated Kotlin file. The new file changes a declared input, so Gradle runs `kspKotlin` this time, and KSP hands the processor only the source file that changed: the new one.

The annotated class isn't in it, so `getSymbolsWithAnnotation` returns nothing, and the processor writes nothing, even though it just read `v2`. The origin file didn't change either, so KSP puts back the copy it saved on the previous build. The app prints `v1` again, this time with `kspKotlin` visibly running in the build log.

Neither result is a KSP bug. KSP tracked every source file exactly as promised, and `schema.txt` was never one of them.

The fix has to come from Gradle's side, because Gradle decides whether the task runs. Instead of a plain string, pass KSP a `CommandLineArgumentProvider`: a small class whose annotated properties Gradle checks like any other task input, and whose `asArguments()` produces the option the processor reads. Mark the file with `@InputFile`, and Gradle fingerprints the file's contents, not just its path:

```
class SchemaArgProvider(
    @get:InputFile
    @get:PathSensitive(PathSensitivity.RELATIVE)
    val schema: File,
) : CommandLineArgumentProvider {
    override fun asArguments() = listOf("schema.path=${schema.absolutePath}")
}

ksp {
    arg(SchemaArgProvider(file("schema.txt")))
}
```

Now an edit to schema.txt reruns the task, and Gradle's info output shows how:

```
Task ':app:kspKotlin' is not up-to-date because:
  Input property 'commandLineArgumentProviders.$0.schema' file .../app/schema.txt has changed.
The input changes require a full rebuild for incremental task ':app:kspKotlin'.
```

The output is fresh, but notice the last line. Gradle can tell a task which source files changed, but only for the inputs the task marks as incremental. A change to any other input, this file included, makes Gradle run the task from scratch. The KSP Gradle plugin then throws away KSP's records of which output came from which file, and every source file in the module is processed again. You get the right value, but every schema edit now costs a full reprocess, which is the work KSP's incremental model exists to avoid.

Room made the same trade. Its DAOs meet all three conditions, but it also writes schema files during the build and reads them back to generate migrations automatically, so those files are inputs too. Its documentation says that if you pass `room.schemaLocation` as a processor option, you "must use Gradle's CommandLineArgumentProvider" for correct incremental and cached builds, and the Room Gradle plugin, added in Room 2.6.0, sets that up for you.

RevenueCat's input isn't a file at all. Check it against the three conditions: the input isn't declared in your code, the output is built from JSON and needs nothing only the compiler knows, and it should change when the dashboard changes, not when your source files do. None of them holds.

The network adds a practical problem on top. A processor that called the RevenueCat API would run only when the KSP task runs, which brings back the stale value from the first experiment. With Gradle's build cache turned on, even that run could be skipped. The KSP task is cacheable, so if an earlier build stored outputs for the same inputs, Gradle reuses them without running the task, and the API is never called.

The download needs its own rule for when to call the API again, one that runs whether or not your source files changed. The clearest home for that rule is a Gradle task of its own, whichever tool generates the code. Once that task has saved the schema to a file, a processor would only add cost: the file would have to be declared as a KSP input as well, and every schema change would reprocess the whole module. That's the design the plugin uses: no processor, and code generation in a Gradle task as well.

## **Two Gradle tasks: Separating the network from the generator**

The plugin splits the job along the line between what Gradle can fingerprint and what it can't. In the [plugin's source](https://github.com/RevenueCat/purchases-android/tree/main/codegen), `RevenueCatCodeGenPlugin.apply()`, the function Gradle calls when a build applies the plugin, registers both tasks. The first one owns the network. In its registration, four `set` calls pass along values from the `revenuecat { }` block in your build script. The one to watch is the fifth: `cacheDir` is a fixed directory under `build/` that the plugin picks itself:

```
val cacheDir = project.layout.buildDirectory.dir("revenuecat/cache")
val outputDir = project.layout.buildDirectory.dir("generated/revenuecat/kotlin")

val fetchTask: TaskProvider<FetchSchemaTask> = project.tasks.register(
    "rcFetchSchema",
    FetchSchemaTask::class.java,
) { task ->
    task.apiKey.set(extension.apiKey)
    task.projectId.set(extension.projectId)
    task.cacheTtlMinutes.set(extension.cacheTtlMinutes)
    task.offlineMode.set(extension.offlineMode)
    task.cacheDir.set(cacheDir)
}
```

The second task owns the code and receives the same cacheDir. Here is its registration, with the generation flags left out:

```
val generateTask: TaskProvider<GenerateCodeTask> = project.tasks.register(
    "rcGenerateCode",
    GenerateCodeTask::class.java,
) { task ->
    task.packageName.set(extension.packageName)
    task.namingStyle.set(extension.namingStyle)
    task.cacheDir.set(cacheDir)
    task.outputDir.set(outputDir)
    task.dependsOn(fetchTask)
}
```

Notice the handoff: `cacheDir` is the output of `rcFetchSchema` and the input of `rcGenerateCode`. A single JSON file in that directory, `revenuecat-schema.json`, is the only thing the generator ever reads. Everything about the network stays on one side of that file, and everything about Kotlin stays on the other.

### **The fetch task: An input Gradle can't fingerprint**

The real input of `rcFetchSchema` is the current state of your dashboard, and Gradle has no way to fingerprint that: it lives on RevenueCat's servers, and nothing on your disk changes when someone renames an entitlement. So `FetchSchemaTask` opts out of Gradle's check in its `init` block:

```
init {
    group = "revenuecat"
    description = "Fetches entitlement and offering schema from RevenueCat API v2"
    // Always evaluate the task action so the TTL check inside runs
    outputs.upToDateWhen { false }
}
```

With upToDateWhen { false }, Gradle runs the task's code on every build, and the code makes the decision itself:

```
val cache = SchemaCache(cacheDir.get().asFile)

if (cache.isValid(cacheTtlMinutes.get())) {
    logger.lifecycle("RevenueCat schema cache is still valid, skipping fetch.")
    return
}
```

`isValid` reads the timestamp stored inside the cached JSON and compares its age with `cacheTtlMinutes`, which defaults to 30. Only a missing, unreadable, or expired cache triggers the API calls. The TTL (time to live) is a policy for an input that never announces its changes: the plugin can't know when your dashboard changed, so it caps how long a successful fetch is reused. A failed fetch is a different case. The default, `USE_CACHE_OR_SKIP`, logs a warning and builds from the old cache, or skips code generation if there's no cache yet. Setting `offlineMode` to `FAIL` turns the failed fetch into a failed build.

### **The generate task: A pure function of the cache**

`rcGenerateCode` sits on the other side of the line. Its output depends only on the cached JSON and a few settings, and it declares all of them. Here are its properties, minus the package name and the four generation flags, which are declared like `namingStyle`:

```
// Note: @CacheableTask is intentionally omitted. The cache JSON includes a timestamp that
// changes on every fresh fetch, so the Gradle build cache would never produce a hit.
public abstract class GenerateCodeTask : DefaultTask() {

    @get:Input
    public abstract val namingStyle: Property<NamingStyle>

    @get:InputDirectory
    @get:PathSensitive(PathSensitivity.RELATIVE)
    public abstract val cacheDir: DirectoryProperty

    @get:OutputDirectory
    public abstract val outputDir: DirectoryProperty
}
```

Because every input is declared, Gradle's ordinary up to date check decides when to skip it. On a second build with nothing changed, exactly one task does any work:

```
> Task :app:rcFetchSchema
RevenueCat schema cache is still valid, skipping fetch.

> Task :app:rcGenerateCode UP-TO-DATE
...
> Task :app:kspDebugKotlin UP-TO-DATE
> Task :app:compileDebugKotlin UP-TO-DATE
```

`rcFetchSchema` runs because it has to, finds a valid cache, and returns without touching the JSON file. The file's fingerprint matches the previous build, so `rcGenerateCode` is skipped, and everything downstream stays up to date. When a fetch writes a new file, the fingerprint changes and the generator runs again.

The comment above the class is about Gradle's build cache, which reuses outputs that any build, on any machine, stored for the same inputs. The timestamp inside the JSON changes on every fresh fetch, even when the schema doesn't, so two machines or two CI runs that each fetch on their own never have identical inputs. Marking the task cacheable would mostly store outputs that no other build could reuse.

Inside the task, generation isn't incremental at all: it deletes the output directory and writes every file again. That's the difference in granularity from KSP. A KSP processor tracks each output file so it can regenerate one out of hundreds, while this task treats its whole output as one unit and lets Gradle decide whether to rebuild it.

### **The generator: The same KotlinPoet, a different destination**

The code that actually writes Kotlin will look familiar from the processor earlier in this article. Looking at `EntitlementsGenerator`:

```
private fun generateIdObject(entitlements: List<EntitlementSchema>, outputDir: File) {
    val objectBuilder = TypeSpec.objectBuilder("RCEntitlementId")
        .addKdoc("Auto-generated entitlement ID constants from RevenueCat dashboard.")

    for (entitlement in entitlements) {
        val constName = NamingConfig.toConstant(entitlement.lookupKey)
        objectBuilder.addProperty(
            PropertySpec.builder(constName, String::class)
                .addModifiers(KModifier.CONST)
                .initializer("%S", entitlement.lookupKey)
                .addKdoc("%L", entitlement.displayName)
                .build(),
        )
    }

    FileSpec.builder(packageName, "RCEntitlementId")
        .addType(objectBuilder.build())
        .build()
        .writeTo(outputDir)
}
```

Put it next to that processor and the builders are the same. The differences are where the data comes from and where the file goes. The input is a `List<EntitlementSchema>` parsed from the cached JSON instead of a `KSClassDeclaration`, and the last call writes to a plain directory instead of to KSP's `CodeGenerator`. There's no `addOriginatingKSFile` call, because no source file exists for the output to originate from.

## **Where the plugin meets KSP: Generated sources are KSP inputs**

Generated code only helps if it's compiled with your app, so the plugin adds its output directory to the `main` Kotlin source set. The registration that takes effect is equivalent to:

```
android.sourceSets.getByName("main").kotlin.srcDir(outputDir)
```

Here `outputDir` is that same directory, resolved to a plain `java.io.File`: just a path, with no record of which task writes to it. That detail matters in a moment. Every task that reads the `main` source set now reads `build/generated/revenuecat/kotlin`. 

That includes `compileDebugKotlin`, and it also includes `kspDebugKotlin`, because KSP processes the same sources the compiler compiles. In a test app with a small processor that logs every file `getAllFiles()` returns, KSP listed each generated RevenueCat file next to the app's own source. The plugin has no processor, yet KSP still reads everything it generates, and that's where the build can break.

The plugin's first prototype made only the `compile` tasks wait for `rcGenerateCode`. Take the clause that matches `ksp` task names out of the shipped plugin's wiring, shown below, and the test app fails on Gradle 9.8.1 with AGP 9.4.1 and KSP 2.3.12:

```
* What went wrong:
A problem was found with the configuration of task ':app:kspDebugKotlin' (type 'KspAATask').
Property has implicit dependency
  Gradle detected a problem with the following location: '.../app/build/generated/revenuecat/kotlin'
    Task ':app:kspDebugKotlin' uses this output of task ':app:rcGenerateCode' without declaring an explicit or implicit dependency. This can lead to incorrect results being produced, depending on what order the tasks are executed
```

Gradle isn't saying the tasks ran in the wrong order. In all six failing runs, across AGP 8 and 9, with KSP 2.3.11 and 2.3.12, `rcGenerateCode` had already finished before `kspDebugKotlin` started. It's saying that nothing guarantees that order. Gradle can see that `kspDebugKotlin` reads files from a directory that `rcGenerateCode` declares as its output, but no rule says KSP must wait for it. A `java.io.File` can't supply that rule, because it's only a path. Without a rule, a later build could run the two tasks in either order, so since Gradle 8.0 this fails the build instead of printing a warning.

Failing is the better outcome here. With the same broken wiring, running only `:app:kspDebugKotlin` succeeds silently: `rcGenerateCode` isn't part of that build at all, so nothing gets flagged, and KSP processes an empty or stale directory.

This is the one place KSP appears in the plugin's source. The shipped plugin declares the rule by task name: any task whose name looks like a Kotlin compile, KSP, or KAPT task runs after `rcGenerateCode`.

```
private fun configureKotlinTaskDependencies(
    project: Project,
    generateTask: TaskProvider<GenerateCodeTask>,
) {
    project.tasks.configureEach { task ->
        val isKotlinTask = task.name.endsWith("Kotlin") && (
            task.name.startsWith("compile") ||
                task.name.startsWith("ksp") ||
                task.name.startsWith("kapt")
            )
        if (isKotlinTask) {
            task.dependsOn(generateTask)
        }
    }
}
```

`configureEach` applies the rule to every task in the project, including the `ksp` and `kapt` tasks whenever those plugins register them, so the rule holds for every Android variant without the plugin knowing which processors you use. Matching by name covers the consumers the plugin knows about. A new consumer with a different naming scheme would need a new clause.

### **The Variant API alternative: Registering the directory with its producer**

The alternative is to never hand Gradle a bare `File`. AGP's Variant API, the `androidComponents` hooks that let a plugin adjust each build variant, can take the task that produces a directory instead of just its path. This isn't what the plugin ships today, but in the same test app, this wiring replaced both the source set call and the name matching:

```
val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java)
androidComponents.onVariants(androidComponents.selector().all()) { variant: Variant ->
    variant.sources.java?.addGeneratedSourceDirectory(generateTask, GenerateCodeTask::outputDir)
}
```

`addGeneratedSourceDirectory` receives the task and the property that holds its output, so Gradle works out the order by itself: every task that reads the variant's sources, the compiler and KSP included, now runs after `rcGenerateCode`. Without name matching or any `dependsOn` on the Kotlin and KSP tasks, the test app built on both AGP 9.4.1 and AGP 8.13.2, with `rcGenerateCode` running first and KSP seeing every generated file.

The trade off is a closer coupling to how AGP behaves across versions. The registration goes through `sources.java`, even though the files are Kotlin. On AGP 8, Kotlin support comes from the separate `org.jetbrains.kotlin.android` plugin, and that plugin and KSP pick up a variant's generated directories only from `sources.java`. In the test app, a directory added to `sources.kotlin` was silently ignored, and the build failed with unresolved references to the generated code.

The call itself has also changed between releases. Before AGP 8.12, `addGeneratedSourceDirectory` overwrote the output location a task had set. Since 8.12, it only supplies a default. The test app covered only AGP 8.13.2 and 9.4.1, and SQLDelight and Apollo Kotlin both carry version checks for these differences. None of this affects apps that use the plugin today, because it never calls `addGeneratedSourceDirectory`. It registers its directory through the `main` source set and orders the compile, KSP, and KAPT tasks by name.

## **Conclusion**

Before you write a generator, find its source of truth and ask who notices when it changes. If the compiler parses it, a KSP processor gives you incremental builds without any tracking code of your own. If it lives anywhere else, such as a schema file or a `.proto`, make it a declared input of a Gradle task, because the build only sees what you declare. Then make sure every task that reads the generated sources, KSP's included, knows which task produces them.

KSP asks one question: which of your source files changed? A Gradle task asks another: which of my declared inputs changed? A dashboard on a server can answer neither, so the plugin turns it into a file that can. Choosing where code generation runs is choosing which of those two questions your input can answer.
