skip to content

You are designing a config DSL `httpClient { timeout = 30; retry { maxAttempts = 3 } }` over a settings object. How do you structure the builder so configuration is validated once and the resulting config is immutable? Walk through the design.

level: seniorimportance: should knowfreq 35%

answer

  1. Mutable builder -> validate in build() -> immutable data class
  2. Nested block = nested builder, frozen before storing
  3. require/check validate once at build time
  4. data class val fields = thread-safe result
  5. @DslMarker + defaults on builder vars

basics

~10 s

Use a mutable builder with var fields and nested builder functions. The top-level function creates the builder, runs the user's lambda, validates, then copies the values into an immutable data class it returns.

solid answer

~40 s

Define a mutable `Builder` class with `var` properties and a nested-block function `retry(block: RetryBuilder.() -> Unit)` that builds a sub-config. The entry function `httpClient(block: Builder.() -> Unit): HttpClientConfig` creates the builder, applies the receiver lambda, then performs validation (e.g. `require(timeout > 0)`) and constructs an immutable result — typically a `data class` with `val` fields. Annotate builder receiver classes with a shared `@DslMarker` annotation to prevent inner blocks from leaking into outer scope. Keep the build/freeze boundary explicit: the builder is mutable and short-lived; the returned `HttpClientConfig` is immutable, thread-safe, and validated. Provide sensible defaults on the builder so optional fields can be omitted. Validation happens once at build time, not on every property write, so misconfiguration fails fast at construction with a clear message.

code

kotlin · 19 lines
kotlin
@DslMarker annotation class HttpDsl

data class RetryConfig(val maxAttempts: Int)
data class HttpClientConfig(val timeoutSeconds: Int, val retry: RetryConfig)

@HttpDsl class RetryBuilder {
    var maxAttempts: Int = 1
    fun build() = RetryConfig(maxAttempts.also { require(it >= 1) })
}
@HttpDsl class HttpClientBuilder {
    var timeout: Int = 30
    private var retry = RetryConfig(1)
    fun retry(block: RetryBuilder.() -> Unit) { retry = RetryBuilder().apply(block).build() }
    fun build(): HttpClientConfig {
        require(timeout > 0) { "timeout must be positive" }
        return HttpClientConfig(timeout, retry)
    }
}
fun httpClient(block: HttpClientBuilder.() -> Unit) = HttpClientBuilder().apply(block).build()

go deeper

for a junior

Can write a single mutable builder and a function that returns it.

for a middle

Separates mutable builder from immutable result and runs basic validation in build().

for a senior

Designs nested builders, fail-fast require/check, defaults, and @DslMarker, justifying the immutability boundary.

for a principal

Considers config evolution, thread-safety of long-lived configs, validation strategy (require vs check), and API ergonomics/back-compat.

## Goal A config DSL should let callers write fluent nested blocks, fail fast on bad config, and hand back an **immutable, thread-safe** result. The proven shape: **mutable builder during config -> validate once -> immutable result**. ## 1. Immutable result types (what callers get) Use `data class` with `val` fields for the final config and any sub-configs: ```kotlin data class RetryConfig(val maxAttempts: Int, val backoffMs: Long) data class HttpClientConfig( val timeoutSeconds: Int, val retry: RetryConfig, ) ``` ## 2. Mutable builders (what callers mutate) Each builder has `var` fields with defaults and a `build()` that validates and freezes: ```kotlin @DslMarker annotation class HttpDsl @HttpDsl class RetryBuilder { var maxAttempts: Int = 1 var backoffMs: Long = 0 fun build(): RetryConfig { require(maxAttempts >= 1) { "maxAttempts must be >= 1" } return RetryConfig(maxAttempts, backoffMs) } } @HttpDsl class HttpClientBuilder { var timeout: Int = 30 private var retry: RetryConfig = RetryConfig(1, 0) fun retry(block: RetryBuilder.() -> Unit) { retry = RetryBuilder().apply(block).build() } fun build(): HttpClientConfig { require(timeout > 0) { "timeout must be positive" } return HttpClientConfig(timeout, retry) } } ``` ## 3. The entry function A single top-level function ties it together; its last parameter is the receiver lambda: ```kotlin fun httpClient(block: HttpClientBuilder.() -> Unit): HttpClientConfig = HttpClientBuilder().apply(block).build() val cfg = httpClient { timeout = 30 retry { maxAttempts = 3; backoffMs = 200 } } ``` ## 4. Why this structure - **Validate once, at build time.** Using `require`/`check` inside `build()` means misconfiguration throws immediately with a message, not on first use far away. - **Immutability after build.** `data class` `val` fields make the config safe to share across threads — important since clients are usually long-lived singletons. - **Nested builders for nested config.** Each sub-block (`retry { }`) is its own builder, validated and frozen before being stored, so partial sub-config never leaks. - **@DslMarker** stops `timeout = ...` inside `retry { }` from accidentally hitting the outer builder. - **Defaults** on `var` fields let callers omit optional settings. ## 5. Variations / tradeoffs - For simple flat config you can skip a separate result type and return the builder snapshot via `copy()` of a data class, but separating builder and result is cleaner. - Prefer `require` for caller-input validation (throws IllegalArgumentException) and `check` for internal invariants (IllegalStateException). - Avoid validating on each property setter: it is repetitive and can reject valid intermediate states during configuration. ### Key APIs/keywords - `Builder.() -> Unit` receiver lambdas; nested builder functions - `apply` to run the lambda and return the builder - `require` / `check` for fail-fast validation - `data class` + `val` for immutable result - `@DslMarker` for scope safety - Default values on builder `var` fields

  • Why validate in build() rather than in each property setter?
    Setters fire on every write and may reject valid intermediate states; build() validates the complete, final configuration once and fails fast with one clear error.
  • Why return a data class with val fields instead of exposing the builder?
    The result is often shared across threads and read for the object's lifetime; immutability prevents accidental reconfiguration and data races.
  • How would you make `retry` optional with a default?
    Initialize the builder's retry field to a default RetryConfig; if the caller never calls retry { } the default is used.

Like assembling an order on a notepad (builder), checking it once at the register (validate), then printing a sealed receipt (immutable config) the kitchen can trust.

saying these in an interview costs you the question

  • Returning the mutable builder itself as the public config
  • Validating on every setter instead of once at build time
  • Storing a half-built sub-builder instead of its frozen result
  • Using lateinit/non-defaulted vars so omitting a block crashes
  • Forgetting @DslMarker, letting inner blocks mutate outer scope

context