Articles
Kotlin Delegated Property Interview Questions: lazy, observable, and State Ownership
Practice Kotlin delegated property interviews with lazy mode comparisons, observable callback traces, shared delegate bugs, and explicit state ownership rules.
Kotlin delegated property interview questions ask more than what the by keyword means. A lazy value can freeze the wrong account's configuration, an observable callback can run after invalid state is already stored, and two objects can accidentally share one delegate's backing value.
The useful question is: Who owns the delegate's state, when can it change, and what does the accessor guarantee? Delegation supplies reusable getter and setter behavior. It does not automatically provide cache invalidation, thread safety, or an event-delivery contract.
This guide labels official Kotlin rules separately from original code-review exercises and design inference. It includes no candidate reports or employer-specific interview claims.
What Does by Actually Delegate?
Official fact: a delegated property uses a delegate's getValue and, for a mutable property, setValue operators. The accessor receives the property owner and property metadata. A delegate need not implement an interface when it supplies the required operators. Kotlin delegated-properties guide
For var score by ScoreDelegate(), identify three separate things: the object containing score, the delegate instance, and the value stored or computed by that delegate. They can have different lifetimes.
A custom delegate could store a field, read a database, or calculate a result on every access. Therefore by alone says nothing about memoization. The contract comes from the selected implementation.
Ask a concrete review question: “When this owner is created, is a new delegate created too, or does the expression reference an existing delegate?” That distinction determines whether state is per instance, per singleton, or shared across an application.
Also distinguish property delegation from class delegation, such as implementing an interface through another object. Similar syntax is not a reason to conflate their accessor and method-forwarding behavior.
When Does lazy Read a Changing Input?
Official fact: lazy computes on first access and retains the resulting value. If initialization throws, a later access attempts initialization again. It is not a periodically refreshed cache. Kotlin lazy API
Review this original example:
class Session(var accountId: String) {
val exportPrefix: String by lazy { "exports/$accountId" }
}
val session = Session("alpha")
session.accountId = "beta"
val first = session.exportPrefix
session.accountId = "gamma"
val second = session.exportPrefix
Worked inference: both first and second are "exports/beta". The initializer reads the account at first access, after the beta assignment. The gamma change does not invalidate the remembered prefix.
These are documentation-reviewed snippets, not executed Kotlin tests; the authoring environment did not have a Kotlin/JDK runtime. The expected trace follows the ordinary sequential contract and makes no claim about races.
The follow-up is: “Which result should the application produce?” If every access should use the current account, a computed getter may be appropriate. If the session must remain bound to one account, make that identity immutable and validate its construction. If export configuration needs explicit refresh, introduce an owner-controlled cache with invalidation.
Do not patch this by guessing that changing lazy's thread-safety mode makes it refresh. Synchronization concerns initialization and publication, not the relationship between the cached value and mutable input.
A variation tests failure: the first initializer attempt reads unavailable configuration and throws; the second attempt succeeds after configuration becomes available. That can be useful, but an initializer that performs a side effect before throwing may repeat that side effect on retry. Successful memoization is not an exactly-once side-effect guarantee.
How Do SYNCHRONIZED, PUBLICATION, and NONE Differ?
Official fact: LazyThreadSafetyMode defines synchronization and publication rules. Its documentation notes that platforms without the relevant thread/synchronization model, such as JS and Wasm, treat the modes equivalently. The following discussion concerns JVM-style multithreaded access. LazyThreadSafetyMode API
| Mode | Initialization reasoning | Original review question |
|---|---|---|
SYNCHRONIZED | Initialization is protected by a lock and the initialized value is visible to other threads | Can initialization safely occur on whichever thread accesses first? |
PUBLICATION | Concurrent attempts can compute several values, while one value is retained and published | Is repeated initialization harmless? |
NONE | No thread-safety guarantee for concurrent access | What enforces thread confinement? |
Official fact: PUBLICATION permits multiple initializer calls under concurrent first access but exposes one computed value. PUBLICATION API
Imagine an initializer calls registerListener() and returns a listener handle. Two racing calls can both register listeners even if only one handle becomes the retained value. Choosing PUBLICATION to “avoid the lock” can therefore create an ownership problem: who unregisters the discarded handle?
This is an engineering counterexample, not a statement that every runtime will execute the initializer exactly twice. A concurrency test should permit multiple attempts and check the invariant rather than assert a fixed print order or winner.
SYNCHRONIZED does not make the returned object thread-safe. If the lazy value is a mutable list, later list operations still need a concurrency policy. NONE can be reasonable for a genuinely confined object, but “usually accessed on the main thread” is weaker than a documented enforcement mechanism.
For expensive initialization, measure contention and first-access latency before changing modes. The correctness requirement is whether repeated initialization and publication are acceptable; benchmark results do not establish that requirement.
Does observable Run Before or After Assignment?
Official fact: Delegates.observable invokes its callback after the property value changes. Kotlin's language guide describes the handler as running on each assignment. It does not promise equality-based deduplication. observable API, delegated-properties guide
Trace this original example:
import kotlin.properties.Delegates
class Cart {
val events = mutableListOf<String>()
var quantity: Int by Delegates.observable(1) { _, old, new ->
events += "$old->$new; stored=$quantity"
}
}
val cart = Cart()
cart.quantity = 2
cart.quantity = 2
Worked inference: the event strings are "1->2; stored=2" and "2->2; stored=2". The property already contains the new value when the callback reads it. Reassigning 2 is still an assignment.
If the callback should publish only meaningful changes, define an equality rule explicitly. Structural equality, reference identity, and a domain version number are different choices. A list whose contents mutate may remain the same reference while representing new business state.
An observable delegate is also not automatically a reactive UI state mechanism. Framework integration may require a framework-specific state holder or stream. Calling a standard-library callback does not itself prove that a screen will re-render.
Can observable Reject Invalid State or Observe List Mutation?
Suppose quantity = -1 reaches a callback that throws. Because observable runs after assignment, throwing is not a validation strategy that guarantees restoration of the old value. Validate before assignment or choose a deliberate setter contract.
Official fact: Delegates.vetoable calls its predicate before the change and uses its Boolean result to accept or reject the proposed value. vetoable API
For a nonnegative quantity, a veto predicate can preserve the old value on a negative proposal. But silent rejection may be wrong for an API whose caller needs a validation error. Compare “assignment rejected,” “exception reported,” and “explicit result returned” as product behaviors before selecting a delegate.
Now replace quantity with var items by Delegates.observable(mutableListOf<String>()) { ... }. Calling items.add("book") mutates the returned list. It does not invoke the property's setter, so the observable delegate does not receive that as a new property assignment.
An immutable-list approach can make changes explicit through items = items + "book", though it still needs a concurrency and performance review. Alternatively, a collection wrapper can own mutation events. Choose one authoritative mutation boundary rather than relying on property syntax to intercept every change inside an object.
Callbacks can also reenter a setter. If a handler normalizes a value by assigning to the same observable property, it can trigger another callback or recurse indefinitely. Normalize before the assignment, or prove that a guard terminates the sequence. A local callback is not a transaction across multiple properties.
How Can Two Owners Accidentally Share One Value?
Official fact: ReadWriteProperty defines a reusable delegate interface with getter and setter operations. Where its backing state resides is an implementation choice. ReadWriteProperty API
This original delegate stores a single integer:
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class CounterDelegate : ReadWriteProperty<Any?, Int> {
private var value = 0
override fun getValue(thisRef: Any?, property: KProperty<*>): Int = value
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
this.value = value
}
}
val sharedCounter = CounterDelegate()
class Worker {
var count: Int by sharedCounter
}
Create workers a and b, then assign a.count = 7. Reading b.count yields 7 too: both accessors use the same delegate field. The thisRef parameter is available, but this implementation does not use it to distinguish owners.
Changing the property to var count: Int by CounterDelegate() creates a new delegate for each ordinary Worker construction. That is the simplest fix if counts should be independent.
A shared delegate could instead index values by owner, but that adds lifetime questions. Strongly retained owner keys can keep objects alive; cleanup, identity semantics, and concurrent access become part of the implementation. Do not add that complexity when per-instance state is sufficient.
This is a useful interview distinction: passing an owner into getValue does not automatically create owner-specific storage. The code must implement that policy.
How Would You Test the State Contract?
Use assertions that match the intended ownership and lifecycle. For the session example, test an account change before first access and another after it. For lazy failure, count attempts while allowing retry after the documented failure. For observable, test repeated equal assignment and mutation of a referenced collection separately.
For the shared-counter exercise, assign two different counts to two owners and check both afterward. A single-owner happy-path test cannot reveal cross-owner leakage.
Official guidance: Kotlin's concurrency documentation distinguishes atomic operations, locking and thread confinement for shared mutable state. Adding a synchronized lazy initializer does not replace these techniques for subsequent mutations. Shared mutable state and concurrency
A strong explanation states the invariant first: “Each worker owns an independent count,” or “This configuration is computed once per immutable session.” Then choose the delegate and checks that enforce it. Naming the pattern without the invariant makes the code harder to review.