Kotlin DSL Building Explained
DSL is Kotlin's most expressive feature — it reads like a configuration file but is actually type-safe Kotlin code. Charlie uses it to build an order configuration DSL, allowing non-technical users to define order rules.
1. What You'll Learn
- DSL design elements: lambda receivers
Type.() -> Unit - Type-safe builders:
apply/run/with @DslMarker: preventing scope leakage- Gradle Kotlin DSL as a real-world example
- Charlie in action:
OrderBuilder+OrderItemBuildernested DSL
2. A Real Architect's Story
(1) Pain Point: Unsafe Configuration Formats
Charlie's order rules used YAML configuration, but YAML has no type checking — typos and missing fields only surfaced at runtime. This caused 2-3 production incidents per month from configuration errors.
(2) Kotlin DSL's Solution
KOTLIN
// YAML: no type safety, errors at runtime
order:
customer: Alice
items:
- sku: ABC
qty: three // TYPO! Should be 3
// Kotlin DSL: type-safe, errors at compile time
order {
customer = "Alice"
items {
item { sku = "ABC"; qty = 3 } // Compile-time type check!
}
}
Kotlin DSL = flexibility of configuration + type safety of code. The compiler catches configuration errors at compile time.
3. Lambda Receivers
(1) Lambdas with Receiver
KOTLIN
// Regular lambda: parameter
val greet: (String) -> Unit = { name -> println("Hello, $name") }
// Lambda with receiver: 'this' refers to receiver
val greet2: String.() -> Unit = { println("Hello, $this") }
// Usage
greet2("Alice") // Hello, Alice
"Alice".greet2() // Hello, Alice (extension-like call)
(2) with / apply / run
KOTLIN
val order = Order()
// with: execute block with receiver, return block result
val summary = with(order) {
id = "ORD-001"
total = 299.99
"Order $id created" // Return value
}
// apply: execute block with receiver, return receiver itself
val configured = order.apply {
id = "ORD-001" // 'this' is order
total = 299.99
} // Returns order
// run: execute block with receiver, return block result
val result = order.run {
id = "ORD-002"
"Processed $id"
}
(3) Scope Function Comparison
| Function | Reference | Return Value | Use Case |
|---|---|---|---|
apply |
this |
Receiver | Object configuration (chaining) |
run |
this |
Block result | Execute + return result |
with |
this |
Block result | Non-extension usage |
let |
it |
Block result | Null-safety transformations |
also |
it |
Receiver | Side effects (logging / validation) |
4. Type-Safe Builders
(1) Basic Builder
KOTLIN
class OrderBuilder {
var id: String = ""
var total: Double = 0.0
var status: String = "PENDING"
var customer: String = ""
private val items = mutableListOf<OrderItem>()
fun item(block: OrderItemBuilder.() -> Unit) {
items.add(OrderItemBuilder().apply(block).build())
}
fun build() = Order(id, total, status, customer, items.toList())
}
class OrderItemBuilder {
var sku: String = ""
var qty: Int = 1
var unitPrice: Double = 0.0
fun build() = OrderItem(sku, qty, unitPrice)
}
// DSL entry point
fun order(block: OrderBuilder.() -> Unit): Order {
return OrderBuilder().apply(block).build()
}
(2) Using the Builder DSL
KOTLIN
val myOrder = order {
id = "ORD-001"
total = 299.99
customer = "Alice"
item {
sku = "SKU-WIDGET"
qty = 3
unitPrice = 9.99
}
item {
sku = "SKU-GADGET"
qty = 1
unitPrice = 149.99
}
}
5. @DslMarker to Prevent Scope Leakage
(1) Problem: Implicit Receiver Ambiguity
KOTLIN
order {
id = "ORD-001"
item {
sku = "ABC"
// DANGER: 'id' here could refer to outer OrderBuilder.id!
id = "ITEM-001" // Which id? Order or Item?
}
}
(2) @DslMarker Solution
KOTLIN
@DslMarker
annotation class OrderDsl
@OrderDsl
class OrderBuilder {
var id: String = ""
fun item(block: OrderItemBuilder.() -> Unit) { ... }
}
@OrderDsl
class OrderItemBuilder {
var sku: String = ""
// Now: cannot access outer OrderBuilder.id from here!
}
order {
id = "ORD-001" // OK: OrderBuilder.id
item {
sku = "ABC" // OK: OrderItemBuilder.sku
// id = "X" // ERROR: cannot access outer scope!
}
}
(3) DSL Build Flow
flowchart TD
A[order block] --> B[OrderBuilder<br/>id, total, customer]
B --> C[item block]
C --> D[OrderItemBuilder<br/>sku, qty, price]
D --> E[build OrderItem]
E --> F[add to items list]
B --> G[build Order]
G --> H[Return Order object]
6. DSL Design Pattern Comparison
| Pattern | Implementation | Readability | Type Safety | Use Case |
|---|---|---|---|---|
| Builder + Lambda Receiver | fun order(block: Builder.() -> Unit) |
★★★★★ | ✅ Compile-time | Complex nested configuration |
| Infix Functions | infix fun A.to(b: B) |
★★★★ | ✅ Compile-time | Simple chaining |
| Operator Overloading | operator fun plus() |
★★★ | ✅ Compile-time | Math / collection ops |
| Annotation Processing | @Annotation class X |
★★★ | ✅ Generation-time | Reducing boilerplate |
| Dynamic Proxy | dynamic style |
★★ | ❌ Runtime | Flexible config / scripts |
7. Gradle Kotlin DSL Example
One of Kotlin DSL's most successful applications — Gradle build scripts:
KOTLIN
// build.gradle.kts - this IS a Kotlin DSL!
plugins {
kotlin("jvm") version "1.9.22"
application
}
dependencies {
implementation(kotlin("stdlib"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
testImplementation(kotlin("test"))
}
kotlin {
jvmToolchain(17)
}
application {
mainClass.set("com.order.MainKt")
}
7. Complete Example: OrderProcessor Order Configuration DSL
KOTLIN
// ============================================
// OrderProcessor - Order Configuration DSL
// Feature: Type-safe order builder DSL
// ============================================
@DslMarker
annotation class OrderDsl
data class OrderItem(val sku: String, val qty: Int, val unitPrice: Double) {
val subtotal: Double get() = qty * unitPrice
}
data class Address(val street: String, val city: String, val country: String)
data class Order(
val id: String,
val customer: String,
val items: List<OrderItem>,
val shippingAddress: Address?,
val priority: String,
val notes: String?
) {
val total: Double get() = items.sumOf { it.subtotal }
}
@OrderDsl
class OrderBuilder {
var id: String = ""
var customer: String = ""
var priority: String = "STANDARD"
var notes: String? = null
private val items = mutableListOf<OrderItem>()
private var shippingAddress: Address? = null
fun item(block: OrderItemBuilder.() -> Unit) {
items.add(OrderItemBuilder().apply(block).build())
}
fun items(block: ItemsBuilder.() -> Unit) {
items.addAll(ItemsBuilder().apply(block).build())
}
fun shipTo(block: AddressBuilder.() -> Unit) {
shippingAddress = AddressBuilder().apply(block).build()
}
fun build(): Order {
require(id.isNotBlank()) { "Order ID is required" }
require(customer.isNotBlank()) { "Customer is required" }
return Order(id, customer, items.toList(), shippingAddress, priority, notes)
}
}
@OrderDsl
class OrderItemBuilder {
var sku: String = ""
var qty: Int = 1
var unitPrice: Double = 0.0
fun build(): OrderItem {
require(sku.isNotBlank()) { "SKU is required" }
return OrderItem(sku, qty, unitPrice)
}
}
@OrderDsl
class ItemsBuilder {
private val items = mutableListOf<OrderItem>()
fun item(block: OrderItemBuilder.() -> Unit) {
items.add(OrderItemBuilder().apply(block).build())
}
fun build() = items.toList()
}
@OrderDsl
class AddressBuilder {
var street: String = ""
var city: String = ""
var country: String = ""
fun build() = Address(street, city, country)
}
// DSL entry point
fun order(block: OrderBuilder.() -> Unit): Order =
OrderBuilder().apply(block).build()
fun main() {
// Type-safe DSL: reads like configuration, verified by compiler
val myOrder = order {
id = "ORD-001"
customer = "Alice"
priority = "HIGH"
items {
item { sku = "SKU-WIDGET"; qty = 3; unitPrice = 9.99 }
item { sku = "SKU-GADGET"; qty = 1; unitPrice = 149.99 }
item { sku = "SKU-DOOHICKEY"; qty = 5; unitPrice = 4.50 }
}
shipTo {
street = "123 Main St"
city = "New York"
country = "US"
}
notes = "Rush delivery requested"
}
println("=== Order Summary ===")
println("ID: ${myOrder.id}")
println("Customer: ${myOrder.customer}")
println("Priority: ${myOrder.priority}")
println("\nItems:")
myOrder.items.forEach { item ->
println(" ${item.sku} x${item.qty} @ \$${item.unitPrice} = \$${item.subtotal} USD")
}
println("\nTotal: \$${myOrder.total} USD")
println("Ship to: ${myOrder.shippingAddress}")
println("Notes: ${myOrder.notes}")
// Validation: compile-time type safety
// sku = 123 // ERROR: String expected, not Int
// qty = "three" // ERROR: Int expected, not String
}
Output:
TEXT
=== Order Summary ===
ID: ORD-001
Customer: Alice
Priority: HIGH
Items:
SKU-WIDGET x3 @ $9.99 = $29.97 USD
SKU-GADGET x1 @ $149.99 = $149.99 USD
SKU-DOOHICKEY x5 @ $4.5 = $22.5 USD
Total: $202.46 USD
Ship to: Address(street=123 Main St, city=New York, country=US)
Notes: Rush delivery requested
❓ FAQ
Q What's the difference between a DSL and a regular API?
A A DSL aims to "read like natural language or configuration", achieved through lambda receivers + extension functions + infix calls. Regular APIs prioritize functionality over readability.
Q Is @DslMarker mandatory?
A Not mandatory, but strongly recommended. Without it, nested DSL scopes can accidentally access members from outer scopes, leading to bugs. @DslMarker prevents this at compile time.
Q What's the difference between lambda receivers and lambda parameters?
A Receivers are accessed via
this (implicit); parameters via it (explicit). Receivers suit DSLs (implicit this feels more natural); parameters suit callbacks (explicit it is clearer).Q What's the performance of DSLs?
A DSLs create Builder objects, which incur slight allocation overhead. But in configuration scenarios (non-hot-paths), this is negligible. Avoid using DSLs in inner loops with millions of calls per second.
Q What's the difference between Kotlin DSL and Groovy DSL?
A Kotlin DSL has compile-time type checking, IDE autocompletion, and refactoring support. Groovy DSL is dynamically flexible but lacks compile-time checks. Gradle Kotlin DSL is gradually replacing Groovy DSL.
Q How do I add conditional logic to a DSL?
A The DSL is itself Kotlin code — you can use
if/when/for and other control flow inside blocks. This is a major advantage over configuration files — configuration + logic in one.📖 Summary
- Lambda receivers
Type.() -> Unitare the heart of DSLs — usethisto implicitly access the receiver inside the block applyreturns the receiver (chainable configuration);run/with/letreturn the block result- Builder pattern + lambda receivers = type-safe DSL
@DslMarkerprevents nested DSL scope leakage, enforced at compile time- DSL = flexibility of configuration + type safety of code
- Gradle Kotlin DSL is DSL's most successful industrial application
📝 Exercises
- Beginner (⭐): Use
applyto configure anOrderobject, setting id, total, and status. Hint:order.apply { id = "ORD-001"; total = 299.99 } - Intermediate (⭐⭐): Build an
EmailBuilderDSL:email { from("a@b.com"); to("c@d.com"); subject("Hi") }. Hint:fun email(block: EmailBuilder.() -> Unit)+ lambda receiver - Challenge (⭐⭐⭐): Use
@DslMarkerto implement a nested DSL:order { customer { name("Alice") }; item { sku("ABC") } }, ensuring inner scopes cannot access outer members. Hint:@DslMarker+ multi-layer Builder



