Property Delegation and Class Delegation Patterns
Property Delegation and Class Delegation Patterns
The lazy Delegate in Depth
You’ve used by lazy before. What you may not have examined are its three thread safety modes, each with different performance and correctness characteristics.
val config: Config by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
loadConfigFromDisk() // expensive, called at most once
}
The lazy function returns a Lazy<T> instance — an interface with two members:
public interface Lazy<out T> {
public val value: T
public fun isInitialized(): Boolean
}
When the compiler encounters val x by lazy { ... }, it generates a getValue call that reads Lazy.value. The first access triggers the initializer lambda; subsequent accesses return the cached result.
Thread Safety Modes
SYNCHRONIZED (default): The initializer runs under a lock. Only one thread can execute the lambda; all other threads block until the value is available. This is the safe default for shared state:
// Thread-safe singleton pattern
val instance: ExpensiveService by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
ExpensiveService.create()
}
Under the hood, this uses synchronized(lock) with double-checked locking — the same pattern you’d write manually in Java:
// Java equivalent of lazy(SYNCHRONIZED)
private volatile ExpensiveService instance;
public ExpensiveService getInstance() {
ExpensiveService result = instance;
if (result == null) {
synchronized (this) {
result = instance;
if (result == null) {
instance = result = ExpensiveService.create();
}
}
}
return result;
}
Seventeen lines of tricky concurrent code, replaced by one by lazy declaration.
PUBLICATION: Multiple threads can execute the initializer concurrently, but only the first result to complete gets stored. Subsequent accesses return that stored value, and results from other threads are discarded. Use this when the initializer is idempotent and you want to avoid lock contention:
val parser: JsonParser by lazy(LazyThreadSafetyMode.PUBLICATION) {
JsonParser.builder().build() // safe to create multiple times
}
This mode uses AtomicReference.compareAndSet() instead of synchronized. The initializer might run more than once, but value is stable once set.
NONE: No synchronization at all. Use this exclusively in single-threaded contexts (Android UI thread, test code, or properties you know are accessed from one thread):
// Android Activity — always on main thread
val binding: ActivityMainBinding by lazy(LazyThreadSafetyMode.NONE) {
ActivityMainBinding.inflate(layoutInflater)
}
NONE avoids even the cost of a volatile read. If you access it from multiple threads, you get undefined behavior — not an exception, just silent corruption.
Checking Initialization
The isInitialized() function lets you inspect whether value has been computed without triggering initialization:
val heavyResource: HeavyResource by lazy { HeavyResource.load() }
fun cleanup() {
if ((::heavyResource as Lazy<*>).isInitialized()) {
heavyResource.close()
}
}
Observable and Vetoable Delegates
Delegates.observable
This delegate executes a callback after every property change. It’s Kotlin’s answer to Java’s PropertyChangeSupport:
var status: String by Delegates.observable("idle") { prop, old, new ->
println("${prop.name}: $old -> $new")
notifyListeners(prop.name, old, new)
}
In Java, the equivalent pattern requires significant infrastructure:
public class StatusHolder {
private final PropertyChangeSupport pcs = new PropertyChangeSupport(this);
private String status = "idle";
public void addPropertyChangeListener(PropertyChangeListener listener) {
pcs.addPropertyChangeListener(listener);
}
public void setStatus(String newStatus) {
String oldStatus = this.status;
this.status = newStatus;
pcs.firePropertyChange("status", oldStatus, newStatus);
}
}
The Kotlin version collapses the support object, listener registration plumbing, and event firing into a single delegate declaration.
Delegates.vetoable
vetoable runs a predicate before the value changes. Return false to reject the new value:
var age: Int by Delegates.vetoable(0) { _, _, newValue ->
newValue in 0..150 // reject unreasonable ages
}
age = 25 // accepted
age = -5 // rejected, age remains 25
age = 200 // rejected, age remains 25
This gives you property-level validation without wrapping every setter in if checks. You can combine observable and vetoable by chaining custom delegates (more on that below).
Map-Backed Properties
When property names align with map keys, you can delegate directly to a Map or MutableMap:
class ServerConfig(private val properties: Map<String, Any?>) {
val host: String by properties
val port: Int by properties
val debugMode: Boolean by properties
}
The delegate uses the property name as the map key. host reads properties["host"], port reads properties["port"], and so on. This works because the Kotlin standard library defines getValue extensions on Map<String, V>.
This pattern is extremely effective for bridging untyped data sources to typed Kotlin objects:
// Parse environment variables into a typed config
val envConfig = ServerConfig(mapOf(
"host" to System.getenv("SERVER_HOST"),
"port" to System.getenv("SERVER_PORT").toInt(),
"debugMode" to (System.getenv("DEBUG") == "true")
))
For mutable configurations, delegate to a MutableMap:
class MutableConfig(private val map: MutableMap<String, Any?>) {
var host: String by map
var port: Int by map
}
Building Custom Delegates
The standard delegates cover common cases, but the real power lies in writing your own. A property delegate is any object with the correct getValue/setValue operator functions.
Example: Cached Delegate with TTL
A property that recomputes its value after a time-to-live expires:
class CachedDelegate<T>(
private val ttlMillis: Long,
private val compute: () -> T
) : ReadOnlyProperty<Any?, T> {
private var cachedValue: T? = null
private var lastComputed: Long = 0
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
val now = System.currentTimeMillis()
if (cachedValue == null || (now - lastComputed) > ttlMillis) {
cachedValue = compute()
lastComputed = now
}
@Suppress("UNCHECKED_CAST")
return cachedValue as T
}
}
fun <T> cached(ttlMillis: Long, compute: () -> T) = CachedDelegate(ttlMillis, compute)
Usage:
class PricingService(private val api: ExternalApi) {
val exchangeRates: Map<String, Double> by cached(ttlMillis = 60_000) {
api.fetchExchangeRates() // re-fetched every 60 seconds
}
}
Example: Validated Delegate
A delegate that enforces constraints on every write:
class Validated<T>(
initialValue: T,
private val validator: (T) -> Boolean,
private val errorMessage: (T) -> String = { "Validation failed for value: $it" }
) : ReadWriteProperty<Any?, T> {
private var value: T = initialValue
init {
require(validator(initialValue)) { errorMessage(initialValue) }
}
override fun getValue(thisRef: Any?, property: KProperty<*>): T = value
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
require(validator(value)) { errorMessage(value) }
this.value = value
}
}
fun <T> validated(initial: T, message: (T) -> String = { "Invalid: $it" }, check: (T) -> Boolean) =
Validated(initial, check, message)
Usage:
class UserProfile {
var email: String by validated("[email protected]", { "Invalid email: $it" }) {
it.contains("@") && it.contains(".")
}
var age: Int by validated(18, { "Age out of range: $it" }) {
it in 0..150
}
}
Unlike Java’s Bean Validation (@javax.validation.constraints), this validation runs on every set, not just when a framework decides to validate. There’s no annotation scanning, no proxy, no separate validation step.
Example: Database Column Delegate
A delegate that reads and writes values to a database row:
class ColumnDelegate<T>(
private val columnName: String,
private val fromDb: (Any?) -> T,
private val toDb: (T) -> Any?
) : ReadWriteProperty<Entity, T> {
override fun getValue(thisRef: Entity, property: KProperty<*>): T {
val raw = thisRef.row[columnName]
return fromDb(raw)
}
override fun setValue(thisRef: Entity, property: KProperty<*>, value: T) {
thisRef.row[columnName] = toDb(value)
thisRef.markDirty(columnName)
}
}
abstract class Entity {
internal val row: MutableMap<String, Any?> = mutableMapOf()
internal val dirtyColumns: MutableSet<String> = mutableSetOf()
internal fun markDirty(column: String) { dirtyColumns.add(column) }
}
class UserEntity : Entity() {
var name: String by ColumnDelegate("user_name", { it as String }, { it })
var age: Int by ColumnDelegate("user_age", { (it as Number).toInt() }, { it })
var active: Boolean by ColumnDelegate("is_active", { it as Boolean }, { it })
}
The thisRef: Entity parameter is typed — the delegate knows it’s attached to an Entity subclass and can access the row data directly. Java’s JPA achieves something similar, but through runtime bytecode manipulation (ByteBuddy or cglib proxies), class-level annotations, and an entity manager. Here, the mechanism is explicit and compile-time verified.
Class Delegation Patterns
The Decorator Pattern
Adding cross-cutting behavior to any interface implementation:
class LoggingRepository<T>(
private val delegate: Repository<T>,
private val logger: Logger
) : Repository<T> by delegate {
override fun save(entity: T): T {
logger.info("Saving entity: $entity")
return delegate.save(entity).also {
logger.info("Saved successfully, result: $it")
}
}
override fun delete(id: String) {
logger.warn("Deleting entity with id: $id")
delegate.delete(id)
}
}
Every Repository<T> method routes to delegate by default. You only override the methods where you want logging. In Java, this pattern requires either implementing every interface method manually or using java.lang.reflect.Proxy — which brings runtime overhead and loses compile-time type safety.
The Adapter Pattern
Adapting one interface to another by delegating the common parts:
interface ModernCache<K, V> {
fun get(key: K): V?
fun put(key: K, value: V)
fun invalidate(key: K)
fun invalidateAll()
fun size(): Int
}
class LegacyCacheAdapter<K, V>(
private val legacy: java.util.Map<K, V>
) : ModernCache<K, V> {
override fun get(key: K): V? = legacy.get(key)
override fun put(key: K, value: V) { legacy.put(key, value) }
override fun invalidate(key: K) { legacy.remove(key) }
override fun invalidateAll() { legacy.clear() }
override fun size(): Int = legacy.size()
}
When both sides share a common interface, class delegation handles the forwarding and you implement only the bridging methods.
Combining Delegation with Extension Properties
You can use delegation to create layered behavior. Here’s a read-through cache that sits in front of a data source:
interface DataSource {
fun fetch(key: String): String?
fun store(key: String, value: String)
fun keys(): Set<String>
}
class CachingDataSource(
private val backing: DataSource,
private val cache: MutableMap<String, String> = mutableMapOf()
) : DataSource by backing {
override fun fetch(key: String): String? {
return cache.getOrPut(key) {
backing.fetch(key) ?: return null
}
}
override fun store(key: String, value: String) {
cache[key] = value
backing.store(key, value)
}
}
keys() and any other DataSource methods pass through to backing untouched. fetch and store add caching logic. You compose behaviors by wrapping delegates around delegates — each layer adds one concern, with zero inheritance.
The pattern scales cleanly:
val dataSource: DataSource = CachingDataSource(
RetryingDataSource(
LoggingDataSource(
DatabaseDataSource(connectionPool)
)
)
)
Each wrapper delegates everything it doesn’t override. In Java, achieving this composition without inheritance would require either Guava’s ForwardingObject pattern (verbose), AOP proxies (implicit), or manual forwarding (tedious). Kotlin gives you a one-keyword solution that’s explicit, compile-time verified, and has zero runtime overhead compared to hand-written code.