Fluent Object Configuration with apply and also in Kotlin
Introduction
When I work with Kotlin day‑to‑day, I often find myself building objects that require a handful of optional settings. Whether it’s configuring a network request, wiring up a UI component, or preparing a data transfer object, the classic approach of passing parameters to a constructor or chaining setter calls quickly becomes verbose. Kotlin offers two extension functions—apply and also—that let you write clean, readable configuration blocks. In this article I’ll walk you through a practical pattern that combines them, show why it’s worth the switch, and give you a ready‑to‑use snippet you can drop into production code.
The Problem with Traditional Builders
Imagine you need a RequestConfig object that may contain a URL, headers, timeout, and retry policy. A naïve implementation looks like this:
data class RequestConfig(
val url: String,
val headers: Map = emptyMap(),
val timeoutMs: Int = DEFAULT_TIMEOUT,
val maxRetries: Int = 0
) {
companion object {
private const val DEFAULT_TIMEOUT = 5000
}
}
Creating an instance requires either a long parameter list or a series of setter calls:
val config = RequestConfig(
url = "https://api.example.com",
headers = mapOf("Authorization" -> "Bearer token"),
timeoutMs = 10000,
maxRetries = 3
)
Even with named arguments, the code can feel cluttered, especially when many optional fields exist. Adding a new optional field means updating every call site. This is where a fluent builder pattern shines, but many developers shy away from the boilerplate that traditional builders generate.
How apply and also Solve It
Kotlin’s apply extension function evaluates its receiver block and returns the receiver itself. This makes it perfect for “configure‑then‑return” scenarios. When combined with also, which simply passes the value through a block, you can chain multiple configuration steps without creating intermediate variables.
The pattern I use most often looks like this:
fun buildRequest(block: RequestConfig.() -> RequestConfig): RequestConfig = block(RequestConfig())
val request = buildRequest {
url = "https://api.example.com"
headers += "Authorization" to "Bearer token"
timeoutMs = 10000
maxRetries = 3
}
At a glance you see the intent: a block that mutates a fresh RequestConfig and returns it. The apply inside buildRequest ensures the receiver is returned after the block runs, so the resulting value is usable directly. The also isn’t strictly necessary here, but it becomes handy when you need to transform the value after configuration, for example adding a default header only if none was supplied:
fun buildRequest(block: RequestConfig.() -> RequestConfig): RequestConfig = block(RequestConfig())
val request = buildRequest {
url = "https://api.example.com"
headers += "Authorization" to "Bearer token"
timeoutMs = 10000
maxRetries = 3
}.also {
if (it.headers.isEmpty()) {
it.headers += "X-Default" to "true"
}
}
Notice that also lets us operate on the already‑configured instance without breaking the flow. The result is a concise, readable DSL that feels natural to write and maintain.
Real‑World Example: Configuring a UI Component
Let’s say you’re building a custom Android Button wrapper. The wrapper needs a text label, background color, click listener, and an optional elevation. Using the same pattern, you can create a small factory:
class ButtonBuilder {
private var text: String = ""
private var backgroundColor: Int = Color.TRANSPARENT
private var onClick: (() -> Unit)? = null
private var elevation: Float = 0f
fun setText(value: String) = apply { text = value }
fun setBackgroundColor(color: Int) = apply { backgroundColor = color }
fun setOnClick(listener: () -> Unit) = apply { onClick = listener }
fun setElevation(value: Float) = apply { elevation = value }
fun build(): Button = Button().apply {
setText(text)
setBackgroundColor(backgroundColor)
onClick?.let { setOnClickListener { it() } }
elevation?.let { setElevation(elevation) }
}
}
// Usage
val button = ButtonBuilder()
.setText("Save")
.setBackgroundColor(Color.BLUE)
.setOnClick { save() }
.setElevation(8f)
.build()
Each setter returns this (thanks to apply), enabling a fluent chain. The build method then applies the stored state to the real Android Button instance. This eliminates the need for a separate builder class with nested Builder and build methods, keeping the code compact while still being testable and readable.
When to Prefer Other Builders
The apply/also pattern works best for objects with a modest number of optional fields. If you start seeing long configuration blocks, it might be a sign that a dedicated data class with a primary constructor (or a sealed class hierarchy) would be clearer. Also, for immutable objects you should consider using copy together with apply to avoid unintended mutations.
Best Practices and Pitfalls
- Keep the configuration block side‑effect free as much as possible.
apply is great for setting fields, but avoid complex logic inside it. - Remember that
apply returns the receiver, not a new instance. If you need immutability, copy the data class after configuration. - Use
also sparingly; it can make the flow harder to follow if over‑used. Reserve it for post‑processing steps. - Document the DSL with a small comment block so teammates understand what each setter does.
Pro tip: When you need a configuration function that returns a value different from the receiver, chain apply with let. This lets you transform the object while still benefiting from the fluent syntax.
Wrapping Up
The combination of apply and also gives you a powerful, Kotlin‑idiomatic way to write fluent configuration blocks without the heavy boilerplate of traditional builder classes. Whether you’re assembling a network request, wiring up a UI component, or preparing a DTO for serialization, this pattern keeps your code clean, testable, and easy to extend. Try it on your next project and see how much more readable the configuration code becomes.