1. Introduction
If you’ve just gotten used to the idea of “wow, I can find a method by name and call it”, it’s only natural to want to apply it everywhere: auto-commands in a CLI, auto-generated help, “hook in plugins”, “avoid a manual function registry”… and so on.
The problem is that runtime reflection is like searching for the right object in a room with the lights off: it’s possible in general, but you’ll keep smashing your little toe into a stool. Even if you wrote everything carefully, reflection almost always means extra runtime work: lookups, checks, argument boxing, error handling, debugging complexity. Plus, you inevitably start depending on strings—and strings, as we know, don’t get “highlighted in red” in the IDE when you make a mistake in them.
And here a good engineering question appears: “Can we make the computer do the boring work for us, but before the program even starts?”
KSP: what it is in plain words
KSP (Kotlin Symbol Processing) is a mechanism that lets you, at compile time, walk through your Kotlin code, find the needed elements (classes, functions, properties), usually marked with annotations, and generate new Kotlin code (.kt files). After generation, the compiler simply compiles those files together with your project, as if you had written them by hand.
A key idea here: KSP does not “speed up compilation for the sake of compilation” and does not “replace Kotlin”. It adds a stage: discover the needed elements → generate sources → then it’s just normal compilation.
If reflection is like “we run the program and, during execution, ask: ‘Hey object, what methods do you have?’”, then KSP is “we compiled a directory of what methods exist before starting, and at runtime we just read the directory”.
2. The main flow: “annotation → generation → regular code”
Now we’ll build an important “mental model”. Try to see it as a pipeline diagram: raw material in, finished part out.
Pipeline diagram
Suppose we have a project’s sources, and we want part of the code to “auto-wire itself”. Then the overall pipeline looks roughly like this:
flowchart TD
A["Your sources (.kt)"] --> B["KSP: symbol analysis (classes/functions/annotations)"]
B --> C["Generated .kt files (build/generated/...)"]
C --> D["Regular Kotlin compilation"]
D --> E["Bytecode / JAR"]
E --> F["Run: regular Kotlin code without reflection"]
The key point: KSP does the work where the compiler “can see everything”, and at runtime “nothing needs to be searched anymore”.
An annotation is just a marker
A very common beginner confusion: “I put an annotation on it—why didn’t anything happen?”
Because an annotation by itself is just a marker. It starts to “mean something” only if there is a tool that reads this marker and acts on it (in our case, a KSP processor/generator).
A simplest marker annotation can look like this:
@Target(AnnotationTarget.FUNCTION)
annotation class CliCommand(val name: String)
By itself, it won’t add a command to your application. But it gives the generator a clear signal: “This is a command—process it.”
4. Practical example: CLI “manually”, “with reflection”, and “with KSP”
So that KSP doesn’t remain an abstract word from the build world, let’s tie it to our learning console application. Suppose we keep developing a CLI app (let’s call it BudgetCli), where there are commands like "add", "list", "help".
Manual command registry
The classic “simple” way is to create a Map<String, (List<String>) -> Unit> and register commands manually. This works, but it feels like bookkeeping: useful, but not joyful.
typealias CommandHandler = (List<String>) -> Unit
val commands: Map<String, CommandHandler> = mapOf(
"help" to ::cmdHelp,
"list" to ::cmdList
)
The problem here is not strings per se; it’s that you must remember to register things. You added cmdAdd()—but didn’t add it to the Map—so the command “kind of exists” but “kind of doesn’t”.
Runtime reflection as an auto-scanner
After a lecture on reflection, the temptation appears: “What if we find all functions annotated with @CliCommand at runtime!”
The idea sounds nice, but it has a cost: at runtime you need to search, filter, and handle errors. And if you select by a string for a method or annotation name, you’re back living in the world of strings.
We won’t dive into implementing such a scanner here (that was in the previous lesson), but it’s important to pin down: runtime reflection is “auto-assembling commands on the spot”, right at startup.
The KSP approach: commands are “assembled” before startup
Now we do the same thing, but in a KSP style: we want to mark command functions with an annotation, and then get a generated registry.
Step 1. Write the annotation
@Target(AnnotationTarget.FUNCTION)
annotation class CliCommand(val name: String)
Step 2. Annotate the command functions
Let’s imagine we have a file Commands.kt:
@CliCommand("help")
fun cmdHelp(args: List<String>) {
println("Commands: help, list") // Commands: help, list
}
@CliCommand("list")
fun cmdList(args: List<String>) {
println("List is empty for now") // List is empty for now
}
Step 3. What KSP “roughly” generates
An important point: we are not writing a KSP processor in this course. But we can understand what code should come out “at the end”.
For example, the generator can create a file GeneratedCliRegistry.kt:
typealias CommandHandler = (List<String>) -> Unit
object GeneratedCliRegistry {
val commands: Map<String, CommandHandler> = mapOf(
"help" to ::cmdHelp,
"list" to ::cmdList
)
}
Notice a nice thing: ::cmdHelp is a regular callable reference that the compiler will verify. If you rename cmdHelp, and the generation (or the generator) doesn’t match the new names, you’ll get a compile-time error, not a “command not found” at runtime. This is a fundamental difference.
Step 4. Use the registry in main
fun runCommand(line: String) {
val parts = line.trim().split(" ").filter { it.isNotBlank() }
val name = parts.firstOrNull() ?: return
val args = parts.drop(1)
val handler = GeneratedCliRegistry.commands[name]
handler?.invoke(args) ?: println("Unknown command: $name")
}
And here comes the “moment of truth”: at runtime we no longer search for functions via reflection. We just pulled a handler from the Map and invoked it. Fast, predictable, type-safe (as much as is possible for a Map<String, ...>).
5. Why generated code is a strong idea
It’s easy to “fall in love” with KSP, because it promises automation without runtime magic. But it’s better to keep an honest balance in mind from the start.
Pros: speed and predictability
When KSP generates Kotlin code, the regular compiler runs next: it checks types, visibility, signatures, and whether functions exist. What could “blow up on the user” with runtime reflection here fails at build time—which is almost always cheaper and safer.
As a nice bonus, you can generate not only command registries, but also help output for help, serialization tables, field mappings, adapters, and other boring chunks that people usually write mechanically.
Cons: the build becomes more complex
KSP is a tool that lives next to the compiler. Therefore, it is sensitive to Kotlin versions, Gradle plugins, and the environment.
This doesn’t mean “KSP is bad”. It means “KSP is part of the build ecosystem”, and the build is also part of the product: it must be maintained, updated carefully, and verified.
6. How to choose an approach and design generation
It’s very tempting to have a “universal hammer”. But in engineering, a universal hammer quickly turns into a universal problem.
Selection table
| Task | Best choice | Why |
|---|---|---|
| The code structure is known at compile time; you need to “auto-wire” elements (commands, mapping, registries) | KSP | You can generate regular Kotlin code, get compiler verification, and runtime speed |
| The structure is unknown until startup (for example, data comes from outside, plugins are loaded from files) | Runtime approaches (sometimes reflection) | You can’t generate a list in advance of what doesn’t exist yet |
| You need simple logic, 2–3 commands, the project is educational | “Manually” | The cheapest option in terms of complexity: less build magic, easier to explain and maintain |
| You need type metadata for diagnostics/logs | Minimal reflection (::class) | Sometimes KClass.simpleName is enough, and it doesn’t grow into a system |
The point of the table is not to “ban” anything. The point is to understand: KSP is an excellent tool when you truly benefit from generation. And if you’re writing a single 200-line file, the generator might end up being more complex than the task itself.
Why generation often creates extension functions
There is a popular code generation style: KSP generates extension functions so your code looks “native”, as if it’s part of the API.
For example, instead of GeneratedCliRegistry.formatHelp() you can generate:
fun String.toCommandName(): String = trim().lowercase()
Or for a model:
data class Expense(val title: String, val amount: Int)
fun Expense.toLogLine(): String = "Expense(title=$title, amount=$amount)"
Why is this convenient? Because extension functions in Kotlin are resolved statically, at compile time: the compiler decides which extension function to call based on the declared type, not “at runtime by magic”. This is one factor in why generated code is usually fast and predictable.
Another advantage of the extension style: it helps you avoid building huge GeneratedUtil123 classes. You get small functions next to the types they’re intended for, and the IDE provides autocompletion as usual.
How to “think in KSP”: contracts matter more than guesses
When people design generation for the first time, they often want to “be smart”: for the generator to guess, auto-fix, invent names, handle every edge case. Usually this ends up with the generator looking like a second compiler… only worse (because it’s yours).
A healthy approach to KSP is to treat it as a strict pipeline: we feed clear rules (a contract) as input, and we get very predictable code as output.
For example, for our CLI commands we can agree on a contract in advance:
The command text is set in @CliCommand("name"), the function must have the signature (List<String>) -> Unit, and all commands are collected into GeneratedCliRegistry.commands. That’s it.
No “if the function has no arguments, I’ll take it too”, no “if there are two identical names, I’ll pick the last one”. Such “kind guesses” usually turn into an un-debuggable lottery.
7. Typical mistakes when using the KSP idea
Mistake #1: expecting the annotation to “work by itself”.
It’s very easy to put @CliCommand above a function and wait for the command to appear. But an annotation is just a marker in the code. Until you add a handler (generator) to the project, nothing happens—and that’s normal. Keep the model in mind: “the annotation is input data for a tool”.
Mistake #2: trying to make the generator a substitute for architecture.
Sometimes people start generating everything: business logic, algorithms, half the application. This quickly leads to the project becoming “a build around generation” rather than “generation around the project”. A good generator usually automates boring glue: registration, simple adapters, repetitive code. If the generator makes the meaningful decisions for you, that’s a warning sign.
Mistake #3: overly “smart” rules and implicit priorities.
As soon as rules appear in generation like “if there are two options, choose the most suitable”, you’ll have to explain what “suitable” means and why today it picked one and tomorrow another. In practice it’s better when the generator either does exactly what you told it to do, or fails with a clear error and asks you to fix the input data.
Mistake #4: lack of fail-fast behavior on conflicts.
If two functions are annotated with @CliCommand("list"), the generator should not “sort it out somehow”, but stop the build and explain the conflict. Otherwise you’ll get a situation where the app starts, the “wrong” command runs, and you spend half a day thinking you have a logic bug—when in fact the bug is in registration.
Mistake #5: version dependence and ignoring that the build is part of the product.
KSP lives next to the compiler and Gradle infrastructure, so it is sensitive to updates. This is not scare-mongering; it’s the reality of tools at this level: if a project uses generation, “updating Kotlin randomly” is no longer a minor thing, but an event that requires care.
Mistake #6: generating an API that is impossible to use without reading the generator’s sources.
Generated code is also an API. If you generate weird names, unexpected classes, and confusing signatures, it will be hard to use even for you a month later. A good style is to generate minimal, readable Kotlin that you wouldn’t be embarrassed to show to a person.
Mistake #7: believing that “generated code must not be touched”.
You really can’t edit it by hand (because it will be regenerated), but you must be able to read it. When something breaks, the most practical skill is to open the generated .kt files and see: “What exactly did we end up with?” This is often faster than any assumptions and guesswork.