skip to content

When designing a builder entry function `fun table(init: Table.() -> Unit): Table`, what design choices (inline, return value, non-null result, immutability) matter, and why?

level: seniorimportance: nice to knowfreq 22%

answer

  1. Return the object (apply returns receiver) -> composes
  2. inline = no lambda alloc + non-local return
  3. public inline can't touch private members
  4. Expose List/val views after build, not Mutable
  5. Validate required fields before returning

basics

~20 s

Decide whether to inline the function (cheaper lambdas, allows non-local returns), make sure it returns the configured object (so it composes), and choose whether the result is mutable afterward. These choices affect performance, ergonomics, and safety.

solid answer

~50 s

A good builder entry function returns the configured, non-null object as a single expression (`Table().apply(init)`), so callers can assign and compose it. Marking it `inline` lets the compiler inline the `init` lambda — avoiding a function-object allocation and enabling non-local returns from the block — which is cheap and idiomatic for tiny DSL helpers; the trade-off is code-size and that public inline functions can't touch private members. You should decide post-build mutability: builders typically mutate a `MutableList` during construction, then you may expose only read-only views (`List`, `val`) on the finished object so the DSL phase and the use phase are separated. Avoid leaking the half-built receiver. For required fields, validate at the end of the builder (or use required constructor params) so an incomplete object can't escape. These choices balance allocation cost, ergonomics, and the guarantee that the returned object is fully and validly configured.

code

kotlin · 8 lines
kotlin
inline fun table(init: Table.() -> Unit): Table =
    Table().apply(init).also { require(it.rows.isNotEmpty()) { "table needs rows" } }

class Table {
    private val _rows = mutableListOf<Row>()
    val rows: List<Row> get() = _rows   // read-only view
    fun row(init: Row.() -> Unit) { _rows += Row().apply(init) }
}

go deeper

for a junior

Knows the function should return the object and configure it in a block.

for a middle

Understands returning the receiver via apply and basic mutability concerns.

for a senior

Reasons about inline/non-local return trade-offs, read-only views, and end-of-builder validation.

for a principal

Designs the phase separation (build vs use), the required-field strategy, and inline policy for a library-grade DSL with consistency across many builders.

## The entry function shape ```kotlin fun table(init: Table.() -> Unit): Table = Table().apply(init) ``` Several deliberate choices live in this one line. ## 1. Return the configured object The builder must **return** the object (not `Unit`), so it composes: `val t = table { ... }`, or it can be passed onward. `apply` gives this for free because it returns its receiver. Returning `Unit` would force callers to capture state via side effects. ## 2. `inline` or not ```kotlin inline fun table(init: Table.() -> Unit): Table = Table().apply(init) ``` - **Pros of `inline`**: the lambda body is inlined at the call site, so no function object is allocated for `init`, and the block may use **non-local returns** (`return` from the enclosing function). For tiny, hot, or deeply nested DSL helpers this removes per-call lambda allocations. - **Cons**: increases bytecode size if the function is large or widely used; a **public** inline function cannot access `private`/`internal` members (they'd leak into call sites). For most leaf builder helpers `inline` is a reasonable default; for large builders, skip it. - You can fine-tune with `noinline` (keep a specific lambda parameter as a real object) or `crossinline` (forbid non-local returns from a lambda that's invoked indirectly). ## 3. Mutability across phases A builder is mutable **during** construction: ```kotlin class Table { private val _rows = mutableListOf<Row>() val rows: List<Row> get() = _rows // read-only view after build fun row(init: Row.() -> Unit) { _rows += Row().apply(init) } } ``` Expose only **read-only** types (`List`, not `MutableList`) and `val`s on the finished object so consumers can't mutate it after the DSL phase. This separates the *configuration* phase from the *use* phase. ## 4. Required fields and validity A risk is returning a half-configured object. Options: - Make required data **constructor parameters** so the object can't exist without them. - **Validate at the end** of the builder (`require(...)` / `check(...)`) before returning, so an invalid object never escapes. ## 5. Don't leak the receiver Because the block has the receiver as `this`, avoid designs where the half-built object can be stashed and used elsewhere before it's complete. ## Summary trade-offs | Choice | Buys you | Costs | |---|---|---| | Return the object | composition | — | | `inline` | no lambda alloc, non-local return | bytecode size, no private access if public | | Read-only views after build | post-build immutability | a little boilerplate | | End-of-builder validation | no invalid object escapes | must define required-ness | These are the levers a senior weighs when promoting an `apply`-based snippet into a reusable, safe builder API.

  • Why can't a public `inline` builder function read a `private` field of the surrounding class?
    Inlining copies the body into the caller's code; exposing private members there would break encapsulation, so the compiler forbids it for public/protected inline functions.
  • How do you stop the finished object from being mutated after the DSL block runs?
    Back it with `private` mutable state and expose only read-only types (`List`, `val` getters), or return an immutable snapshot built at the end.

saying these in an interview costs you the question

  • Returning `Unit` from the builder so it can't compose
  • Always inlining without considering bytecode size or private-access limits
  • Exposing `MutableList`/`var` so the built object stays mutable
  • Returning a half-built object with no validation of required fields

context