Skip to Content
Course content

67: Building a Kotlin DSL for Configuration

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've probably seen the configuration blocks in Gradle or Ktor and assumed that Kotlin has some secret, built-in "DSL mode" or that these libraries are using a complex compiler plugin to rewrite the syntax. It looks like a different language entirely, right? I remember thinking the same thing when I first saw build.gradle.kts. I spent way too much time looking for a "DSL keyword" that didn't exist.

The Myth: DSLs Require Special Language Magic

The misconception is that a Domain Specific Language is a structural feature of the compiler. You might think you need a framework to "parse" a block of code into a configuration object. Let's look at how a beginner usually tries to solve this: by creating a giant constructor or a builder class with a bunch of setPort() and setHost() methods.

// The "Standard" way - clunky and verbose
val config = ServerConfig().apply {
    setPort(8080)
    setHost("localhost")
    setTimeout(30)
}

While apply gets us closer, it doesn't feel like a language. It feels like we're just mutating an object. The "magic" you're looking for isn't a feature; it's a pattern. We aren't changing how Kotlin works; we're just using function types to change the context of this.

The Reality: Function Literals with Receivers

The real secret to a Kotlin DSL is the Function Literal with Receiver. You've seen () -> Unit before, but the DSL powerhouse is T.() -> Unit. That little T. changes everything. It tells Kotlin: "This lambda isn't just a block of code; it's an extension function that runs inside an instance of T."

I'll show you how to build a simple ServerConfig DSL from scratch. First, we define the data we want to capture, and then we create a "builder" function that accepts a lambda with that data class as its receiver.

class ServerConfig {
    var port: Int = 80 // Default value
    var host: String = "127.0.0.1"
    var timeout: Int = 30

    override fun toString() = "Server(host=$host, port=$port, timeout=$timeout)"
}

// This is the "magic" function
fun server(init: ServerConfig.() -> Unit): ServerConfig {
    val config = ServerConfig()
    config.init() // We execute the lambda inside the context of the 'config' object
    return config
}

// Now look at the usage:
val myServer = server {
    port = 8080
    host = "production.example.com"
    // timeout is left as default
}

Notice that inside the server { ... } block, I didn't have to write config.port = 8080. Because the lambda is ServerConfig.() -> Unit, the ServerConfig instance becomes this. It's an elegant way to hide the boilerplate of object instantiation and mutation.

Preventing Scope Leakage with @DslMarker

Once you start nesting DSLs—say, a server block that contains a security block—you'll hit a frustrating problem: scope leaking. Without a guard, you can accidentally call a server property inside the security block, and the compiler won't stop you because both are available in the implicit this chain.

To fix this, we use @DslMarker. This tells the compiler, "If I'm inside a block marked with this annotation, I shouldn't be able to access the receiver of an outer block of the same type."

@DslMarker
annotation class ServerDsl

@ServerDsl
class ServerConfig {
    var port: Int = 80
    
    fun security(init: SecurityConfig.() -> Unit) {
        // nesting logic here
    }
}

@ServerDsl
class SecurityConfig {
    var enabled: Boolean = false
}

// Now, if you try to set 'port' inside the 'security' block, 
// Kotlin will throw a compiler error instead of letting you 
// accidentally mutate the outer server object.

I always recommend adding a marker annotation the moment your DSL hits a second level of nesting. It saves you from those "how did this value change?" bugs that are a nightmare to debug in production.




📋 Practical Task

Exercise: Build a Database Connection Pool DSL

Your goal is to create a type-safe DSL for configuring a database connection pool. This is a common pattern in infrastructure-as-code tools.

Requirements:

  • Create a DbConfig class with properties for url (String), maxConnections (Int), and driver (String).
  • Create a nested RetryPolicy class with properties for attempts (Int) and delayMs (Long).
  • Implement a DbConfig.retryPolicy { ... } function that allows the RetryPolicy to be configured inside the DbConfig block.
  • Implement a top-level database { ... } builder function that returns a configured DbConfig instance.
  • Use a @DslMarker annotation to ensure that DbConfig properties cannot be modified inside the retryPolicy block.

Expected Usage:

val myDb = database {
    url = "jdbc:mysql://localhost:3306/mydb"
    maxConnections = 20
    driver = "com.mysql.cj.jdbc.Driver"
    
    retryPolicy {
        attempts = 3
        delayMs = 1000
        // url = "wrong" // This should trigger a compiler error thanks to @DslMarker
    }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.