Skip to main content
kotlin in depth advanced patterns for java engineers

Property Delegation and Class Delegation Patterns

9 min read Chapter 14 of 21

Property Delegation and Class Delegation Patterns

Property Delegation Chain

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.