Lecture notes — DSL Construction
ver. 1.0.0
Where we are
This is the last unit of the module, and it is deliberately placed there: almost every mechanism it uses has already been covered.
Extension functions and infix calls from unit 3, lambdas from unit 5, operator conventions from unit 7, higher-order functions from unit 8 — and one substantial new idea, lambdas with receivers, previewed in unit 5. Put together, they let an API read like a language designed for the problem at hand.
The contrast with the previous unit is the point. Reflection achieved flexibility at runtime, by giving up static checking. DSLs achieve expressiveness at compile time, keeping every guarantee.
What you will be able to do
define-a-dsl— Define what a domain-specific language is and distinguish internal from external.explain-dsl-structure— Explain what gives a DSL structure, as opposed to a merely clean API.use-lambdas-with-receivers— Use a lambda with a receiver to give a block its own implicit this.build-a-nested-dsl— Build a nested, structured DSL such as an HTML builder.use-the-invoke-convention— Use the invoke convention to make an object callable like a function.use-infix-and-extensions-for-readability— Combine infix calls, extension functions and member extensions for readable syntax.read-real-kotlin-dsls— Read and explain real Kotlin DSLs such as test frameworks and Exposed.judge-when-a-dsl-is-warranted— Judge when a DSL is worth building and when a plain API is better.
What we will cover
- From APIs to DSLs — what a DSL is, and what gives it structure.
- Lambdas with receivers — the central mechanism.
- The invoke convention — making an object callable.
- DSLs in practice — four real ones, read for their mechanisms.
- When a DSL is warranted — and when it is not.
A taste of the goal
By the end of this unit you will be able to build these shapes:
html { body { p { +"text" } } } // well-formed HTML, checked
"kotlin" should startWith("kot") // test assertions
1.days.ago // date arithmetic
Country.select { Country.name eq "Kotlin" } // SQLNone of these is a string. None is parsed at runtime. Each is ordinary Kotlin, type-checked by the compiler, with autocompletion in the IDE.
A DSL is a vocabulary that readers must learn.
Sometimes that is an excellent bargain. Sometimes a plain function would have been kinder. The last section of this unit returns to the question, and it is worth holding it open while you read the mechanisms.
From APIs to DSLs
What a DSL is
A general-purpose language does everything adequately. A domain-specific language does one thing well, giving up generality in exchange. SQL and regular expressions are the classic examples — neither can express a whole program, and both are far better than a general-purpose language at what they do.
DSLs tend to be declarative rather than imperative: they describe what the desired result is and leave how to the implementation. That is where the concision comes from, and also where the optimisation opportunities come from — a SQL engine can reorder a query precisely because the query did not specify a procedure.
External and internal
External DSLs are separate languages with their own grammar and parser, embedded in the host program as strings:
String sql = "SELECT * FROM Country WHERE name = 'Kotlin'";The cost is plain: the compiler cannot check a string, the IDE cannot complete it, refactoring cannot rename through it, and errors appear at runtime.
Internal DSLs are ordinary host-language code, written so it reads like a purpose-built one:
val result = (Country join Customer)
.slice(Country.name, Count(Customer.id))
.selectAll()
.groupBy(Country.name)The same query, checked by the compiler, completed by the IDE, and refactored safely when a column is renamed.
What actually makes it a DSL
This is the sharpest idea in the section, and it is easy to miss.
Clean API design gives you readable calls. A DSL gives you structure — a grammar.
- In a command-query API, each call stands alone. The sequence is flat: a list of independent statements.
- In a structured DSL, calls nest and chain. The result has a shape that mirrors the domain’s own shape.
Which is why an HTML DSL looks like HTML. Nesting is what carries the structure, and it is the reason the next section — lambdas with receivers — is the central mechanism of the unit.
Learning outcomes
- define-a-dsl: Define what a domain-specific language is and distinguish internal from external.
- explain-dsl-structure: Explain what gives a DSL structure, as opposed to a merely clean API.
Concepts
- domain-specific-language: generality traded for expressiveness in one domain
- internal-dsl: host-language code that reads like a separate language, and keeps static typing
- type-safe-builders: nesting that the compiler checks
Lambdas with receivers

The idea
An ordinary lambda parameter has type (T) -> Unit, and the block refers to it. A lambda with a receiver has type T.() -> Unit, and inside the block the value of type T is the implicit this — so all its members can be used unqualified.
The difference looks small and is not:
build { it.name = "x"; it.value = 1 } // ordinary lambda
build { name = "x"; value = 1 } // lambda with receiverOnce blocks are nested several levels deep, that is the difference between a DSL and a pile of punctuation.
You have already used these
apply, with, run and buildString from unit 5 all take lambdas with receivers:
fun buildString(builderAction: StringBuilder.() -> Unit): String {
val sb = StringBuilder()
sb.builderAction() // called like a method on sb
return sb.toString()
}Worth going back to apply with this in mind. It stops looking like a special form and becomes an ordinary function you could have written — which is the general lesson of this unit arriving early.
How the HTML builder works
fun createSimpleTable() = createHTML().
table {
tr {
td { +"cell" }
}
}Each tag function takes a lambda with a receiver of the corresponding tag type. Inside tr { ... }, this is a TR, so only the functions declared on TR are available. Which means:
- the nesting comes from lambdas containing other calls
- the static checking comes from the receiver type controlling what is legal inside
table {
td { } // will not compile — td is not a member of TABLE
}That is a genuinely strong guarantee, and it is not something a string-based template engine can offer at any price. Nor can it be achieved by naming discipline alone — it falls out of the receiver types.
Member extensions
The confinement is sharpened by declaring extension functions inside a class, so they exist only where that class is the receiver:
class Table {
fun Column<*>.primaryKey() { } // available only inside a Table block
}A member extension is visible only within the DSL’s scope. The vocabulary is available inside the block and invisible outside it, which keeps the DSL from polluting the surrounding namespace.
That combination — receiver-scoped vocabulary, statically checked nesting — is essentially the whole technique.
Learning outcomes
- use-lambdas-with-receivers: Use a lambda with a receiver to give a block its own implicit this.
- build-a-nested-dsl: Build a nested, structured DSL such as an HTML builder.
Concepts
- lambdas-with-receiver: the receiver becomes
this, so members need no qualification - extension-function-types:
T.() -> Unit, the type that makes it possible - member-extension-functions: an extension declared inside a class, so it exists only in that scope
The invoke convention
class Greeter(val greeting: String) {
operator fun invoke(name: String) {
println("$greeting, $name!")
}
}
val bavarianGreeter = Greeter("Servus")
bavarianGreeter("Dmitry") // Servus, Dmitry!Defining operator fun invoke makes instances callable with parentheses. obj(args) compiles to obj.invoke(args) — exactly the pattern from unit 7, where a + b became a.plus(b), now applied to the call syntax itself.
Where you have already seen it. Function types are interfaces with an invoke method, which is why a lambda stored in a variable can be called with parentheses. That was invoke all along — and it is why unit 8’s nullable function parameter needed ?.invoke().
Why a DSL wants it
Gradle’s build files are the motivating example, and they want to permit both:
dependencies { compile("junit:junit:4.11") } // block form
dependencies.compile("junit:junit:4.11") // direct formThe first needs an object taking a lambda with a receiver; the second needs the same object exposing methods directly. Making it callable via invoke gives both from a single object:
class DependencyHandler {
fun compile(coordinate: String) { }
operator fun invoke(body: DependencyHandler.() -> Unit) {
body()
}
}The general shape: invoke loosens the nesting requirement, so a DSL can offer a block form for the multi-item case and a direct form for the single-item case without duplicating its API.
invoke on an arbitrary class makes calls unreadable. person("Alice") tells the reader nothing about what happens.
It earns its place in a DSL, where the surrounding structure supplies the meaning, and rarely anywhere else.
Learning outcomes
- use-the-invoke-convention: Use the invoke convention to make an object callable like a function.
Concepts
- invoke-convention: unit 7’s convention mechanism applied to the call syntax itself
DSLs in practice
The most useful section in the unit, and it is a reading exercise: the mechanisms are known, so the work is recognising them in the wild.
Testing frameworks
s should startWith("kot")An infix function combined with an extension:
infix fun <T> T.should(matcher: Matcher<T>) = matcher.test(this)And where the framework wants assertions confined to a test scope, the infix receiver becomes a member extension — available inside the test block and nowhere else.
Date arithmetic
val yesterday = 1.days.ago
val tomorrow = 1.days.fromNowExtension properties on Int, returning a value that carries its own further extensions:
val Int.days: Period
get() = Period.ofDays(this)
val Period.ago: LocalDate
get() = LocalDate.now() - thisExtending a built-in type in a scoped way, and doing something a subclass could not — you cannot subclass Int.
SQL with Exposed
object Country : Table() {
val id = integer("id").autoIncrement().primaryKey()
val name = varchar("name", 50)
}
val result = Country.select { Country.name eq "Kotlin" }The mechanisms stack:
- member extensions to define table columns —
autoIncrement()andprimaryKey()exist only inside aTable - infix functions for the comparison operators —
eq,less,like - lambdas with receivers for the query block
And the result is statically typed. A renamed column is a compile error, not a broken query discovered in production.
Android UI construction
The fourth, and it shows the same builder pattern as the HTML DSL applied to a widget tree — nested blocks, each with the enclosing view as its receiver.
Every one of these is ordinary Kotlin. There is no macro system, no metaprogramming, no runtime parsing.
A small set of features — extensions, infix, conventions, receiver lambdas — composes into what looks like language design.
That composability is the honest answer to why Kotlin has so many small features rather than one big one.
Learning outcomes
- read-real-kotlin-dsls: Read and explain real Kotlin DSLs such as test frameworks and Exposed.
- use-infix-and-extensions-for-readability: Combine infix calls, extension functions and member extensions for readable syntax.
Concepts
- internal-dsl: four real ones, each assembled from features you already know
When a DSL is warranted
The costs are real
- a vocabulary readers must learn before they can read the code
- indirection between what is written and what happens
- the standing temptation to be clever
The benefit is real too
In a domain that is repetitive and genuinely has structure — markup, queries, builds, tests, UI layout — a DSL removes noise that no amount of good naming can.
Does the DSL make the reader’s job easier?
A DSL that is delightful to write and puzzling to read has failed, however pleased its author was with it.
For a handful of calls, write the functions. For a hundred structurally similar ones, build the DSL.
Learning outcomes
- judge-when-a-dsl-is-warranted: Judge when a DSL is worth building and when a plain API is better.
- define-a-dsl: Define what a domain-specific language is and distinguish internal from external.
- explain-dsl-structure: Explain what gives a DSL structure, as opposed to a merely clean API.
- use-lambdas-with-receivers: Use a lambda with a receiver to give a block its own implicit this.
- build-a-nested-dsl: Build a nested, structured DSL such as an HTML builder.
- use-the-invoke-convention: Use the invoke convention to make an object callable like a function.
- use-infix-and-extensions-for-readability: Combine infix calls, extension functions and member extensions for readable syntax.
- read-real-kotlin-dsls: Read and explain real Kotlin DSLs such as test frameworks and Exposed.
Closing the module
Look back at what this last unit used.
Extension functions and infix calls from unit 3. Lambdas from unit 5. Operator conventions from unit 7. Higher-order functions and inlining from unit 8. None of them was introduced for DSLs. Each was a modest, self-contained convenience at the time.
That is the argument the whole module has been making, and this unit is where it lands:
Kotlin’s power is not in any individual feature — most are small — but in how cleanly they compose. A language of small, orthogonal, well-chosen features lets its users build things its designers never specified.
That is a claim about programming language design, not just about Kotlin, and it is worth carrying into the rest of the course.
Conclusion
A DSL is not a tidy API; it is a grammar.
Clean naming gives readable calls. Nesting and chaining give structure that mirrors the domain — which is why an HTML DSL looks like HTML and a query DSL looks like a query.
Internal DSLs keep everything a string loses.
Compile-time checking, IDE completion, safe refactoring. The Exposed-versus-JDBC comparison is the whole argument in two code samples.
T.() -> Unitis the mechanism, andapplywas using it all along.The receiver becomes
this, so members need no qualification — and because each block’s receiver type controls what is legal inside, misplacing a tag is a compile error.Member extensions confine the vocabulary to the block.
An extension declared inside a class exists only where that class is the receiver. The DSL’s words are available inside and invisible outside.
invokeis unit 7’s convention applied to the call itself.It lets one object serve as both a block and a direct call, which is how Gradle offers
dependencies { }anddependencies.compile(...)from the same handler.The honest test is whether the reader’s job got easier.
Not the author’s. A DSL is a vocabulary others must learn, and it earns that cost only in a repetitive domain with real structure.
The module’s real claim is about composition.
Extensions, infix, conventions, receiver lambdas — none designed for DSLs, all combining into one. Small orthogonal features let users build what the designers never specified.