Story Opening

The design review was in the small meeting room with the whiteboard nobody fully erased. Kabir had written three options on it, under the heading region + audit log, everywhere:

  1. ThreadLocal<PricingContext>, set by a servlet filter.
  2. A request-scoped Spring bean injected into every pricing class.
  3. A PricingContext parameter object, passed by hand.

Next to it, on the projector, was what he had done on Friday, which was option 3 without the object:

// Fragment of story/ThreadedContext.kt
// Kabir's Friday version: region and log threaded through every layer by hand.
fun percentOff(pricePaise: Long, percent: Int, region: String, log: AuditLog): Long {
val result = pricePaise * (100 - percent) / 100
log.lines += "$region: -$percent% -> $result"
return result
}
fun priceLine(pricePaise: Long, quantity: Int, region: String, log: AuditLog): Long =
percentOff(pricePaise, 10, region, log) * quantity
fun priceBasket(lines: List<Pair<Long, Int>>, region: String, log: AuditLog): Long =
lines.sumOf { (price, quantity) -> priceLine(price, quantity, region, log) }

“Every function takes region and log, and most of them only pass them on,” he said. “Option 1 breaks the moment pricing goes async, because the value stays on the old thread. Option 2 turns every pricing rule into a Spring bean. Option 3 is this, with one argument instead of two.”

Lena added a fourth line to the whiteboard: context parameters. Then a fifth, extensions, and a sixth, delegation. “Same idea three times,” she said. “Stop carrying things the compiler can carry for you.”


Java → Kotlin: The Quick Map

JavaKotlinNote
StringUtils.isSku(s)fun String.isSku() → s.isSku()Found by IDE completion on any String
ThreadLocal, request-scoped beans, extra parameterscontext(region: Region) fun …Compile-time implicit parameters (Stable since 2.4.0)
Builder: p.setA(…); p.setB(…); return p;Product().apply { a = …; b = … }One of five scope functions
Optional.ofNullable(x).map(…)x?.let { … }Runs only for non-null
Decorator: 30 forwarding methodsclass Counting(inner: Catalog) : Catalog by innerThe compiler writes the forwarding
Double-checked locking for a lazy fieldval x by lazy { … }Also PUBLICATION and NONE modes
PropertyChangeSupportvar x by Delegates.observable(…)Also vetoable, map-backed, custom
a.add(b), a.compareTo(b) < 0, map.get(k)a + b, a < b, map[k]A fixed set of overloadable operators
Builders and Consumer<Builder>Type-safe builders with @DslMarkerLambdas with receivers (Part 5)

Conceptual Deep-Dive

Compile-time plumbing, not runtime machinery

Java solves “make this available everywhere” with runtime mechanisms. Utility classes need an explicit Utils. call at every site. Thread-locals hold context per thread. Dynamic proxies and AOP intercept calls. Spring’s scoped beans resolve per request. They work, but they are invisible to the type system and fragile in the ways Kabir listed: a ThreadLocal doesn’t follow work onto another thread, and a Spring proxy doesn’t intercept a call that a bean makes on itself.

Every tool in this part is the opposite trade. Kotlin’s compiler rewrites the source into ordinary Java-shaped bytecode:

You writeThe compiler generates
fun String.isSku()static boolean isSku(String)
context(region: Region, log: AuditLog) fun priceWithVat(p: Long)static long priceWithVat(Region, AuditLog, long)
class CountingCatalog(inner: Catalog) : Catalog by innerOne forwarding method per interface method you don’t override
val planogram by lazy { … }A hidden Lazy field and a getter that calls getValue()
a + ba.plus(b)

So the mental model is: if you can’t see how it would work as a plain Java method call, look again; it is a plain Java method call. That explains both the power, which is no reflection, no proxies and no thread affinity, and the traps, which are all cases where you expected runtime behaviour, such as virtual dispatch or self-interception, from something that is resolved at compile time.


Technical Explanation

Extension functions and properties

// An extension function: called like a member, compiled to a static method that takes the receiver first.
fun String.isSku(): Boolean = matches(Regex("SHW-\\d{4}"))
// Extension properties have no backing field: they are getters (and setters) only.
val Int.rupees: Long get() = this * 100L
fun Long.formatPaise(): String = "₹${this / 100}.${(this % 100).toString().padStart(2, '0')}"
// A nullable receiver: callable on null, and 'this' may be null inside.
fun String?.orUnknown(): String = this ?: "UNKNOWN"
open class Shelf
class ChilledShelf : Shelf()
fun Shelf.kind() = "shelf"
fun ChilledShelf.kind() = "chilled shelf"
class Label(val text: String) {
fun render() = "member: $text"
}
// A member always wins over an extension with the same signature. The compiler warns.
fun Label.render() = "extension: $text"
fun main() {
println("SHW-1001".isSku()) // -> true
println(165.rupees) // -> 16500
println(16_550L.formatPaise()) // -> ₹165.50
val missing: String? = null
println(missing.orUnknown()) // -> UNKNOWN
// Extensions dispatch on the static type: there is no virtual call to override.
val shelf: Shelf = ChilledShelf()
println(shelf.kind()) // -> shelf
println(ChilledShelf().kind()) // -> chilled shelf
println(Label("Toor Dal").render()) // -> member: Toor Dal
}

An extension doesn’t modify the class. It is a static function in the file’s facade class, with the receiver as its first parameter:

// javap -p com.shelfwise.part06.extensions.ExtensionsKt
public static final boolean isSku(java.lang.String);
public static final long getRupees(int);
public static final java.lang.String formatPaise(long);
public static final java.lang.String orUnknown(java.lang.String);
public static final java.lang.String kind(com.shelfwise.part06.extensions.Shelf);
public static final java.lang.String kind(com.shelfwise.part06.extensions.ChilledShelf);
...

Three rules follow from “it’s a static method”:

  • Dispatch is static (see the Gotchas).
  • Members win. If the class has a member with the same signature, the extension is never called, and the compiler says so: warning: this extension is shadowed by a member: 'fun render(): String' defined in '…Label'. The practical form of this rule: a library upgrade that adds a matching member silently takes over from your extension, with only that warning.
  • No access to private members, no state. An extension sees only the receiver’s public API (or internal, within the module), and an extension property can’t have a backing field; it is a getter.

The design value is discoverability without inheritance: "SHW-1001".isSku() shows up in IDE completion on any String, and no wrapper type or utility-class import is needed beyond the function’s own. The standard library is mostly extensions: map and filter are extensions on Iterable, and use on AutoCloseable.

Scope functions

class ProductDraft {
var sku = ""
var name = ""
var pricePaise = 0L
val tags = mutableListOf<String>()
}
fun findName(sku: String): String? = mapOf("SHW-1001" to "Toor Dal 1kg")[sku]
class Reviewer(val name: String) {
// Inside run, unqualified names resolve against the draft first: 'name' is the product's name.
fun sign(draft: ProductDraft) = draft.run { "$name, reviewed by ${this@Reviewer.name}" }
}
fun main() {
// apply: configure an object and return it. Inside, 'this' is the object.
val draft = ProductDraft().apply {
sku = "SHW-1001"
name = "Toor Dal 1kg"
pricePaise = 16_500
tags += "staples"
}
// also: a side effect that returns the object unchanged. Inside, 'it' is the object.
val same = draft.also { println("created ${it.sku}") } // -> created SHW-1001
println(same === draft) // -> true
// let: transform a value, most often after ?. to run only for non-null.
println(findName("SHW-1001")?.let { "$it (${it.length} chars)" }) // -> Toor Dal 1kg (12 chars)
println(findName("SHW-9999")?.let { "found $it" } ?: "no such SKU") // -> no such SKU
// run: compute a result from an object, with 'this' as the receiver.
println(draft.run { "$sku: $name at ${pricePaise / 100}" }) // -> SHW-1001: Toor Dal 1kg at 165
// with: the same as run, but the object is an argument. Reads well for "with this object, do...".
println(with(draft.tags) { joinToString(prefix = "#") }) // -> #staples
// takeIf: the value if it passes the check, otherwise null.
println(15.takeIf { it in 1..90 }) // -> 15
println(500.takeIf { it in 1..90 }) // -> null
println(Reviewer("Lena").sign(draft)) // -> Toor Dal 1kg, reviewed by Lena
}

Five functions (plus takeIf), two questions: how does the block see the object (this or it), and what comes back (the object or the block’s result)?

FunctionObject inside the blockReturnsUse it for
applythisthe objectConfiguring a new object
alsoitthe objectSide effects in a chain: logging, validation
letitthe block’s result?.let { } on a nullable; transforming a value
runthisthe block’s resultComputing something from an object’s members
with(x) { }thisthe block’s resultSeveral calls on one object, not in a chain

apply, also, let and run (in its x.run { } form) are inline extensions, and with is an inline top-level function. apply, run and with take lambdas with a receiver (T.() -> …); also and let take ordinary ones ((T) -> …). All of them cost nothing at runtime. Readability is the only cost. One per expression is a good rule, and nested scope functions with two its or two thises should be split into named variables.

Context parameters (Stable since 2.4.0)

class AuditLog(private val component: String) {
val lines = mutableListOf<String>()
fun info(message: String) {
lines += "[$component] $message"
}
}
data class Region(val code: String, val vatPercent: Int)
// Context parameters (Stable since 2.4.0): declared once, supplied by the caller's context.
context(region: Region, log: AuditLog)
fun priceWithVat(pricePaise: Long): Long {
val total = pricePaise * (100 + region.vatPercent) / 100
log.info("${region.code}: $pricePaise -> $total")
return total
}
// A caller with the same context passes it on without naming it.
context(region: Region, log: AuditLog)
fun basketTotal(prices: List<Long>): Long = prices.sumOf { priceWithVat(it) }
class Transaction(val id: String) {
private val writes = mutableListOf<String>()
fun write(row: String) {
writes += row
}
fun commit(): List<String> = writes.toList()
}
context(tx: Transaction)
fun savePrice(sku: String, pricePaise: Long) = tx.write("$sku=$pricePaise")
// '_': this function never touches the Transaction; it only passes it on, implicitly, to savePrice.
context(_: Transaction)
fun reprice(skus: List<String>, pricePaise: Long) = skus.forEach { savePrice(it, pricePaise) }
// The block's type has a context: inside it, a Transaction is available implicitly.
fun transaction(id: String, block: context(Transaction) () -> Unit) {
val tx = Transaction(id)
context(tx) { block() }
println("commit ${tx.id}: ${tx.commit()}")
}
fun main() {
val log = AuditLog("pricing")
// context(a, b) { } (standard library) puts values into the context for the block.
context(Region("MH", 5), log) {
println(basketTotal(listOf(10_000, 20_000))) // -> 31500
}
log.lines.forEach(::println)
// -> [pricing] MH: 10000 -> 10500
// -> [pricing] MH: 20000 -> 21000
// priceWithVat(10_000) // error: no context argument for 'region: Region' found.
transaction("tx-1") {
savePrice("SHW-1001", 15_900)
reprice(listOf("SHW-2040", "SHW-2041"), 69_000)
} // -> commit tx-1: [SHW-1001=15900, SHW-2040=69000, SHW-2041=69000]
}

A function declares its context in a context(…) list before fun. Callers don’t pass those arguments: the compiler finds them in the caller’s own context, either the caller’s context parameters (basketTotal → priceWithVat) or values put in scope with the standard library’s context(a, b) { … } (or with(a) { … }, since implicit receivers count too). If nothing suitable is in scope, the call doesn’t compile, with the error shown in the comment. You can read a context value by its name (region), declare it as _ when you only pass it on (reprice), or fetch it with contextOf<T>().

In bytecode, context parameters are ordinary leading parameters:

// javap -p com.shelfwise.part06.context.ContextParametersKt
public static final long priceWithVat(Region, AuditLog, long);
public static final long basketTotal(Region, AuditLog, java.util.List<java.lang.Long>);
public static final void savePrice(Transaction, java.lang.String, long);
public static final void transaction(java.lang.String, kotlin.jvm.functions.Function1<? super Transaction, kotlin.Unit>);

That answers Kabir’s objection to ThreadLocal. A context parameter is an argument, so a lambda that runs on another thread captures it like any other value, and a coroutine (Part 10) carries it across suspensions:

import kotlin.concurrent.thread
// Option 1 from the whiteboard: context in a ThreadLocal.
val currentRegion = ThreadLocal<String>()
fun regionFromThreadLocal(): String? = currentRegion.get()
// The alternative: context as a context parameter.
context(region: Region)
fun regionFromContext(): String = region.code
fun main() {
currentRegion.set("MH")
var seen: String? = "unset"
thread { seen = regionFromThreadLocal() }.join() // the new thread has its own, empty slot
println(seen) // -> null
context(Region("MH", 5)) {
thread { seen = regionFromContext() }.join() // the lambda captures the context like any value
}
println(seen) // -> MH
}

It also means Java callers see, and must pass, every context argument explicitly.

The status details, because they changed recently:

  • Stable in 2.4.0: declaring and calling functions and properties with context parameters, and context function types like context(Transaction) () -> Unit. No compiler flag. (Kotlin 2.2 and 2.3 needed -Xcontext-parameters.)
  • Still Experimental: passing context arguments explicitly at the call site, and callable references to functions with context parameters.
  • Removed in 2.3.20: the older context receivers (context(Region) without a name, which made members callable without a qualifier). Code and blog posts from 2023 and 2024 use them, and they no longer compile. Migrate by naming the parameter and qualifying the calls.

Use context parameters for cross-cutting values that most functions in a call chain only pass along: a tenant or region, a logger, a transaction, a clock. Don’t use them to hide real inputs. A price is an argument; the region it is priced in is context.

Class delegation

interface Catalog {
fun priceOf(sku: String): Long?
fun skus(): Set<String>
// A default method that calls another interface method.
fun pricedCount(): Int = skus().count { priceOf(it) != null }
}
class InMemoryCatalog(private val prices: Map<String, Long?>) : Catalog {
override fun priceOf(sku: String) = prices[sku]
override fun skus() = prices.keys
}
// 'by inner': the compiler writes every Catalog method as a call to 'inner', except those we override.
class CountingCatalog(private val inner: Catalog) : Catalog by inner {
var lookups = 0
private set
override fun priceOf(sku: String): Long? {
lookups++
return inner.priceOf(sku)
}
}
// 'var inner' looks like a swappable decorator, but 'by' captured the value at construction.
class SwitchableCatalog(var inner: Catalog) : Catalog by inner
fun main() {
val catalog = CountingCatalog(InMemoryCatalog(mapOf("SHW-1001" to 16_500, "SHW-2040" to null)))
println(catalog.priceOf("SHW-1001")) // -> 16500
println(catalog.skus()) // -> [SHW-1001, SHW-2040]
println(catalog.lookups) // -> 1
// pricedCount() is forwarded to 'inner', and inner's calls to priceOf() never come back here.
println(catalog.pricedCount()) // -> 1
println(catalog.lookups) // -> 1
val switchable = SwitchableCatalog(InMemoryCatalog(mapOf("SHW-1001" to 16_500)))
switchable.inner = InMemoryCatalog(mapOf("SHW-1001" to 14_900))
println(switchable.priceOf("SHW-1001")) // -> 16500
}

: Catalog by inner makes the compiler generate every Catalog method you don’t override as a call to inner:

// javap -c CountingCatalog (simplified)
public int pricedCount();
0: aload_0
1: getfield inner:Lcom/shelfwise/part06/delegation/Catalog;
4: invokeinterface Catalog.pricedCount:()I
9: ireturn

This is the decorator pattern without the boilerplate, and composition without inheritance, which makes final-by-default (Part 3) livable. The last two lines of output show the catch. pricedCount() is forwarded to inner, and inside inner the calls to priceOf() go to inner’s own implementation. The override in CountingCatalog never sees them:

sequenceDiagram participant C as caller participant W as CountingCatalog participant I as InMemoryCatalog C->>W: pricedCount() W->>I: pricedCount() (generated forwarding) I->>I: skus(), priceOf(...) (own methods, not W's override) I-->>W: 1 W-->>C: 1, lookups unchanged

If you know Spring, you know this bug: it is the self-invocation problem of proxy-based AOP, where @Transactional on a method called from the same bean is ignored. Delegation is a proxy written at compile time, so it has the same limit. Inheritance is the only way to get “my override applies to calls the base makes on itself”.

Delegated properties

import kotlin.properties.Delegates
import kotlin.properties.ReadOnlyProperty
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class Store(val code: String) {
// lazy: computed on first access, then cached. Thread-safe (synchronized) by default.
val planogram: List<String> by lazy {
println("loading planogram for $code")
listOf("A1: staples", "A2: snacks")
}
}
class PriceTag(initialPaise: Long) {
val history = mutableListOf<String>()
// observable: a callback after every assignment.
var pricePaise: Long by Delegates.observable(initialPaise) { _, old, new -> history += "$old -> $new" }
// vetoable: the callback decides whether the assignment happens.
var discountPercent: Int by Delegates.vetoable(0) { _, _, new -> new in 0..90 }
}
// Map delegation: each property reads the map entry with its own name.
class StoreConfig(values: Map<String, Any?>) {
val region: String by values
val openHour: Int by values
}
// A custom delegate: any object with getValue/setValue operators. ReadWriteProperty spells them out.
class Trimmed : ReadWriteProperty<Any?, String> {
private var value = ""
override fun getValue(thisRef: Any?, property: KProperty<*>): String = value
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
this.value = value.trim()
}
}
class Listing {
var title: String by Trimmed()
}
// provideDelegate runs once, when the property is declared: here it registers a column by property name.
open class Table(val name: String) {
val columns = mutableListOf<String>()
fun column() = ColumnProvider()
inner class ColumnProvider {
operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): ReadOnlyProperty<Any?, String> {
columns += property.name
val qualified = "$name.${property.name}"
return ReadOnlyProperty { _, _ -> qualified }
}
}
}
object Products : Table("products") {
val sku by column()
val price by column()
}
fun main() {
// What field-access frameworks (JPA, Gson, Jackson field visibility) see: the delegate, not the value.
println(Store::class.java.declaredFields.map { it.name }.sorted()) // -> [code, planogram$delegate]
val pune = Store("PUN-014")
println("store created") // -> store created
println(pune.planogram.size)
// -> loading planogram for PUN-014
// -> 2
println(pune.planogram.first()) // -> A1: staples
val tag = PriceTag(16_500)
tag.pricePaise = 15_900
tag.pricePaise = 14_900
println(tag.history) // -> [16500 -> 15900, 15900 -> 14900]
tag.discountPercent = 20
tag.discountPercent = 500 // vetoed: the value stays 20
println(tag.discountPercent) // -> 20
val settings = mutableMapOf<String, Any?>("region" to "MH", "openHour" to 7)
val config = StoreConfig(settings)
println("${config.region} opens at ${config.openHour}") // -> MH opens at 7
settings["openHour"] = 9 // the delegate reads the map on every access: no snapshot
println(config.openHour) // -> 9
val listing = Listing()
listing.title = " Toor Dal 1kg "
println("[${listing.title}]") // -> [Toor Dal 1kg]
println(Products.columns) // -> [sku, price]
println(Products.sku) // -> products.sku
}

val x by d means “store d in a hidden field, and implement the getter (and setter) as d.getValue(…) / d.setValue(…)”. For Store.planogram that is a private final kotlin.Lazy planogram$delegate field, filled in the constructor, and a getPlanogram() that calls planogram$delegate.getValue().

The delegates in the standard library:

  • lazy computes on first access and caches. The default mode is SYNCHRONIZED: the initializer runs under a lock and succeeds at most once, which is the double-checked locking you’d otherwise write in Java. lazy(LazyThreadSafetyMode.PUBLICATION) allows racing initializers but publishes one result, and NONE drops the lock for single-threaded use. If the initializer throws, the next access tries again.
  • Delegates.observable calls back after each assignment, and Delegates.vetoable calls back before it, so it can refuse the change (discountPercent stays 20).
  • A Map delegates each property to the entry with its name. It is handy for configuration, but a missing key throws NoSuchElementException on access, not at construction.
  • Your own: anything with getValue/setValue operators. The ReadWriteProperty and ReadOnlyProperty interfaces spell out the signatures.
  • provideDelegate runs once per instance, during construction, with access to the property’s name. For the Products object that means once: Products.columns was filled when the object initialised, before either property was read. That is how a table-mapping DSL can register columns from property names alone.

Operators and infix

@JvmInline
value class Paise(val amount: Long) : Comparable<Paise> {
operator fun plus(other: Paise) = Paise(amount + other.amount)
operator fun times(quantity: Int) = Paise(amount * quantity)
override fun compareTo(other: Paise) = amount.compareTo(other.amount)
override fun toString() = "₹${amount / 100}.${(amount % 100).toString().padStart(2, '0')}"
}
// infix: a two-argument call written without dots and parentheses.
infix fun Paise.percentOff(percent: Int) = Paise(amount * (100 - percent) / 100)
class PriceBook {
private val prices = mutableMapOf<String, Paise>()
operator fun get(sku: String): Paise? = prices[sku]
operator fun set(sku: String, price: Paise) {
prices[sku] = price
}
operator fun contains(sku: String) = sku in prices
}
fun main() {
val dal = Paise(16_500)
val rice = Paise(72_000)
println(dal + rice) // -> ₹885.00
println(dal * 3) // -> ₹495.00
println(dal < rice) // -> true
println(dal percentOff 10) // -> ₹148.50
val book = PriceBook()
book["SHW-1001"] = dal // set(...)
println(book["SHW-1001"]) // -> ₹165.00
println("SHW-9999" in book) // -> false
var basket = Paise(0)
basket += dal // no plusAssign defined, so this is basket = basket + dal
basket += rice
println(basket) // -> ₹885.00
}

Kotlin maps a fixed set of symbols to functions marked operator: + to plus, * to times, < and >= to compareTo, [] to get/set, in to contains, () to invoke, and so on. You can’t invent new symbols, and == always calls equals (which you override, not mark as operator). a += b uses plusAssign if it exists and otherwise rewrites to a = a + b, which is why basket must be a var here. compareTo needs no operator modifier because it overrides Comparable.compareTo, which already has one.

infix lets a member or extension with one parameter be called as dal percentOff 10. The standard library uses it for to ("SHW-1001" to 16_500 builds a Pair), until, step and shl. Use it sparingly: a domain verb between two values reads well; a general-purpose function written infix reads like a puzzle.


Step-by-Step Hands-On: A Product-Query DSL

Code: kotlin-for-java-survivors/language/part06-extend-scope-delegate (file dsl/ProductQuery.kt).

The Product Knowledge Assistant will turn questions like “gluten-free pasta under ₹200?” into catalog queries. Before any AI is involved, Kabir wants a query type that a developer can write by hand, which reads like the question, and which won’t compile when it’s nonsense.

Step 1 — The model, the marker, and the rupees extension from the extensions section, so prices read as 200.rupees:

// Fragment of dsl/ProductQuery.kt
data class Product(
val sku: String,
val name: String,
val category: String,
val pricePaise: Long,
val tags: Set<String> = emptySet(),
)
// Step 1: the marker. Builders annotated with it can't silently reach an outer builder's methods.
@DslMarker
annotation class QueryDsl
// Step 1 (continued): an extension property, so prices read as 200.rupees.
val Int.rupees: Long get() = this * 100L
typealias Criterion = (Product) -> Boolean

Step 2 — A nested builder for alternatives. Inside anyOf { }, each call adds an alternative instead of a requirement:

// Fragment of dsl/ProductQuery.kt
// Step 2: the nested builder for alternatives.
@QueryDsl
class AnyOfBuilder {
internal val alternatives = mutableListOf<Criterion>()
fun category(name: String) {
alternatives += { it.category == name }
}
fun tagged(tag: String) {
alternatives += { tag in it.tags }
}
}

Step 3 — The top-level builder. Each method records a criterion; nothing runs until the query does. anyOf takes a lambda with an AnyOfBuilder receiver, which is how nesting works:

// Fragment of dsl/ProductQuery.kt
// Step 3: the top-level builder. Each call adds a criterion; nothing runs until the query does.
@QueryDsl
class QueryBuilder {
private val criteria = mutableListOf<Criterion>()
private var order: Comparator<Product> = compareBy { it.sku }
private var limit = Int.MAX_VALUE
fun category(name: String) {
criteria += { it.category == name }
}
fun tagged(vararg tags: String) {
criteria += { product -> tags.all { it in product.tags } }
}
fun priceBelow(paise: Long) {
criteria += { it.pricePaise < paise }
}
fun anyOf(block: AnyOfBuilder.() -> Unit) {
val alternatives = AnyOfBuilder().apply(block).alternatives.toList()
criteria += { product -> alternatives.any { it(product) } }
}
fun cheapestFirst() {
order = compareBy { it.pricePaise }
}
fun limit(count: Int) {
limit = count
}
fun build() = ProductQuery(criteria.toList(), order, limit)
}

Step 4 — The query, callable like a function, and the entry point. operator fun invoke lets glutenFreePasta(catalog) read as a call. query { } creates a builder and runs your block with it as this:

// Fragment of dsl/ProductQuery.kt
class ProductQuery(
private val criteria: List<Criterion>,
private val order: Comparator<Product>,
private val limit: Int,
) {
// invoke: the query object can be called like a function.
operator fun invoke(products: List<Product>): List<Product> =
products.filter { product -> criteria.all { it(product) } }.sortedWith(order).take(limit)
}
// Step 4: the entry point. The lambda's receiver is the builder.
fun query(block: QueryBuilder.() -> Unit): ProductQuery = QueryBuilder().apply(block).build()

Step 5 — Use it.

// Fragment of dsl/ProductQuery.kt
// Step 5: use it.
fun main() {
val catalog = listOf(
Product("SHW-4001", "Penne Rigate 500g", "pasta", 12_000, setOf("gluten-free")),
Product("SHW-4002", "Fusilli 500g", "pasta", 9_500),
Product("SHW-4003", "Brown Rice Spaghetti 400g", "pasta", 18_900, setOf("gluten-free", "vegan")),
Product("SHW-4004", "Quinoa Pasta 250g", "pasta", 24_500, setOf("gluten-free")),
Product("SHW-5001", "Rice Noodles 200g", "noodles", 8_500, setOf("gluten-free")),
)
// "Gluten-free pasta under ₹200?"
val glutenFreePasta = query {
category("pasta")
tagged("gluten-free")
priceBelow(200.rupees)
cheapestFirst()
}
glutenFreePasta(catalog).forEach { println("${it.sku} ${it.name}") }
// -> SHW-4001 Penne Rigate 500g
// -> SHW-4003 Brown Rice Spaghetti 400g
val noodlesOrVegan = query {
anyOf {
category("noodles")
tagged("vegan")
// limit(1) // error: 'fun limit(count: Int): Unit' cannot be called in this context with an implicit receiver. Use an explicit receiver if necessary.
}
limit(1)
}
println(noodlesOrVegan(catalog).map { it.name }) // -> [Brown Rice Spaghetti 400g]
}

The commented-out limit(1) inside anyOf is what @DslMarker is for. Without the marker, the call would compile: lambdas with receivers nest, so the outer QueryBuilder is still an implicit receiver inside anyOf, and limit would quietly apply to the whole query. With both builders marked @QueryDsl, the compiler allows only the innermost receiver’s members without a qualifier, and reports 'fun limit(count: Int): Unit' cannot be called in this context with an implicit receiver. If you really mean the outer one, write this@query.limit(1).

This is the shape of the Kotlin DSLs you’ll use in this series: Gradle’s build.gradle.kts, Spring’s router { } and BeanRegistrarDsl. Each is a builder class and functions taking Builder.() -> Unit, usually with a @DslMarker.


Tips, Tricks & Gotchas

Gotcha — extensions don’t override. Java developers read shelf.kind() as a virtual call. With extensions it isn’t one: the declared type picks the function, and the code above prints shelf for a ChilledShelf. Declare behaviour that varies by subtype as a member (or an abstract member of a sealed type), and keep extensions for behaviour that doesn’t.

Gotcha — this inside a scope function isn’t your class. A Java lambda never rebinds this; it always means the enclosing instance. Inside run, apply and with, this and every unqualified member name resolve against the receiver first, so name in Reviewer.sign is the product’s name, not the reviewer’s. In a class with common property names (name, id, status), prefer let/also with a named parameter, or qualify with this@ClassName.

Gotcha — context arguments resolve by type, and the nearest one wins. It feels like @Autowired, and it fails like it. Two values of the same type at the same level are an ambiguity error, but a nested context(…) of the same type silently shadows the outer one:

class Log(val name: String)
context(log: Log)
fun whoLogs(): String = log.name
fun main() {
// Context arguments resolve by type. An inner context of the same type silently wins.
context(Log("request")) {
println(whoLogs()) // -> request
context(Log("batch")) {
println(whoLogs()) // -> batch
}
}
// Two of the same type at the same level is ambiguous, like two candidate beans for @Autowired.
// context(Log("a"), Log("b")) { whoLogs() } // error: multiple potential context arguments for 'log: Log' in scope.
}

Give context types distinct, domain-specific names (AuditLog, not Logger) so an inner block can’t capture one by accident.

Gotcha — a var delegate doesn’t swap. In class SwitchableCatalog(var inner: Catalog) : Catalog by inner, the compiler copies inner into a hidden private final field ($$delegate_0) at construction. Reassigning inner changes the property and nothing else, so the code above still prints 16500. A Java decorator with a setter would switch. If you need a swappable target, write the forwarding methods by hand.

Gotcha — field-access frameworks see the delegate. A delegated property has no field of its own: Store’s fields are code and planogram$delegate. JPA with field access, Gson, and Jackson configured for fields will persist or serialise the Lazy object, or nothing. Keep delegated properties out of entities and DTOs.

Tip — what Java sees. Extensions are static methods on the file’s facade class, and a DSL entry point takes a Function1<QueryBuilder, Unit>. Java can call both, without the syntax:

// An extension is a static method on the file's facade class, receiver first.
System.out.println(ExtensionsKt.isSku("SHW-1001")); // -> true
// A DSL entry point takes a Function1<QueryBuilder, Unit>: usable, but no longer a DSL.
ProductQuery pasta = ProductQueryKt.query(q -> {
q.category("pasta");
q.limit(1);
return Unit.INSTANCE;
});

@file:JvmName("Catalogs") renames the facade class (Part 12). For Java-heavy consumers, offer a plain builder API next to the DSL.

Tip — lazy is not free. Each by lazy property adds a Lazy and its initializer lambda per instance and, in the default and PUBLICATION modes, a volatile read on every access. For a value computed from constructor arguments and always used, initialise it directly. Use lazy for expensive values that are often never read.


Key Takeaways

ConceptRemember
ExtensionsStatic methods with the receiver as the first parameter; static dispatch; members win
Scope functionsapply/also return the object; let/run/with return the block’s result; this vs it
Context parameterscontext(name: Type), Stable since 2.4.0; leading parameters in bytecode; resolved by type, nearest wins. Context receivers were removed in 2.3.20
Class delegation: Interface by delegate writes the forwarding methods; delegate’s self-calls bypass your overrides; the delegate is fixed at construction
lazySynchronised by default; PUBLICATION and NONE modes; retries after an exception
Custom delegatesgetValue/setValue operators; provideDelegate runs once per instance, at construction
OperatorsFixed set of symbols mapped to operator funs; == is always equals
DSLsBuilder + Builder.() -> Unit + @DslMarker to block outer receivers

Story Closing

The pricing rules shed their extra parameters by Wednesday. Every function that needed the region said so in its context(…) list, and the controller put a Region and an AuditLog into context once per request. The lookup-metrics decorator Kabir had planned became a ten-line class with by. The query DSL went into a shared module, and by Friday the store-operations team was using it, from Java, in their nightly stock-report job over the full shelf-reading export.

On Monday morning they sent two messages. The first was a GC log from the nightly run: young-generation collections every few hundred milliseconds, because every step of the job allocated a fresh list the size of the export. The second was a stack trace from a test of theirs: a list returned by Kabir’s Assortment class, typed List<String>, read-only as far as Kotlin was concerned, had been sorted in place by a Java utility, and a second caller had seen the new order.

“List in Kotlin is a promise that you won’t modify it,” Lena said. “Java never made that promise.”

In Part 7, Kabir learns what Kotlin’s collections really are on the JVM, and when a chain of filter and map should become a sequence.


This is Part 6 of a 16-part series: “Kotlin for Java Survivors: Life After Semicolons.”