Skip to Content
Course content

52: Kotlin DSLs and Builder Patterns

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've spent a lot of time in large codebases where configuring objects felt like filling out a government form. You've seen it: constructors with fifteen nullable arguments, or a chain of .setSomething() calls that stretch across the screen. It's tedious and error-prone. This is why I love Kotlin DSLs. We can actually create a mini-language inside Kotlin that makes our configuration look like a declaration rather than a set of instructions.

To show you how this works, let's build a simple DSL for creating Email Templates. We want to be able to define a subject, a header, and a body in a way that feels natural.

The frustration of clunky constructors

If we did this the "standard" way, we'd have a data class like this:

data class Email(
    val subject: String,
    val header: String,
    val body: String,
    val footer: String
)

That's fine for three fields, but once you add CCs, BCCs, attachments, and priority levels, the constructor becomes a nightmare. You start forgetting which String is the header and which is the body. I've definitely pushed a bug to production because I swapped two adjacent String arguments in a constructor. Let's avoid that.

Moving toward a builder

The first step is the Builder pattern. We create a separate class to hold the state while we're "assembling" the email. I'll start by defining an EmailBuilder that has a build() method to return the final immutable Email object.

class EmailBuilder {
    var subject: String = ""
    var header: String = ""
    var body: String = ""
    var footer: String = ""

    fun build() = Email(subject, header, body, footer)
}

Now, the "Kotlin magic" happens. We can write a top-level function that takes a lambda. But not just any lambda—a function literal with receiver. By using EmailBuilder.() -> Unit, we tell Kotlin that inside the lambda, the this context is an instance of EmailBuilder.

fun email(init: EmailBuilder.() -> Unit): Email {
    val builder = EmailBuilder()
    builder.init() // This executes the lambda within the context of the builder
    return builder.build()
}

Now look at how we use it. It's clean. It's declarative. We don't even need to type this.subject because we're inside the receiver scope:

val myEmail = email {
    subject = "Project Update"
    header = "Hello Team,"
    body = "The sprint is looking good!"
    footer = "Best, Engineering Lead"
}

Adding nested complexity

Real-world DSLs usually have nesting. Let's say an email can have multiple "sections" in the body. I'll create a Section class and a way to add them to the builder.

class Section(var content: String = "")

class EmailBuilder {
    var subject: String = ""
    val sections = mutableListOf<Section>()

    fun section(init: Section.() -> Unit) {
        val s = Section().apply(init)
        sections.add(s)
    }
    
    fun build() = Email(subject, sections) 
}

Oops, my scope is leaking

Here is where I usually trip up the first time I build these. I tried to run this code and noticed something weird. Because section is a function of EmailBuilder, and the Section block is also a lambda, I could accidentally call section { ... } inside another section { ... } block.

Since Section doesn't have a section method, you'd think it's impossible. But because of how Kotlin handles receivers in nested lambdas, the inner block still has access to the outer EmailBuilder receiver. I could accidentally nest sections infinitely, which makes no sense for my email model.

email {
    subject = "Wrong"
    section {
        content = "I am the first section"
        section { // This should NOT be allowed, but Kotlin lets me!
            content = "I am a nested section that shouldn't exist"
        }
    }
}

Locking it down with @DslMarker

To fix this, Kotlin provides @DslMarker. It's an annotation that tells the compiler: "Once you enter a receiver of this type, you can't access the receivers of the outer scope."

First, I define the marker annotation. Then, I apply it to all my builder classes.

@DslMarker
annotation class EmailDsl

@EmailDsl
class EmailBuilder {
    var subject: String = ""
    val sections = mutableListOf<Section>()

    fun section(init: Section.() -> Unit) {
        val s = Section().apply(init)
        sections.add(s)
    }
}

@EmailDsl
class Section {
    var content: String = ""
}

Now, if I try to call section { ... } inside another section block, the compiler will throw an error. It forces the developer to stick to the hierarchy I've designed. It's a small addition, but it's the difference between a "loose" script and a professional, type-safe DSL.




📋 Practical Task

Build a Markdown-style List DSL

Your task is to create a DSL that allows a user to build a nested list structure (similar to a Markdown bulleted list).

Requirements:

  • Create a ListItem class that can hold a text string and a list of child ListItem objects.
  • Create a ListBuilder class to manage the assembly of these items.
  • Implement a top-level bulletList { ... } function.
  • Ensure you use a @DslMarker annotation to prevent calling the item { ... } function inside another item { ... } block unless you explicitly design it to allow nesting (in this case, the requirements want you to allow nesting, but prevent the user from accessing the root ListBuilder properties from within a nested item).
  • The final output should be a List of ListItem objects.

Example Usage you should be aiming for:

val myList = bulletList {
    item {
        text = "Shopping"
        item { text = "Milk" }
        item { text = "Eggs" }
    }
    item {
        text = "Chores"
        item { text = "Vacuum" }
    }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.