Kotlin DSL Builder Pattern — Creating Type-Safe Builders

What Is a DSL?

A DSL (Domain-Specific Language) is a small language designed to solve problems in a specific domain. Kotlin allows you to naturally create type-safe DSLs through Lambda with Receiver. Gradle build scripts, Ktor routing, and Jetpack Compose are prime examples of Kotlin DSLs.

This article walks through the mechanics of lambda with receiver up to practical DSL implementation, step by step.

Lambda with Receiver

A lambda with receiver allows you to directly call methods and properties of a specific object inside the lambda without using this. The standard library functions apply, with, and buildString all use this pattern.

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

// HTML tag builder — Using lambda with receiver
class TagBuilder(private val name: String) {
    private val children = mutableListOf<String>()
    private val attributes = mutableMapOf<String, String>()

    // Set attribute
    fun attr(key: String, value: String) {
        attributes[key] = value
    }

    // Add text
    fun text(content: String) {
        children.add(content)
    }

    // Add child tag
    fun tag(name: String, block: TagBuilder.() -> Unit) {
        val child = TagBuilder(name)
        child.block()  // Invoke lambda with receiver
        children.add(child.build())
    }

    fun build(): String {
        val attrStr = if (attributes.isEmpty()) ""
            else " " + attributes.entries.joinToString(" ") { "${it.key}=\"${it.value}\"" }
        val content = children.joinToString("\n")
        return "<$name$attrStr>\n$content\n</$name>"
    }
}

fun html(block: TagBuilder.() -> Unit): String {
    val builder = TagBuilder("html")
    builder.block()
    return builder.build()
}

fun main() {
    // Using buildGreeting
    val greeting = buildGreeting {
        append("Hello, ")  // Call StringBuilder's method directly
        append("Kotlin ")
        append("DSL!")
    }
    println(greeting)
    // Output: Hello, Kotlin DSL!

    // Using the HTML DSL
    val page = html {
        tag("head") {
            tag("title") {
                text("Kotlin DSL Example")
            }
        }
        tag("body") {
            tag("h1") {
                text("Welcome")
            }
            tag("p") {
                attr("class", "content")
                text("This HTML was built with Kotlin DSL.")
            }
        }
    }
    println(page)
    // Output:
    // <html>
    // <head>
    // <title>
    // Kotlin DSL Example
    // </title>
    // </head>
    // <body>
    // <h1>
    // Welcome
    // </h1>
    // <p class="content">
    // This HTML was built with Kotlin DSL.
    // </p>
    // </body>
    // </html>
}

Restricting Scope with @DslMarker

In nested DSLs, accessing methods from an outer scope can cause unintended behavior. The @DslMarker annotation restricts scope to prevent this issue.

// DslMarker definition
@DslMarker
annotation class ConfigDsl

// Configuration DSL implementation
@ConfigDsl
class ServerConfig {
    var host: String = "localhost"
    var port: Int = 8080
    private var _database: DatabaseConfig? = null
    private var _cache: CacheConfig? = null

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

    fun cache(block: CacheConfig.() -> Unit) {
        _cache = CacheConfig().apply(block)
    }

    override fun toString(): String {
        return """
            |Server Config:
            |  Host: $host:$port
            |  DB: $_database
            |  Cache: $_cache
        """.trimMargin()
    }
}

@ConfigDsl
class DatabaseConfig {
    var url: String = ""
    var username: String = ""
    var password: String = ""
    var poolSize: Int = 10

    override fun toString() = "$url (pool: $poolSize)"
}

@ConfigDsl
class CacheConfig {
    var type: String = "redis"
    var host: String = "localhost"
    var ttlSeconds: Int = 3600

    override fun toString() = "$type://$host (TTL: ${ttlSeconds}s)"
}

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

fun main() {
    val config = server {
        host = "api.example.com"
        port = 443

        database {
            url = "jdbc:postgresql://db.example.com:5432/myapp"
            username = "app_user"
            password = "secret"
            poolSize = 20
            // host = "..."  // Thanks to @DslMarker, ServerConfig.host is not accessible here
        }

        cache {
            type = "redis"
            host = "cache.example.com"
            ttlSeconds = 7200
        }
    }

    println(config)
    // Output:
    // Server Config:
    //   Host: api.example.com:443
    //   DB: jdbc:postgresql://db.example.com:5432/myapp (pool: 20)
    //   Cache: redis://cache.example.com (TTL: 7200s)
}

Without @DslMarker, you could access ServerConfig.host inside the database { } block, causing confusion. With @DslMarker, only the current scope’s receiver methods are directly accessible.

Practical DSL — Validation Rule Builder

Here is a useful input validation DSL for real-world use.

@DslMarker
annotation class ValidationDsl

@ValidationDsl
class ValidationBuilder<T> {
    private val rules = mutableListOf<Pair<String, (T) -> Boolean>>()

    // Add a rule
    fun rule(description: String, predicate: (T) -> Boolean) {
        rules.add(description to predicate)
    }

    // Run validation
    fun validate(value: T): ValidationResult {
        val failures = rules
            .filter { (_, predicate) -> !predicate(value) }
            .map { (desc, _) -> desc }
        return if (failures.isEmpty()) {
            ValidationResult.Valid
        } else {
            ValidationResult.Invalid(failures)
        }
    }
}

sealed class ValidationResult {
    data object Valid : ValidationResult()
    data class Invalid(val errors: List<String>) : ValidationResult()
}

fun <T> validate(value: T, block: ValidationBuilder<T>.() -> Unit): ValidationResult {
    val builder = ValidationBuilder<T>()
    builder.block()
    return builder.validate(value)
}

// Usage example
data class SignUpForm(
    val email: String,
    val password: String,
    val age: Int
)

fun main() {
    val form = SignUpForm(
        email = "user@example.com",
        password = "abc",
        age = 15
    )

    val result = validate(form) {
        rule("Email must contain @") { it.email.contains("@") }
        rule("Password must be at least 8 characters") { it.password.length >= 8 }
        rule("Must be at least 18 years old") { it.age >= 18 }
    }

    when (result) {
        is ValidationResult.Valid -> println("Validation passed!")
        is ValidationResult.Invalid -> {
            println("Validation failed:")
            result.errors.forEach { println("  - $it") }
        }
    }
    // Output:
    // Validation failed:
    //   - Password must be at least 8 characters
    //   - Must be at least 18 years old

    // Validating a valid form
    val validForm = form.copy(password = "securePassword123", age = 25)
    val validResult = validate(validForm) {
        rule("Email must contain @") { it.email.contains("@") }
        rule("Password must be at least 8 characters") { it.password.length >= 8 }
        rule("Must be at least 18 years old") { it.age >= 18 }
    }
    println(if (validResult is ValidationResult.Valid) "Valid form!" else "Invalid")
    // Output: Valid form!
}

Summary

  • Lambda with receiver: The core DSL syntax that allows direct access to receiver object members inside a lambda.
  • @DslMarker: Restricts outer scope access in nested DSLs to prevent mistakes.
  • apply/with/run: Standard library scope functions also use lambda with receiver.
  • Replaces the Builder pattern: Replacing Java’s Builder pattern with DSL greatly improves readability.
  • Type safety: Validity is verified at compile time, making it safer than string-based configuration.

Was this article helpful?