A Domain-Specific Language (DSL) is a mini-language designed for a specific purpose. In Kotlin, DSLs look like custom syntax — but they’re actually just regular Kotlin code using lambdas with receivers, extension functions, and infix notation. You’ve been using Kotlin DSLs every day without realising it — Gradle build scripts, Jetpack Compose UI, Ktor routing, and even buildList { } are all DSLs. This guide explains how they work, how to build your own, and why they make APIs feel natural and expressive.
DSLs You Already Use
// Gradle build script — a DSL
dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib")
testImplementation("junit:junit:4.13.2")
}
// Jetpack Compose — a DSL for UI
Column(modifier = Modifier.padding(16.dp)) {
Text("Hello")
Button(onClick = { }) { Text("Click me") }
}
// Ktor routing — a DSL for HTTP servers
routing {
get("/articles") {
call.respond(articleService.getAll())
}
post("/articles") {
val article = call.receive<Article>()
articleService.create(article)
}
}
// buildList — a simple standard library DSL
val items = buildList {
add("first")
add("second")
if (includeThird) add("third")
}
// All of these look like custom syntax, but they're regular Kotlin
// using lambdas with receivers
Lambda with Receiver — The Core Concept
A lambda with receiver is a lambda that runs in the context of a specific object. Inside the lambda, you can call that object’s methods and properties directly — as if you were inside the class:
// Normal lambda
val greet: (String) -> String = { name -> "Hello, $name" }
// Lambda WITH RECEIVER — StringBuilder is the receiver
val buildGreeting: StringBuilder.() -> Unit = {
append("Hello, ") // calling StringBuilder.append directly
append("World!") // no need for "this.append" or any reference
}
// Usage
val result = StringBuilder().apply(buildGreeting)
println(result) // "Hello, World!"
// The type signature: ReceiverType.() -> ReturnType
// Inside the lambda, "this" refers to the receiver (StringBuilder)
How this becomes a DSL
// A function that takes a lambda with receiver
fun buildString(block: StringBuilder.() -> Unit): String {
val sb = StringBuilder()
sb.block() // invoke the lambda on sb
return sb.toString()
}
// Usage — looks like a mini-language for building strings
val greeting = buildString {
append("Hello, ")
append("Kotlin ")
append("DSL!")
}
// "Hello, Kotlin DSL!"
// The magic: inside the { } block, you have direct access
// to all StringBuilder methods without any prefix
Building a Simple DSL — Step by Step
Let’s build an HTML DSL that generates HTML strings:
Step 1: Define the builder classes
class HTML {
private val children = mutableListOf<String>()
fun head(block: Head.() -> Unit) {
val head = Head()
head.block()
children.add(head.render())
}
fun body(block: Body.() -> Unit) {
val body = Body()
body.block()
children.add(body.render())
}
fun render(): String = "<html>\n${children.joinToString("\n")}\n</html>"
}
class Head {
private var titleText = ""
fun title(text: String) {
titleText = text
}
fun render(): String = " <head><title>$titleText</title></head>"
}
class Body {
private val children = mutableListOf<String>()
fun h1(text: String) {
children.add(" <h1>$text</h1>")
}
fun p(text: String) {
children.add(" <p>$text</p>")
}
fun render(): String = " <body>\n${children.joinToString("\n")}\n </body>"
}
Step 2: Create the entry point function
fun html(block: HTML.() -> Unit): String {
val html = HTML()
html.block()
return html.render()
}
Step 3: Use the DSL
val page = html {
head {
title("My Page")
}
body {
h1("Welcome")
p("This is a Kotlin DSL example.")
p("It generates HTML!")
}
}
println(page)
// <html>
// <head><title>My Page</title></head>
// <body>
// <h1>Welcome</h1>
// <p>This is a Kotlin DSL example.</p>
// <p>It generates HTML!</p>
// </body>
// </html>
Key Language Features for DSLs
Extension functions
// Add methods to existing types for a fluent API
fun String.bold(): String = "<b>$this</b>"
fun String.italic(): String = "<i>$this</i>"
val styled = "Hello".bold().italic() // "<i><b>Hello</b></i>"
Infix functions
// Remove the dot and parentheses for natural reading
infix fun Int.days(unit: String): Duration {
return Duration.ofDays(this.toLong())
}
// Makes code read like English
val timeout = 5 days "from now" // reads naturally
// Real example: assertion DSL
infix fun <T> T.shouldBe(expected: T) {
if (this != expected) throw AssertionError("Expected $expected but got $this")
}
"hello" shouldBe "hello" // ✅ passes
42 shouldBe 42 // ✅ passes
Operator overloading
// Custom operators for DSL-like syntax
class CssBuilder {
private val properties = mutableMapOf<String, String>()
operator fun String.invoke(value: String) {
properties[this] = value
}
fun build(): String = properties.entries.joinToString("; ") { "${it.key}: ${it.value}" }
}
fun css(block: CssBuilder.() -> Unit): String {
val builder = CssBuilder()
builder.block()
return builder.build()
}
val style = css {
"color"("red")
"font-size"("16px")
"margin"("10px")
}
// "color: red; font-size: 16px; margin: 10px"
@DslMarker — controlling scope
Without @DslMarker, inner lambdas can access outer receivers, which leads to confusing code:
// ❌ Without @DslMarker — body block can accidentally call head methods
html {
body {
title("Oops") // ❌ this calls Head.title from the outer scope!
}
}
// ✅ With @DslMarker — compiler prevents cross-scope access
@DslMarker
annotation class HtmlDsl
@HtmlDsl
class HTML { /* ... */ }
@HtmlDsl
class Head { /* ... */ }
@HtmlDsl
class Body { /* ... */ }
html {
body {
// title("Oops") // ❌ compile error! title is not in Body's scope
this@html.head { // ✅ must be explicit to access outer scope
title("My Page")
}
}
}
Real Android Patterns
Network request builder DSL
class RequestBuilder {
var url: String = ""
var method: String = "GET"
private val headers = mutableMapOf<String, String>()
private var body: String? = null
var timeoutMs: Long = 10_000
fun headers(block: HeaderBuilder.() -> Unit) {
val builder = HeaderBuilder()
builder.block()
headers.putAll(builder.build())
}
fun body(content: String) {
body = content
}
fun build(): Request = Request(url, method, headers, body, timeoutMs)
}
class HeaderBuilder {
private val headers = mutableMapOf<String, String>()
infix fun String.to(value: String) {
headers[this] = value
}
fun build(): Map<String, String> = headers
}
fun request(block: RequestBuilder.() -> Unit): Request {
val builder = RequestBuilder()
builder.block()
return builder.build()
}
// Usage — clean, readable API
val req = request {
url = "https://api.example.com/articles"
method = "POST"
timeoutMs = 5_000
headers {
"Authorization" to "Bearer token123"
"Content-Type" to "application/json"
}
body("""{"title": "New Article"}""")
}
Test data builder DSL
class UserBuilder {
var name: String = "Test User"
var email: String = "test@example.com"
var age: Int = 25
var isActive: Boolean = true
private val roles = mutableListOf<String>()
fun roles(vararg role: String) {
roles.addAll(role)
}
fun build(): User = User(name, email, age, isActive, roles)
}
fun user(block: UserBuilder.() -> Unit = {}): User {
val builder = UserBuilder()
builder.block()
return builder.build()
}
// Usage in tests — only specify what matters
val defaultUser = user { } // all defaults
val admin = user { name = "Admin"; roles("admin") }
val inactive = user { isActive = false }
// List builder
fun users(block: MutableList<UserBuilder>.() -> Unit): List<User> {
val builders = mutableListOf<UserBuilder>()
builders.block()
return builders.map { it.build() }
}
val testUsers = buildList {
add(user { name = "Alice"; age = 30 })
add(user { name = "Bob"; age = 25 })
add(user { name = "Charlie"; isActive = false })
}
Navigation DSL
class NavGraphBuilder {
private val destinations = mutableListOf<Destination>()
fun screen(route: String, block: ScreenBuilder.() -> Unit) {
val builder = ScreenBuilder(route)
builder.block()
destinations.add(builder.build())
}
fun build(): NavGraph = NavGraph(destinations)
}
class ScreenBuilder(private val route: String) {
var title: String = ""
var requiresAuth: Boolean = false
private var content: (@Composable () -> Unit)? = null
fun content(block: @Composable () -> Unit) {
content = block
}
fun build(): Destination = Destination(route, title, requiresAuth, content)
}
fun navGraph(block: NavGraphBuilder.() -> Unit): NavGraph {
val builder = NavGraphBuilder()
builder.block()
return builder.build()
}
// Usage
val graph = navGraph {
screen("home") {
title = "Home"
content { HomeScreen() }
}
screen("articles") {
title = "Articles"
content { ArticleListScreen() }
}
screen("profile") {
title = "Profile"
requiresAuth = true
content { ProfileScreen() }
}
}
Validation DSL
class ValidationBuilder<T> {
private val rules = mutableListOf<Pair<String, (T) -> Boolean>>()
fun rule(message: String, check: (T) -> Boolean) {
rules.add(message to check)
}
fun validate(value: T): List<String> {
return rules.filter { (_, check) -> !check(value) }
.map { (message, _) -> message }
}
}
fun <T> validator(block: ValidationBuilder<T>.() -> Unit): ValidationBuilder<T> {
val builder = ValidationBuilder<T>()
builder.block()
return builder
}
// Usage — declarative validation rules
val emailValidator = validator<String> {
rule("Email is required") { it.isNotBlank() }
rule("Email must contain @") { "@" in it }
rule("Email must have a domain") { it.substringAfter("@").contains(".") }
}
val errors = emailValidator.validate("bad-email")
// ["Email must contain @", "Email must have a domain"]
val noErrors = emailValidator.validate("user@example.com")
// [] — empty, all rules pass
// Form validation
data class RegistrationForm(val name: String, val email: String, val age: Int)
val formValidator = validator<RegistrationForm> {
rule("Name is required") { it.name.isNotBlank() }
rule("Name must be at least 2 characters") { it.name.length >= 2 }
rule("Invalid email") { "@" in it.email }
rule("Must be 18 or older") { it.age >= 18 }
}
Common Mistakes to Avoid
Mistake 1: Overcomplicating with DSLs when simple functions work
// ❌ Over-engineered DSL for something simple
val user = user {
name { first("Alice"); last("Smith") }
age(25)
email("alice@example.com")
}
// ✅ A data class with named arguments is simpler and clearer
val user = User(
name = "Alice Smith",
age = 25,
email = "alice@example.com"
)
// DSLs shine when you have:
// - Nested hierarchical structures (HTML, UI, navigation)
// - Builder patterns with many optional configurations
// - Domain logic that reads like natural language
Mistake 2: Forgetting @DslMarker
// ❌ Inner lambdas leak into outer scope
html {
body {
head { } // compiles but makes no sense — head inside body?
}
}
// ✅ Use @DslMarker to restrict scope
@DslMarker
annotation class HtmlDsl
// Now head { } inside body { } is a compile error
Mistake 3: Mutable state leaking from DSL builders
// ❌ Builder is mutable after build
val builder = UserBuilder()
val user = builder.apply { name = "Alice" }.build()
builder.name = "Bob" // builder is still accessible and mutable!
// ✅ Return immutable result, discard builder
fun user(block: UserBuilder.() -> Unit): User {
return UserBuilder().apply(block).build() // builder is local
}
val user = user { name = "Alice" }
// No access to the builder after this point
Mistake 4: Making DSLs too clever
// ❌ Too much operator overloading — unreadable
val route = "/api" / "v1" / "articles" + auth + cache(300)
// ✅ Readable DSL with clear method names
val route = route("/api/v1/articles") {
requireAuth = true
cacheSeconds = 300
}
Summary
- A DSL is a mini-language for a specific domain — built with regular Kotlin features
- Lambda with receiver (
Type.() -> Unit) is the core building block — it gives the lambda access to the receiver’s members - Extension functions add methods to existing types for a fluent API
- Infix functions remove dots and parentheses for natural reading (
"key" to "value") - Operator overloading enables custom syntax but should be used sparingly
@DslMarkerprevents inner lambdas from accessing outer receivers — always use it in nested DSLs- You use Kotlin DSLs daily: Gradle build scripts, Jetpack Compose, Ktor,
buildList,apply,with - DSLs are great for hierarchical structures (HTML, UI, navigation), configuration (requests, routes), and test data builders
- Don’t overuse DSLs — if named arguments or a simple function call is clear enough, prefer that
- Return immutable results from DSL builders — don’t expose mutable builder state
- Keep DSLs readable over clever — if a teammate can’t understand it in 5 seconds, simplify it
Kotlin DSLs are one of the language’s most distinctive features. The combination of lambdas with receivers, extension functions, and infix notation lets you create APIs that feel like custom languages — but are fully type-safe, auto-completable, and compile-time checked. Once you understand the building blocks, you’ll start seeing DSL opportunities everywhere — configuration, validation, testing, routing, and UI construction.
Happy coding!
Comments (0)