You're designing a public nested builder DSL that produces an immutable tree. What design choices prevent builder/mutation leaks and partially-built nodes from escaping?
answer
- Two types: mutable internal builder vs immutable public node
- Private MutableList → build() copies to List (defensive)
- Builder funcs return Unit / immutable child, never the live builder
- sealed hierarchy + @DslMarker closes the grammar
- Validate invariants inside build()
basics
~10 sKeep mutable builders hidden, build into private mutable lists, and only hand back a finished read-only tree. Don't return the builder or let the configuration block run after building finishes.
solid answer
~40 sSeparate the builder type (mutable, internal) from the result type (immutable, public). The builder accumulates children in a private MutableList; the top-level entry function runs the receiver lambda and then calls build() to produce the immutable node — copying lists to List/ImmutableList so post-build mutation is impossible. Make builder functions return the immutable child (or Unit) rather than the live builder, so callers can't keep mutating after the block. Use @DslMarker to stop cross-scope calls. Avoid leaking 'this' from inside a child's init lambda. Consider sealing the node hierarchy so the legal grammar is closed. The receiver lambda runs synchronously before build(), so there's no escape of a half-built node unless you deliberately store the receiver — which the DSL shouldn't encourage.
code
kotlin · 17 lines@DslMarker annotation class DocDsl
sealed interface Node
class Section(val title: String, val children: List<Node>) : Node
class Text(val value: String) : Node
@DocDsl class SectionBuilder(private val title: String) {
private val children = mutableListOf<Node>()
fun text(v: String) { children += Text(v) }
fun section(title: String, init: SectionBuilder.() -> Unit) {
children += SectionBuilder(title).apply(init).build()
}
fun build(): Section {
require(title.isNotBlank()) { "title required" }
return Section(title, children.toList()) // defensive copy → immutable
}
}
fun document(title: String, init: SectionBuilder.() -> Unit) = SectionBuilder(title).apply(init).build()go deeper
Can state that the result should be read-only but not design the builder/result split.
Separates builder from result and copies the list, but may miss receiver-capture and sealed-grammar nuances.
Designs defensive copies, Unit-returning builders, validation in build(), and uses @DslMarker.
Reasons end-to-end about leak surfaces, read-only-vs-immutable distinction, sealed grammar, and immutable-collection libraries for public API guarantees.
## The risk Nested builders are mutable during construction (children get appended). If that mutability leaks past construction, callers can corrupt a 'finished' tree, observe a half-built node, or hold a reference that keeps mutating. A robust public DSL designs the leaks away. ## Pattern: separate builder and result types Keep a mutable **builder** distinct from the immutable **node**: ```kotlin @DslMarker annotation class DocDsl sealed interface Node class Section(val title: String, val children: List<Node>) : Node // immutable result class Text(val value: String) : Node @DocDsl class SectionBuilder(private val title: String) { private val children = mutableListOf<Node>() // private, mutable, hidden fun text(value: String) { children += Text(value) } fun section(title: String, init: SectionBuilder.() -> Unit) { children += SectionBuilder(title).apply(init).build() } fun build(): Section = Section(title, children.toList()) // defensive copy → immutable } fun document(title: String, init: SectionBuilder.() -> Unit): Section = SectionBuilder(title).apply(init).build() ``` Key moves: - **`children` is private and mutable**; the public result exposes only `List<Node>`. - **`build()` copies** with `toList()`, so even if the builder is somehow retained, the produced `Section` can't be mutated through it. - **`section(...)` builds the child immediately** and stores the *immutable* `Section`, not the child builder. - **Builder functions return `Unit`** (or the immutable child), never the live builder — callers can't keep a handle to mutate later. ## Closing the grammar Make the node hierarchy **`sealed`** so only the intended node types exist. Combined with `@DslMarker`, the set of legal nested calls is fully constrained at compile time. ## Avoiding receiver leaks The one real escape route is a user capturing `this` inside a block (`var stash: SectionBuilder? = null; section("x") { stash = this }`). You can't fully prevent capture, but: don't return the builder, keep mutating methods on the builder only (not the result), and the builder becomes useless after `build()` since its output is already copied. Document that the builder is single-use. ## Other considerations - **Validation in build()**: enforce invariants (non-empty title, no duplicate ids) at `build()` time so an invalid tree never materializes. - **Thread-safety**: builders are not thread-safe by design; the receiver lambda runs synchronously on one thread, which is fine — don't share a builder across threads. - **Kotlinx immutable collections**: returning `kotlinx.collections.immutable.PersistentList`/`ImmutableList` documents immutability stronger than `List` (which is only read-only, not guaranteed immutable). ## Summary Hide the mutable builder, produce an immutable result via a defensive-copy `build()`, return immutable children or Unit, seal the hierarchy, mark with `@DslMarker`, and validate in `build()`. Then partially-built or mutable state can't escape the construction call.
- Why call toList() in build() if children is already a MutableList passed as a read-only List?A read-only List can still be a MutableList underneath; toList() makes a true copy so no retained reference to the builder's list can mutate the finished node.
- Can you fully prevent a user from capturing the builder receiver inside a block?No — Kotlin can't stop lambda capture; you mitigate by never returning the builder, copying in build(), and treating the builder as single-use.
- Why prefer a sealed node hierarchy here?It closes the set of legal node types so the DSL grammar is exhaustive and exhaustively matchable in when expressions.
Like a film set (the mutable builder) versus the released movie (the immutable result): the audience only ever gets the finished cut, never the live set.
saying these in an interview costs you the question
- Exposing the mutable builder list directly on the result type
- Returning the live builder from nested functions, allowing later mutation
- Assuming a read-only List is automatically immutable (it isn't — it can be a backing MutableList)
- Skipping defensive copies in build()
- Making the builder thread-safe 'just in case' when it's used synchronously on one thread