This episode masters metaprogramming and DSLs in Kotlin: building a domain-specific language with Kotlin syntax, type-safe builders with lambda receivers, annotation processing with kapt and KSP, and reflection and compile-time features used by modern libraries.

One reason Kotlin is so expressive is its ability to build Domain-Specific Languages (DSLs) — small languages inside the language, tailored to a single domain. Episode 16 covers type-safe builders, lambda receivers, annotation processing, and reflection.
DSLs in Kotlin aren't magic: they're built from ordinary language features like extension functions and lambdas with receivers. Popular frameworks such as the Gradle Kotlin DSL, Ktor routing, and Compose are all DSLs.
After this episode, you'll understand how DSLs work from the inside and know when to build one.
A DSL makes an API read like a configuration document. The best-known examples in the Kotlin ecosystem are the Gradle Kotlin DSL and Ktor routing — both use nested blocks that look like configuration even though they're plain Kotlin code.
The key to building a DSL: a combination of lambdas with receivers, extension functions, and intuitively named functions. You've mastered all three in episodes 3, 4, and 7.
Let's build a small DSL for server configuration:
class ServerConfig {
var host: String = "localhost"
var port: Int = 8080
val routes = mutableListOf<String>()
fun route(path: String) {
routes.add(path)
}
}
fun server(blok: ServerConfig.() -> Unit): ServerConfig {
val config = ServerConfig()
config.blok()
return config
}The server function takes a lambda with a ServerConfig receiver. DSL users can write server { host = ...; route("/") }, and all calls execute in the context of the config instance.
val cfg = server {
host = "0.0.0.0"
port = 9000
route("/api/v1")
route("/health")
}
println("${cfg.host}:${cfg.port}")The usage above looks like a configuration declaration, but server { ... } is an ordinary Kotlin function. That's the power of a DSL: domain APIs read naturally without parsing another language. You can test this DSL with kotlinc Main.kt -include-runtime -d dsl.jar and then run the JAR.
A lambda receiver makes the receiver's properties and functions available directly inside the block. In the server DSL above, host and route are available without a prefix because the receiver is ServerConfig. The compiler ensures every use is valid — that's the "type-safe" in type-safe builder.
Large DSLs build hierarchies with nested builders. This is the pattern Compose and Ktor use:
fun application(blok: Application.() -> Unit): Application {
val app = Application()
app.blok()
return app
}
class Application {
fun routing(blok: Routing.() -> Unit) {
val r = Routing()
r.blok()
routes.add(r)
}
}routing builds a sub-builder and adds it to the parent. Each level brings its own receiver, so you can write routing { get("/") { ... } } blocks without naming the objects explicitly.
kapt (Kotlin Annotation Processing Tool) lets Java annotation processors work on Kotlin code. It compiles Kotlin into Java stubs so older processors can run:
plugins {
id("org.jetbrains.kotlin.kapt")
}
dependencies {
kapt("com.example:annotation-processor:1.0")
}The kapt(...) block in dependencies adds the processor. kapt works but is slow and mimics Java; it's now in maintenance mode at JetBrains.
KSP (Kotlin Symbol Processing) is designed specifically for Kotlin, processing symbols directly without Java stubs. The result is faster compilation and an API that understands Kotlin features like data classes and nullable types:
plugins {
id("com.google.devtools.ksp") version "2.0.0-1.0.24"
}
dependencies {
ksp("com.example:kotlin-processor:1.0")
}KSP is the new standard for libraries like Room, Moshi, and Arrow. For new projects, choose KSP unless a library you use still requires kapt. Episode 8 noted that kotlinx.serialization doesn't even need annotation processing at all.
Reflection lets you read a class's structure at runtime — function names, annotations, and properties:
data class Produk(val nama: String)
fun main() {
val props = Produk::class.memberProperties
props.forEach { println("${it.name}: ${it.returnType}") }
}Produk::class.memberProperties returns a class's property metadata. Reflection is flexible but slow on hot paths — episode 15 already warned against using it on critical paths.
For performance, do metaprogramming at compile time, not runtime. Code generation via KSP or a compiler plugin produces fast, type-safe code. The direction of the Kotlin ecosystem is clear: from runtime reflection toward code generation at compile time.
Episode 16 opened up metaprogramming and DSLs: building domain languages with lambda receivers, type-safe builder hierarchies, the difference between kapt and KSP, and reflection and code generation as two metaprogramming approaches.
The key takeaways:
In episode 17 we'll discuss interoperability and migration — calling Java from Kotlin and vice versa, working with legacy Java codebases, nullability annotations and type safety across boundaries, and migrating a Java project to Kotlin gradually.