skip to content

Routing & Config DSL Idioms

Server and configuration DSLs like Ktor's routing block or a serialization config are the same builder pattern applied to a settings object. Reading one and explaining what the braces actually are is a common interview exercise.

part ofKotlinoverview, primer and where to startread it →
on this pageshow

questions

5

Explain how a config DSL like Json { isLenient = true } works under the hood. What two Kotlin language features make this `{ ... }` block possible?

level: juniorimportance: must knowfreq 75%

answer

  1. Last param is Builder.() -> Unit
  2. Trailing lambda moves outside parens
  3. Receiver means this == builder
  4. isLenient = true is this.isLenient = true
  5. Mutable builder -> immutable result

basics

~10 s

The braces are a function passed as the last argument. Inside, this is a settings object, so you set properties like isLenient directly without naming the object.

solid answer

~40 s

Json { ... } is a function whose last parameter is a function type with receiver: `JsonBuilder.() -> Unit`. Two features combine. First, trailing-lambda syntax: when a lambda is the last argument it moves outside the parentheses, so `Json({...})` becomes `Json { ... }`. Second, the receiver type means inside the lambda `this` is a JsonBuilder instance, so `isLenient = true` is really `this.isLenient = true`. The Json function creates a JsonBuilder, applies your lambda to it to mutate its fields, then builds an immutable Json instance from the populated builder. This is the classic builder-via-receiver-lambda idiom shared by kotlinx.serialization, Ktor install blocks, and HTTP client config.

code

kotlin · 10 lines
kotlin
val json = Json {
    isLenient = true
    ignoreUnknownKeys = true
    prettyPrint = false
}
// desugars to:
val json2 = Json(builderAction = fun JsonBuilder.() {
    this.isLenient = true
    this.ignoreUnknownKeys = true
})

go deeper

for a junior

Recognizes Json { } is a function call with a trailing lambda and that you set properties inside.

for a middle

Explains function-type-with-receiver, the this rebinding, and the build-then-freeze flow.

for a senior

Articulates why builder/result are split (mutability, thread-safety) and relates it to apply.

for a principal

Discusses API design tradeoffs: validation timing, immutability guarantees, and forward-compatible config evolution.

## The shape of a config DSL When you write `Json { isLenient = true; ignoreUnknownKeys = true }`, you are calling a normal top-level function named `Json`. Its signature looks like: ```kotlin fun Json(builderAction: JsonBuilder.() -> Unit): Json ``` Two Kotlin features make the curly-brace syntax possible. ### 1. Trailing-lambda syntax Kotlin lets you move a lambda **out of the parentheses** when it is the **last** parameter of a call. So `Json({ ... })` can be written `Json { ... }`. If the lambda is the only argument, the parentheses disappear entirely. ### 2. Function type with receiver The parameter type `JsonBuilder.() -> Unit` is a **function type with receiver**. The `JsonBuilder.` prefix means: inside the lambda, **`this` is a JsonBuilder** — exactly as if the lambda body were a method on JsonBuilder. That is why you can write `isLenient = true` directly; it desugars to `this.isLenient = true`, and `this` resolves to the builder. ### What the function does internally The builder pattern here is: create a mutable builder, run the caller's lambda against it to mutate fields, then freeze it into an immutable result. ```kotlin // conceptual implementation class JsonBuilder { var isLenient: Boolean = false var ignoreUnknownKeys: Boolean = false // ... } fun Json(builderAction: JsonBuilder.() -> Unit): Json { val builder = JsonBuilder() // 1. mutable builder builder.builderAction() // 2. apply caller's lambda (this == builder) return JsonImpl(builder) // 3. produce immutable config } ``` The standard-library function `apply` is the same idea: `apply { ... }` is `T.() -> Unit` returning the receiver. ### Why a separate builder + immutable result The **builder** is mutable and only lives during configuration. The **result** (`Json`) is immutable and thread-safe at runtime. Separating them means callers cannot accidentally mutate config after construction. ### Key APIs/keywords - Function type with receiver: `Builder.() -> Unit` - Trailing-lambda call syntax - `this` rebinding inside the receiver lambda - `apply` / `run` as standard-library receiver-lambda helpers

  • Why is the configured Json instance immutable while the builder is mutable?
    The builder exists only during setup; the resulting Json is read at runtime concurrently, so making it immutable avoids race conditions and accidental reconfiguration.
  • What happens if the function's lambda parameter is NOT the last parameter?
    You cannot use trailing-lambda syntax; you must pass it inside the parentheses. DSL builders deliberately put the receiver lambda last.

Like handing someone a blank form (the builder) and saying 'fill in the fields'; when they hand it back you laminate it into a permanent card (the immutable config).

saying these in an interview costs you the question

  • Thinking { } is a code block / anonymous object literal rather than a lambda argument
  • Saying `this` inside the block refers to the enclosing class, not the builder
  • Believing isLenient is a global variable rather than a builder property
  • Confusing the mutable builder with the immutable result type

context

open as a page

In Ktor, how does `routing { route("/api") { get("/users") { } } }` build a nested route tree? Describe the receiver types at each level and why nesting works.

level: middleimportance: must knowfreq 70%

basics

~10 s

Each block is a lambda whose this is a route node. route("/api") makes a child node and runs the inner lambda against it, so get inside attaches to /api, building a tree of routes.

open as a page

Compare Ktor's `install(ContentNegotiation) { json() }` configuration DSL with a settings-object DSL like `Json { }`. How are their builder lambdas structured, and what does the configuration lambda's receiver type tell you?

level: middleimportance: should knowfreq 40%

basics

~20 s

Both use a trailing lambda over a settings object. Json { } configures a JsonBuilder; install(Plugin) { } configures that plugin's own Configuration class. The receiver type tells you which settings are available inside the block.

open as a page

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%

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.

open as a page

When building a nested config/routing DSL, why might inner blocks accidentally call outer-scope builder functions, and how does @DslMarker fix it?

level: seniorimportance: should knowfreq 45%

basics

~20 s

In nested blocks, both the inner and outer this receivers are in scope, so you can call an outer builder by accident. @DslMarker on the receiver types blocks the implicit outer receiver, forcing you to be explicit.

open as a page