You combine scope functions with Kotlin DSLs by using each scope function for a clear role:
apply {}to configure DSL objectsalso {}to log, validate, or attach side effectsrun {}to produce a final valuelet {}to transform intermediate valueswith {}to operate on an existing DSL context
The most important DSL feature is the lambda with receiver:
Builder.() -> Unit
That lets your DSL block behave as if it is “inside” the builder object.
Basic pattern
A typical DSL builder function looks like this:
fun route(init: RouteBuilder.() -> Unit): Route {
return RouteBuilder()
.apply(init)
.build()
}
Here:
RouteBuilder()
.apply(init)
means:
Create a builder, run the DSL block against it, and keep the configured builder.
Then:
.build()
turns the builder into the final domain object.
Example: small HTTP route DSL
data class Route(
val path: String,
val method: String,
val headers: Map<String, String>,
val handlerName: String?
)
class RouteBuilder {
var path: String = "/"
var method: String = "GET"
private val headers = mutableMapOf<String, String>()
private var handlerName: String? = null
fun header(name: String, value: String) {
headers[name] = value
}
fun handler(name: String) {
handlerName = name
}
fun build(): Route {
return Route(
path = path,
method = method,
headers = headers.toMap(),
handlerName = handlerName
)
}
}
fun route(init: RouteBuilder.() -> Unit): Route {
return RouteBuilder()
.apply(init)
.also {
require(it.path.startsWith("/")) {
"Route path must start with /"
}
}
.build()
}
Usage:
val usersRoute = route {
path = "/users"
method = "GET"
header("Accept", "application/json")
handler("listUsers")
}
This reads like a small language:
route {
path = "/users"
method = "GET"
header("Accept", "application/json")
handler("listUsers")
}
Where scope functions fit
Use apply to configure builders
This is the most common pairing in Kotlin DSLs.
fun route(init: RouteBuilder.() -> Unit): Route {
return RouteBuilder()
.apply(init)
.build()
}
Because apply:
- uses
thisas the receiver - returns the same object
That matches DSL setup perfectly.
Use also for validation or logging
Use also when you want to inspect the builder without changing the chain result.
fun route(init: RouteBuilder.() -> Unit): Route {
return RouteBuilder()
.apply(init)
.also {
require(it.path.isNotBlank()) {
"Route path cannot be blank"
}
}
.build()
}
also keeps the configured RouteBuilder flowing into build().
Use run to compute the final result
You can use run when the final step is more than a direct build() call.
fun route(init: RouteBuilder.() -> Unit): Route {
return RouteBuilder()
.apply(init)
.run {
require(path.startsWith("/")) {
"Route path must start with /"
}
build()
}
}
Inside run, the builder is available as this, and the return value is the result of the block.
So this returns a Route, not a RouteBuilder.
Nested DSLs
Scope functions become especially useful when your DSL creates nested structures.
data class Page(
val title: String,
val sections: List<Section>
)
data class Section(
val heading: String,
val paragraphs: List<String>
)
class PageBuilder {
var title: String = ""
private val sections = mutableListOf<Section>()
fun section(init: SectionBuilder.() -> Unit) {
sections += SectionBuilder()
.apply(init)
.build()
}
fun build(): Page {
return Page(
title = title,
sections = sections.toList()
)
}
}
class SectionBuilder {
var heading: String = ""
private val paragraphs = mutableListOf<String>()
fun paragraph(text: String) {
paragraphs += text
}
fun build(): Section {
return Section(
heading = heading,
paragraphs = paragraphs.toList()
)
}
}
fun page(init: PageBuilder.() -> Unit): Page {
return PageBuilder()
.apply(init)
.build()
}
Usage:
val page = page {
title = "Kotlin DSLs"
section {
heading = "Introduction"
paragraph("Kotlin DSLs are built with lambdas with receivers.")
paragraph("Scope functions help keep builder code concise.")
}
section {
heading = "Best Practices"
paragraph("Use apply for configuration.")
paragraph("Use run when returning a final computed value.")
}
}
The key part is this:
sections += SectionBuilder()
.apply(init)
.build()
That pattern is the backbone of many Kotlin DSLs.
More expressive builder helpers
You can combine DSL functions with scope functions to keep code readable.
class FormBuilder {
private val fields = mutableListOf<Field>()
fun textField(name: String, init: TextFieldBuilder.() -> Unit = {}) {
fields += TextFieldBuilder(name)
.apply(init)
.build()
}
fun build(): Form = Form(fields.toList())
}
data class Form(val fields: List<Field>)
data class Field(
val name: String,
val label: String,
val required: Boolean
)
class TextFieldBuilder(
private val name: String
) {
var label: String = name
var required: Boolean = false
fun build(): Field {
return Field(
name = name,
label = label,
required = required
)
}
}
fun form(init: FormBuilder.() -> Unit): Form {
return FormBuilder()
.apply(init)
.build()
}
Usage:
val signupForm = form {
textField("email") {
label = "Email address"
required = true
}
textField("username") {
label = "Username"
}
}
Adding validation with also
fun form(init: FormBuilder.() -> Unit): Form {
return FormBuilder()
.apply(init)
.build()
.also {
require(it.fields.isNotEmpty()) {
"Form must contain at least one field"
}
}
}
This works, but note the validation happens after build() and validates the final Form.
If you want to validate the builder before building:
fun form(init: FormBuilder.() -> Unit): Form {
return FormBuilder()
.apply(init)
.also {
// validate builder state here
}
.build()
}
Transforming DSL output with let
Use let when you want to build something and then convert it.
val fieldNames = form {
textField("email") {
required = true
}
textField("username")
}.let { builtForm ->
builtForm.fields.map { it.name }
}
Here, the DSL produces a Form, and let transforms it into a List<String>.
Using run for rendering
A common pattern is:
- Build a DSL object
- Render it to a final string
val html = page {
title = "Kotlin"
section {
heading = "DSLs"
paragraph("DSLs can make configuration expressive.")
}
}.run {
buildString {
appendLine("# $title")
sections.forEach { section ->
appendLine()
appendLine("## ${section.heading}")
section.paragraphs.forEach { paragraph ->
appendLine(paragraph)
}
}
}
}
Here:
page { ... }
returns a Page.
Then:
.run { ... }
uses that Page to compute a rendered String.
Practical guideline
For DSL internals, the most common pattern is:
fun dsl(init: Builder.() -> Unit): Result {
return Builder()
.apply(init)
.also {
// optional validation, logging, debugging
}
.build()
}
For nested DSL elements:
fun child(init: ChildBuilder.() -> Unit) {
children += ChildBuilder()
.apply(init)
.build()
}
For transforming the finished DSL result:
val output = dsl {
// configuration
}.run {
// render or compute final value
}
Avoid excessive nesting
This is expressive:
val config = server {
port = 8080
route {
path = "/users"
method = "GET"
}
}
This is harder to follow:
val config = ServerBuilder().apply {
RouteBuilder().apply {
path = "/users"
}.also {
println(it)
}.run {
build()
}.also {
addRoute(it)
}
}.run {
build()
}
Prefer creating named DSL functions like route {} instead of exposing too many raw scope-function chains to DSL users.
Rule of thumb
Inside DSL builders:
apply = configure a builder
also = validate, log, debug
run = compute or build a final result
let = transform a DSL result
with = group operations on an existing context
A clean Kotlin DSL usually hides the scope functions inside the implementation, while exposing a readable API to callers:
val app = application {
name = "Demo"
server {
port = 8080
route {
method = "GET"
path = "/health"
}
}
}
Internally, that elegant syntax is often powered by simple patterns like:
Builder()
.apply(init)
.build()
