Ever looked at code that reads so naturally you forget it's actually code? That's the magic of Domain-Specific Languages (DSLs).

In Kotlin, you can build APIs that look less like programming and more like writing sentences. Instead of HttpClient.newBuilder().setConnectionTimeout(5000).setReadTimeout(10000).build(), imagine writing:

httpClient {
    connectionTimeout = 5 seconds
    readTimeout = 10 seconds
}

Clean. Readable. Almost poetic.

Kotlin's DSL capabilities let you create these elegant APIs using type-safe builders. Think of it as building a custom language within Kotlin - one that's tailored perfectly to your domain, whether that's building HTML, configuring servers, or defining database schemas.

Today we're diving deep into Kotlin DSLs - what they are, how to build them, and why they'll make your APIs a joy to use.

What Is a DSL?

A Domain-Specific Language is a mini-language designed for a specific purpose, as opposed to general-purpose languages like Kotlin itself.

Examples you've probably used:

  • SQL: SELECT * FROM users WHERE age > 18
  • HTML: <div><h1>Hello</h1></div>
  • Gradle (Kotlin DSL): dependencies { implementation("some:library:1.0") }

In Kotlin, we can build these DSLs that feel natural to read and write, all while maintaining full type safety.

The Building Blocks

Kotlin DSLs rely on three key language features:

1. Lambda with Receiver

fun buildString(init: StringBuilder.() -> Unit): String {
    val builder = StringBuilder()
    builder.init()  // Call lambda with StringBuilder as receiver
    return builder.toString()
}

// Usage - `this` inside the lambda is StringBuilder
val result = buildString {
    append("Hello")  // No need for `builder.append`
    append(" ")
    append("World")
}

println(result)  // "Hello World"

The magic: Inside the lambda, this is StringBuilder, so you can call its methods directly.

2. Extension Functions

class Person {
    var name: String = ""
    var age: Int = 0
}

fun Person.configure(init: Person.() -> Unit) {
    this.init()
}

// Usage
val person = Person().apply {
    configure {
        name = "John Kamau"
        age = 30
    }
}

3. Operator Overloading (Optional)

data class Duration(val value: Long, val unit: TimeUnit)

val Int.seconds: Duration
    get() = Duration(this.toLong(), TimeUnit.SECONDS)

val Int.minutes: Duration
    get() = Duration(this.toLong(), TimeUnit.MINUTES)

// Usage
val timeout = 5.seconds  // Duration(5, SECONDS)
val interval = 10.minutes  // Duration(10, MINUTES)

Example 1: HTML Builder DSL

Let's build a type-safe HTML DSL from scratch.

Goal: Write HTML like this:

html {
    head {
        title { +"My Page" }
    }
    body {
        h1 { +"Welcome!" }
        p {
            +"This is a paragraph with "
            a(href = "https://example.com") {
                +"a link"
            }
        }
        ul {
            li { +"First item" }
            li { +"Second item" }
            li { +"Third item" }
        }
    }
}

Implementation:

// Base class for all HTML elements
abstract class Element(val name: String) {
    protected val children = mutableListOf<Element>()
    protected val attributes = mutableMapOf<String, String>()

    // Add text content (the unary plus operator)
    operator fun String.unaryPlus() {
        children.add(TextElement(this))
    }

    // Render the element as HTML string
    open fun render(): String {
        val attrs = if (attributes.isEmpty()) ""
        else attributes.entries.joinToString(" ", " ") { "${it.key}=\"${it.value}\"" }

        val childrenHtml = children.joinToString("") { it.render() }

        return "<$name$attrs>$childrenHtml</$name>"
    }
}

// Text node (not a real element, but treated as one for simplicity)
class TextElement(private val text: String) : Element("") {
    override fun render() = text
}

// Container elements that can have children
abstract class ContainerElement(name: String) : Element(name)

// Specific HTML elements
class Html : ContainerElement("html")
class Head : ContainerElement("head")
class Title : ContainerElement("title")
class Body : ContainerElement("body")
class H1 : ContainerElement("h1")
class P : ContainerElement("p")
class A : ContainerElement("a")
class Ul : ContainerElement("ul")
class Li : ContainerElement("li")

// Extension functions to add child elements
fun Html.head(init: Head.() -> Unit) {
    val head = Head()
    head.init()
    children.add(head)
}

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

fun Head.title(init: Title.() -> Unit) {
    val title = Title()
    title.init()
    children.add(title)
}

fun Body.h1(init: H1.() -> Unit) {
    val h1 = H1()
    h1.init()
    children.add(h1)
}

fun Body.p(init: P.() -> Unit) {
    val p = P()
    p.init()
    children.add(p)
}

fun P.a(href: String, init: A.() -> Unit) {
    val a = A()
    a.attributes["href"] = href
    a.init()
    children.add(a)
}

fun Body.ul(init: Ul.() -> Unit) {
    val ul = Ul()
    ul.init()
    children.add(ul)
}

fun Ul.li(init: Li.() -> Unit) {
    val li = Li()
    li.init()
    children.add(li)
}

// Top-level builder function
fun html(init: Html.() -> Unit): Html {
    val html = Html()
    html.init()
    return html
}

// Usage
fun main() {
    val page = html {
        head {
            title { +"My Awesome Website" }
        }
        body {
            h1 { +"Welcome to My Site!" }
            p {
                +"Check out "
                a(href = "https://kotlin.org") {
                    +"Kotlin"
                }
                +" for more info."
            }
            ul {
                li { +"Item 1" }
                li { +"Item 2" }
                li { +"Item 3" }
            }
        }
    }

    println(page.render())
}

Output:

<html><head><title>My Awesome Website</title></head><body><h1>Welcome to My Site!</h1><p>Check out <a href="https://kotlin.org">Kotlin</a> for more info.</p><ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul></body></html>

Example 2: Type-Safe SQL Query Builder

Building SQL queries with string concatenation is error-prone and dangerous (SQL injection!). Let's build a type-safe DSL:

Goal:

val query = select("name", "email") from "users" where ("age" gt 18)

Implementation:

// Query components
data class SelectQuery(
    val columns: List<String>,
    val tableName: String,
    val condition: Condition? = null
)

// Conditions
sealed class Condition {
    data class Gt(val column: String, val value: Any) : Condition()
    data class Lt(val column: String, val value: Any) : Condition()
    data class Eq(val column: String, val value: Any) : Condition()
    data class And(val left: Condition, val right: Condition) : Condition()
    data class Or(val left: Condition, val right: Condition) : Condition()
}

// Builder classes
class SelectBuilder(private val columns: List<String>) {
    infix fun from(table: String): FromBuilder {
        return FromBuilder(columns, table)
    }
}

class FromBuilder(
    private val columns: List<String>,
    private val table: String
) {
    infix fun where(condition: Condition): SelectQuery {
        return SelectQuery(columns, table, condition)
    }

    fun build(): SelectQuery {
        return SelectQuery(columns, table)
    }
}

// Extension functions for building conditions
infix fun String.gt(value: Any) = Condition.Gt(this, value)
infix fun String.lt(value: Any) = Condition.Lt(this, value)
infix fun String.eq(value: Any) = Condition.Eq(this, value)
infix fun Condition.and(other: Condition) = Condition.And(this, other)
infix fun Condition.or(other: Condition) = Condition.Or(this, other)

// Top-level function
fun select(vararg columns: String) = SelectBuilder(columns.toList())

// Render to SQL string
fun SelectQuery.toSql(): String {
    val cols = columns.joinToString(", ")
    val whereClause = condition?.let { " WHERE ${it.toSql()}" } ?: ""
    return "SELECT $cols FROM $tableName$whereClause"
}

fun Condition.toSql(): String = when (this) {
    is Condition.Gt -> "$column > $value"
    is Condition.Lt -> "$column < $value"
    is Condition.Eq -> "$column = $value"
    is Condition.And -> "(${left.toSql()} AND ${right.toSql()})"
    is Condition.Or -> "(${left.toSql()} OR ${right.toSql()})"
}

// Usage
fun main() {
    val query1 = select("name", "email") from "users" where ("age" gt 18)
    println(query1.toSql())
    // SELECT name, email FROM users WHERE age > 18

    val query2 = select("*") from "products" where (
            ("price" lt 100) and ("inStock" eq true)
            )
    println(query2.toSql())
    // SELECT * FROM products WHERE (price < 100 AND inStock = true)

    val query3 = select("id", "title") from "posts" where (
            ("author" eq "John") or ("featured" eq true)
            )
    println(query3.toSql())
    // SELECT id, title FROM posts WHERE (author = John OR featured = true)
}

Why this is awesome:

  • Type-safe (can't write invalid queries)
  • No SQL injection (values are properly escaped)
  • IDE autocomplete works perfectly
  • Refactoring tools can rename columns/tables

Example 3: Configuration DSL

Let's build a DSL for configuring an HTTP server:

Goal:

server {
    host = "localhost"
    port = 8080

    routes {
        get("/") { "Hello World!" }
        get("/users") { userController.getAll() }
        post("/users") { userController.create(request.body) }
    }

    middleware {
        logger()
        cors {
            allowOrigin = "*"
            allowMethods = listOf("GET", "POST", "PUT", "DELETE")
        }
    }
}

Implementation:

// Server configuration
class ServerConfig {
    var host: String = "0.0.0.0"
    var port: Int = 3000

    internal val routesList = mutableListOf<Route>()
    internal val middlewareList = mutableListOf<Middleware>()

    fun routes(init: RoutesBuilder.() -> Unit) {
        val builder = RoutesBuilder()
        builder.init()
        routesList.addAll(builder.routes)
    }

    fun middleware(init: MiddlewareBuilder.() -> Unit) {
        val builder = MiddlewareBuilder()
        builder.init()
        middlewareList.addAll(builder.middlewares)
    }

    fun start() {
        println("🚀 Server starting on $host:$port")
        println("📍 Routes:")
        routesList.forEach { println("  ${it.method} ${it.path}") }
        println("🔧 Middleware: ${middlewareList.size} configured")
    }
}

// Route definition
data class Route(
    val method: String,
    val path: String,
    val handler: () -> Any
)

class RoutesBuilder {
    internal val routes = mutableListOf<Route>()

    fun get(path: String, handler: () -> Any) {
        routes.add(Route("GET", path, handler))
    }

    fun post(path: String, handler: () -> Any) {
        routes.add(Route("POST", path, handler))
    }

    fun put(path: String, handler: () -> Any) {
        routes.add(Route("PUT", path, handler))
    }

    fun delete(path: String, handler: () -> Any) {
        routes.add(Route("DELETE", path, handler))
    }
}

// Middleware system
sealed class Middleware {
    object Logger : Middleware()
    data class Cors(var allowOrigin: String = "*", var allowMethods: List<String> = emptyList()) : Middleware()
}

class MiddlewareBuilder {
    internal val middlewares = mutableListOf<Middleware>()

    fun logger() {
        middlewares.add(Middleware.Logger)
    }

    fun cors(init: Middleware.Cors.() -> Unit) {
        val cors = Middleware.Cors()
        cors.init()
        middlewares.add(cors)
    }
}

// Top-level builder
fun server(init: ServerConfig.() -> Unit): ServerConfig {
    val config = ServerConfig()
    config.init()
    return config
}

// Usage
fun main() {
    val app = server {
        host = "localhost"
        port = 8080

        routes {
            get("/") { "Welcome to the API" }
            get("/health") { mapOf("status" to "ok") }
            post("/users") { "User created" }
        }

        middleware {
            logger()
            cors {
                allowOrigin = "https://myapp.com"
                allowMethods = listOf("GET", "POST", "PUT", "DELETE")
            }
        }
    }

    app.start()
}

Output:

🚀 Server starting on localhost:8080
📍 Routes:
  GET /
  GET /health
  POST /users
🔧 Middleware: 2 configured

Real-World Use Cases

1. Jetpack Compose (Android UI)

@Composable
fun UserProfile(user: User) {
    Column {
        Text(
            text = user.name,
            style = MaterialTheme.typography.h4
        )
        Spacer(modifier = Modifier.height(8.dp))
        Text(
            text = user.email,
            style = MaterialTheme.typography.body1
        )
    }
}

2. Ktor (Web Framework)

fun Application.module() {
    routing {
        get("/") {
            call.respondText("Hello, World!")
        }
        post("/api/users") {
            val user = call.receive<User>()
            // ...
            call.respond(HttpStatusCode.Created, user)
        }
    }
}

3. Gradle Kotlin DSL

dependencies {
    implementation("org.jetbrains.kotlin:kotlin-stdlib:1.9.0")
    testImplementation("junit:junit:4.13.2")
}

Best Practices

1. Enforce scope with @DslMarker:

@DslMarker
annotation class HtmlTagMarker

@HtmlTagMarker
abstract class Element(val name: String)

// This prevents invalid nesting like:
html {
    body {
        this@html.body {  // ❌ Compiler error - can't nest body in body
            // ...
        }
    }
}

2. Provide sensible defaults:

class DatabaseConfig {
    var host: String = "localhost"
    var port: Int = 5432
    var maxConnections: Int = 10
}

// Users can override only what they need
database {
    host = "prod-db.example.com"
    // port and maxConnections use defaults
}

3. Validate configuration:

class ServerConfig {
    var port: Int = 3000
        set(value) {
            require(value in 1..65535) { "Port must be between 1 and 65535" }
            field = value
        }
}

4. Use inline functions for zero runtime overhead:

inline fun <T> buildList(init: MutableList<T>.() -> Unit): List<T> {
    val list = mutableListOf<T>()
    list.init()
    return list
}

// Gets compiled to direct calls - no lambda object created

When NOT to Use DSLs

DSLs aren't always the answer:

  • Simple configuration - Plain data classes might be enough
  • One-time use - Not worth the complexity
  • Team unfamiliarity - Steep learning curve for maintainers
  • Over-abstraction - When the DSL obscures what's actually happening

Use DSLs when:

  • Building complex hierarchical structures (UI, HTML, config files)
  • API will be used frequently
  • Type safety and autocompletion add significant value
  • Domain logic is complex and benefits from a specialized syntax

The Bottom Line

Kotlin DSLs turn code into prose. They make complex configurations readable, enforce type safety, and provide amazing IDE support.

Use DSL builders in Spring Boot + Kotlin APIs for expressive configuration, combine with Kotlin coroutines for async DSL blocks, and apply mobile app state management principles when designing state-heavy DSLs.

You've seen how to build:

  • HTML generators
  • SQL query builders
  • Server configuration DSLs

These same patterns apply to building test fixtures, defining workflows, creating chart configurations, or any domain where a specialized syntax adds value.

The key ingredients:

  • Lambda with receiver for scope
  • Extension functions for adding behaviors
  • Operator overloading for natural syntax
  • @DslMarker for scope control

Start small. Build a simple DSL for your next configuration file or test builder. You'll quickly see the benefits of code that reads like English but compiles like Kotlin.

Sources: