In the previous tutorial, you learned about inline functions and reified types. Now let’s learn about DSLs. A DSL (Domain-Specific Language) is a small language designed for a specific task. Kotlin makes it easy to create DSLs using lambdas with receivers.

In this tutorial, you will learn:

  • Lambdas with receiver
  • Building a config DSL
  • Nested DSL builders
  • @DslMarker annotation
  • HTML builder DSL
  • Route DSL
  • Query builder DSL
  • Gradle-style DSL

What is a DSL?

A DSL is a language designed for a specific domain. You already use Kotlin DSLs every day:

// Gradle build script — a DSL
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
}

// Ktor routing — a DSL
routing {
    get("/") { call.respondText("Hello") }
}

// Android Compose — a DSL
Column {
    Text("Hello")
    Button(onClick = { }) { Text("Click") }
}

These all look like special syntax, but they are regular Kotlin code using lambdas with receivers.

Lambdas with Receiver

A lambda with receiver is a lambda that has access to the members of a receiver object. The type is written as ReceiverType.() -> ReturnType.

fun buildGreeting(block: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.block() // Call the lambda with sb as receiver
    return sb.toString()
}

// Usage — inside the lambda, you can call StringBuilder methods directly
val greeting = buildGreeting {
    append("Hello, ")   // this.append("Hello, ")
    append("World!")     // this.append("World!")
}
println(greeting) // "Hello, World!"

Inside the lambda, this refers to the StringBuilder. You can call its methods without prefixing them. This is what makes DSLs feel like a special language.

How it Works

// Without receiver — must use "it" or a parameter name
fun buildGreetingNormal(block: (StringBuilder) -> Unit): String {
    val sb = StringBuilder()
    block(sb)
    return sb.toString()
}
val greeting = buildGreetingNormal { sb ->
    sb.append("Hello, ")  // Must prefix with sb
    sb.append("World!")
}

// With receiver — cleaner syntax
fun buildGreeting(block: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.block()
    return sb.toString()
}
val greeting = buildGreeting {
    append("Hello, ")  // No prefix needed
    append("World!")
}

Building a Config DSL

The simplest DSL is a configuration builder. Create a class with mutable properties and a builder function.

class ServerConfig {
    var host: String = "localhost"
    var port: Int = 8080
    var maxConnections: Int = 100
    var ssl: Boolean = false
}

fun serverConfig(block: ServerConfig.() -> Unit): ServerConfig {
    val config = ServerConfig()
    config.block()
    return config
}

Usage:

val server = serverConfig {
    host = "api.example.com"
    port = 443
    ssl = true
    maxConnections = 200
}

This reads almost like a configuration file. No = with config.host, no builder pattern boilerplate. Just set the properties directly.

Default Values

Anything you don’t set keeps its default value:

val minimal = serverConfig {
    host = "api.example.com"
}
// port = 8080, ssl = false, maxConnections = 100

Nested DSL Builders

For complex configurations, nest builders inside each other:

class AppConfig {
    var name: String = ""
    var version: String = ""
    var server: ServerConfig = ServerConfig()
    var database: DatabaseConfig = DatabaseConfig()
    var logging: LoggingConfig = LoggingConfig()

    fun server(block: ServerConfig.() -> Unit) {
        server = ServerConfig().apply(block)
    }

    fun database(block: DatabaseConfig.() -> Unit) {
        database = DatabaseConfig().apply(block)
    }

    fun logging(block: LoggingConfig.() -> Unit) {
        logging = LoggingConfig().apply(block)
    }
}

fun appConfig(block: AppConfig.() -> Unit): AppConfig {
    return AppConfig().apply(block)
}

Usage:

val app = appConfig {
    name = "MyApp"
    version = "1.0"

    server {
        host = "0.0.0.0"
        port = 8080
    }

    database {
        url = "jdbc:postgresql://localhost/mydb"
        username = "admin"
        maxPoolSize = 20
    }

    logging {
        level = "DEBUG"
        file = "app.log"
    }
}

Each nested block creates its own configuration object. The syntax is clean and hierarchical.

@DslMarker

When you have nested DSLs, there is a problem. The inner lambda can access members of the outer receiver:

html {
    body {
        // Can accidentally call html's head() from here!
        head { } // Oops — this calls Html.head(), not Body.head()
    }
}

@DslMarker fixes this. It restricts access to only the current receiver:

@DslMarker
annotation class HtmlDsl

@HtmlDsl
class Html { ... }

@HtmlDsl
class Body { ... }

// Now this is a compile error:
html {
    body {
        head { } // Error: 'head' can't be called in this context
    }
}

Always use @DslMarker in your DSLs to prevent accidental calls to outer receivers.

HTML Builder DSL

Here is a type-safe HTML builder. This is a classic Kotlin DSL example.

@HtmlDsl
class Html : HtmlElement("html") {
    fun head(block: Head.() -> Unit) {
        val head = Head()
        head.block()
        children.add(head)
    }

    fun body(block: Body.() -> Unit) {
        val body = Body()
        body.block()
        children.add(body)
    }
}

@HtmlDsl
class Body : HtmlElement("body") {
    fun h1(text: String) { /* ... */ }
    fun p(text: String) { /* ... */ }
    fun ul(block: UList.() -> Unit) { /* ... */ }
    fun a(href: String, text: String) { /* ... */ }
}

@HtmlDsl
class UList : HtmlElement("ul") {
    fun li(text: String) { /* ... */ }
}

fun html(block: Html.() -> Unit): Html {
    val html = Html()
    html.block()
    return html
}

Usage:

val page = html {
    head {
        title("My Page")
    }
    body {
        h1("Welcome")
        p("This is a paragraph.")
        ul {
            li("Item 1")
            li("Item 2")
            li("Item 3")
        }
        a("https://example.com", "Click here")
    }
}

println(page.render())

Output:

<html>
  <head>
    <title>My Page</title>
  </head>
  <body>
    <h1>Welcome</h1>
    <p>This is a paragraph.</p>
    <ul>
      <li>Item 1</li>
      <li>Item 2</li>
      <li>Item 3</li>
    </ul>
    <a href="https://example.com">Click here</a>
  </body>
</html>

The DSL is type-safe. You can only add li inside ul, and title inside head. The compiler catches mistakes.

Route DSL

A routing DSL similar to Ktor or Express:

@DslMarker
annotation class RouteDsl

@RouteDsl
class Router {
    val routes = mutableListOf<Route>()

    fun get(path: String, handler: () -> String) { /* ... */ }
    fun post(path: String, handler: () -> String) { /* ... */ }
    fun put(path: String, handler: () -> String) { /* ... */ }
    fun delete(path: String, handler: () -> String) { /* ... */ }

    fun group(prefix: String, block: Router.() -> Unit) {
        val subRouter = Router()
        subRouter.block()
        subRouter.routes.forEach { route ->
            routes.add(route.copy(path = "$prefix${route.path}"))
        }
    }
}

fun router(block: Router.() -> Unit): Router {
    val router = Router()
    router.block()
    return router
}

Usage:

val routes = router {
    get("/") { "Home page" }
    get("/about") { "About page" }

    group("/api") {
        get("/users") { "List users" }
        post("/users") { "Create user" }
        delete("/users") { "Delete user" }
    }
}

The group function adds a prefix to all routes inside it. This is how frameworks like Ktor organize their routing.

Query Builder DSL

A SQL-like query builder:

@DslMarker
annotation class QueryDsl

@QueryDsl
class QueryBuilder {
    private var table: String = ""
    private val columns = mutableListOf<String>()
    private val conditions = mutableListOf<String>()
    private var orderBy: String? = null
    private var limit: Int? = null

    fun from(table: String) { this.table = table }
    fun select(vararg cols: String) { columns.addAll(cols) }
    fun where(condition: String) { conditions.add(condition) }
    fun orderBy(column: String, direction: String = "ASC") { /* ... */ }
    fun limit(count: Int) { limit = count }

    fun build(): String { /* ... */ }
}

fun query(block: QueryBuilder.() -> Unit): String {
    val builder = QueryBuilder()
    builder.block()
    return builder.build()
}

Usage:

val sql = query {
    select("name", "email", "age")
    from("users")
    where("age > 18")
    where("active = true")
    orderBy("name")
    limit(10)
}
println(sql)
// SELECT name, email, age FROM users WHERE age > 18 AND active = true ORDER BY name ASC LIMIT 10

Multiple where calls are joined with AND. If you skip select, it defaults to *.

Gradle-Style DSL

A dependency management DSL similar to Gradle:

@DslMarker
annotation class GradleDsl

@GradleDsl
class ProjectBuilder {
    var group: String = ""
    var version: String = ""
    val plugins = mutableListOf<String>()
    var deps: List<Dependency> = emptyList()

    fun plugins(block: PluginsBuilder.() -> Unit) { /* ... */ }
    fun dependencies(block: DependenciesBuilder.() -> Unit) { /* ... */ }
}

fun project(block: ProjectBuilder.() -> Unit): ProjectBuilder {
    val builder = ProjectBuilder()
    builder.block()
    return builder
}

Usage:

val proj = project {
    group = "com.kemalcodes"
    version = "1.0.0"

    plugins {
        kotlin("jvm")
        id("application")
    }

    dependencies {
        implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
        testImplementation("io.mockk:mockk:1.13.16")
    }
}

This looks almost exactly like a real build.gradle.kts file. That is because Gradle’s Kotlin DSL uses the same techniques.

Real-World Kotlin DSLs

Here are some popular frameworks that use Kotlin DSLs:

Gradle Kotlin DSL

Your build.gradle.kts file is a Kotlin DSL:

plugins {
    kotlin("jvm") version "2.3.0"
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
}

Ktor Routing

routing {
    get("/") { call.respondText("Hello") }
    route("/api") {
        get("/users") { call.respond(users) }
    }
}

Jetpack Compose

Column {
    Text("Hello")
    Button(onClick = { }) {
        Text("Click me")
    }
}

Exposed (SQL)

Users.select { Users.name eq "Alex" }
    .orderBy(Users.age, SortOrder.DESC)
    .limit(10)

All of these use the same technique: lambdas with receiver, @DslMarker, and type-safe builders.

How to Design a DSL

Here are the steps to create a good DSL:

  1. Define the domain model. What objects and properties does your domain have?
  2. Create builder classes. One class for each scope in the DSL.
  3. Add a top-level function. This is the entry point (html, router, query).
  4. Use @DslMarker. Prevent scope leaking between nested builders.
  5. Provide defaults. Make the DSL usable without setting every property.
  6. Keep it readable. The DSL should read like natural language.

DSL Pattern

// 1. Define the domain model
class MyConfig {
    var property: String = "default"
}

// 2. Create a top-level builder function
fun myDsl(block: MyConfig.() -> Unit): MyConfig {
    return MyConfig().apply(block)
}

// 3. Use it
val config = myDsl {
    property = "custom value"
}

Common Mistakes

Mistake 1: Not Using @DslMarker

// BAD — outer receiver is accessible
html {
    body {
        head { } // Compiles but wrong — calls Html.head()
    }
}

// GOOD — use @DslMarker to restrict scope
@DslMarker
annotation class HtmlDsl

Mistake 2: Making DSLs Too Complex

// BAD — too many nested levels
app {
    module {
        feature {
            component {
                service {
                    // Hard to read
                }
            }
        }
    }
}

// GOOD — keep nesting shallow
app {
    server { host = "0.0.0.0" }
    database { url = "..." }
}

Mistake 3: Not Providing Defaults

// BAD — must set everything
val server = serverConfig {
    host = "..."
    port = ...
    ssl = ...
    maxConnections = ...
    timeout = ...
    // Must set all 10 properties
}

// GOOD — sensible defaults, override what you need
val server = serverConfig {
    host = "api.example.com"
    ssl = true
}

Summary

ConceptDescription
Lambda with receiverReceiverType.() -> Unit — access members directly
@DslMarkerPrevents scope leaking in nested DSLs
Builder patternCreate object, configure with lambda, return
applyStandard function for building objects
Nested buildersFunctions that create child scopes
Type-safe buildersCompiler enforces valid structure

Source Code

You can find the source code for this tutorial on GitHub: tutorial-21-dsl

What’s Next?

In the next tutorial, you will learn about Kotlin Serialization and working with JSON.