Building Better Objects: Modern Kotlin Approaches to the Builder Pattern
Move beyond verbose boilerplate with idiomatic Kotlin features like Scope Functions, Collection Builders, and Type-Safe DSLs.
The Builder pattern is a staple of software engineering — a trusted friend when you need to construct complex objects step-by-step. However, in the expressive world of Kotlin, the traditional “Gang of Four” implementation can feel unnecessarily verbose.
In Kotlin, we can achieve the same flexibility with less boilerplate and more idiomatic power. Let’s look at how to modernize your object construction.
The Pain of Direct Construction
Imagine you are building a DatabaseQuery object. It has many optional parameters: selection, whereClause, orderBy, limit, and offset.
// A data class using defaults instead of forced nulls
class DatabaseQuery(
val selection: List<String> = emptyList(),
val whereClause: String? = null,
val orderBy: String? = null,
val limit: Int? = null,
val offset: Int? = null,
val joinTables: List<String> = emptyList(),
val distinct: Boolean = false
)
fun createQuery() {
// Problem: Constructing this with conditional logic is messy
// You can't easily "if-statement" your way into a constructor call.
val query = DatabaseQuery(
selection = listOf("id", "name"),
whereClause = "age > 30",
limit = 10
)
}While Named Arguments solve the “telescoping constructor” problem, they don’t help if you need to build the query dynamically across multiple lines of logic.
1. The Classic Builder (Interoperable & Explicit)
The traditional approach uses a separate Builder class. While verbose, it is the gold standard for Java Interoperability.
data class Email(
val recipient: String,
val subject: String?,
val body: String?,
val attachments: List<String>
)
class EmailBuilder {
private var recipient: String? = null
private var subject: String? = null
private var body: String? = null
private val attachments = mutableListOf<String>()
fun to(recipient: String) = apply { this.recipient = recipient }
fun subject(subject: String) = apply { this.subject = subject }
fun body(body: String) = apply { this.body = body }
fun addAttachment(path: String) = apply { attachments.add(path) }
fun build(): Email {
// Use requireNotNull to validate and capture the value cleanly
val to = requireNotNull(recipient) { "Recipient is required" }
return Email(to, subject, body, attachments.toList())
}
}2. The Power of apply for Configuration
For objects with mutable properties (like configuration classes or UI components), the apply scope function is incredibly efficient. It allows you to initialize and configure an object in a single expression.
class NetworkRequest(
var url: String,
var method: String = "GET",
var headers: MutableMap<String, String> = mutableMapOf(),
var body: String? = null,
var timeout: Long = 5000L
)
fun createRequest(endpoint: String, token: String? = null): NetworkRequest =
NetworkRequest(url = "https://api.example.com/$endpoint").apply {
method = "POST"
headers["Content-Type"] = "application/json"
// Use safe call + let for clean conditional configuration
token?.let { headers["Authorization"] = "Bearer $it" }
body = """{"data": "active"}"""
timeout = 10_000L
}Why it works: apply returns the object itself, making it a "built-in" builder for any class with mutable state.
3. Collection Builders
If your “complex object” is actually a list or map that needs logic to build, don’t write a builder class. Use the standard library’s buildList.
fun generatePlaylist(includePremium: Boolean): List<String> = buildList {
add("Intro Track")
if (includePremium) {
add("Exclusive Jazz Session")
add("Behind the Scenes Audio")
}
// Logic-driven construction
repeat(3) { i -> add("Standard Track ${i + 1}") }
}The Benefit: It provides a MutableList inside the block but returns a strictly immutable List.
4. Domain Specific Languages (DSLs)
For complex, nested objects, we use Lambdas with Receivers. To prevent the “leaking” of scopes (e.g., calling a builder function inside another where it doesn’t belong), we use @DslMarker.
@DslMarker
annotation class ReportDsl
@ReportDsl
class ReportBuilder {
var title: String = "Untitled"
private val sections = mutableListOf<ReportSection>()
// Richer DSL: Accept a block that returns a String
fun section(name: String, contentBlock: () -> String) {
sections.add(ReportSection(name, contentBlock()))
}
fun build() = Report(title, sections)
}
data class Report(val title: String, val sections: List<ReportSection>)
data class ReportSection(val name: String, val content: String)
// The DSL entry point
fun report(block: ReportBuilder.() -> Unit): Report =
ReportBuilder().apply(block).build()
// Usage - Reads like a configuration file
val myReport = report {
title = "Monthly Sales"
section("Overview") {
"Sales are up by 20% compared to last quarter."
}
section("Regional Details") {
val topRegion = "North America" // You can perform logic here
"Performance was strongest in $topRegion."
}
}In practice, most professional Kotlin projects mix these approaches — using simple constructors where possible and stepping up to DSLs only when the complexity justifies it.
Frequently Asked Questions (FAQs)
When should I still use a traditional “Builder” class?
Use it primarily for Java Interoperability. Java doesn’t support Kotlin’s named arguments or DSLs natively. A classic Builder ensures Java users have a familiar, clean API.
How does apply differ from a DSL?
apply is a general-purpose tool that works on any existing object. A DSL (Lambda with Receiver) is a custom-designed API that can restrict scopes via @DslMarker, provide more expressive naming, and handle complex nesting (like HTML or UI layouts).
Is there a performance cost to these builders?
For most applications, the impact is negligible. However, if you are in an extremely tight, high-frequency loop, remember that every Lambda/DSL block creates a temporary object. For 99% of use cases, the readability and safety gains are the priority.
Questions for the Readers
- Have you ever used
@DslMarkerto fix scope issues in your nested builders? - Do you prefer
applyfor configuration, or do you stick todata classconstructors with named arguments? - What is the most complex object you’ve had to build in Kotlin? Let’s discuss in the comments!
If you find my content useful, feel free to support my work here. Every contribution is greatly appreciated! 🙏

Comments
Post a Comment