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: