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
@DslMarkerannotation- 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:
- Define the domain model. What objects and properties does your domain have?
- Create builder classes. One class for each scope in the DSL.
- Add a top-level function. This is the entry point (
html,router,query). - Use
@DslMarker. Prevent scope leaking between nested builders. - Provide defaults. Make the DSL usable without setting every property.
- 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
| Concept | Description |
|---|---|
| Lambda with receiver | ReceiverType.() -> Unit — access members directly |
@DslMarker | Prevents scope leaking in nested DSLs |
| Builder pattern | Create object, configure with lambda, return |
apply | Standard function for building objects |
| Nested builders | Functions that create child scopes |
| Type-safe builders | Compiler 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.