Delegated properties in Kotlin: lazy and observable

1. Overview

Kotlin lets you delegate reading and writing a property to another object through the by keyword. This mechanism is called delegated properties. The standard library provides several delegates that are used very frequently. The most common are lazy for lazy initialization, observable to listen for changes, and vetoable to block invalid changes.

After reading this article, you’ll know how to use each delegate and choose the right thread-safety mode for lazy. You’ll also learn how to write tests for them and situations to avoid.

2. Setup

The delegates in this article are already in kotlin-stdlib, so you don’t need to add any libraries. To write tests, add JUnit 5 and AssertJ:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.3</version>
    <scope>test</scope>
</dependency>

If you’re using Spring Boot 3.x, spring-boot-starter-test already includes both of these libraries.

3. How delegated properties work

When you write val x: T by someDelegate, the compiler generates a hidden field to hold the delegate. The getter of x calls someDelegate.getValue(...). If x is a var, the setter calls setValue(...).

You can write a simple delegate yourself to see how it works:

import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty

class TrimmedString(private var value: String = "") : ReadWriteProperty<Any?, String> {
    override fun getValue(thisRef: Any?, property: KProperty<*>): String = value

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        this.value = value.trim()
    }
}

class Customer {
    var name: String by TrimmedString()
}

fun main() {
    val customer = Customer()
    customer.name = "   Nguyễn Văn An  "
    println("[${customer.name}]")
}

Let me explain each part:

  • TrimmedString implements ReadWriteProperty, which has two functions: getValue and setValue.
  • Each time you assign customer.name, Kotlin calls setValue, and the string is trim()ed before storing.
  • The property parameter tells you the name of the property being accessed. observable uses this parameter to tell you which property just changed.
[Nguyễn Văn An]

You rarely need to write a delegate yourself, since the standard library already has the most commonly used ones.

4. Lazy initialization with by lazy

4.1. Basic usage

lazy takes a lambda. The lambda runs only the first time you read the property. The result is cached for all subsequent reads.

class ReportService {
    val exchangeRates: Map<String, Double> by lazy {
        println("Loading exchange rates...")
        mapOf("USD" to 25_400.0, "EUR" to 27_300.0)
    }
}

fun main() {
    val service = ReportService()
    println("Service created")
    println(service.exchangeRates["USD"])
    println(service.exchangeRates["EUR"])
}

Result:

Service created
Loading exchange rates...
25400.0
27300.0

There are three things to note:

  • Creating ReportService doesn’t trigger loading the exchange rates.
  • The line “Loading exchange rates…” prints only once, even though the property is read twice.
  • lazy only works with val. Writing var x by lazy { ... } causes a compilation error because Lazy doesn’t have setValue.

lazy is suitable for values that are expensive to create but not always needed. Examples: a complex regex, a configuration read from a file, or a client that calls an external service.

4.2. Thread-safety modes

By default, lazy uses LazyThreadSafetyMode.SYNCHRONIZED. This mode ensures the lambda runs exactly once, even if multiple threads access it. You can change the mode if needed:

class PriceCalculator {
    // Only one thread runs the initializer, other threads wait
    val taxTable: Map<String, Double> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
        mapOf("VN" to 0.1, "SG" to 0.09)
    }

    // Multiple threads can run the initializer, the first value is kept
    val discountRules: List<String> by lazy(LazyThreadSafetyMode.PUBLICATION) {
        listOf("BLACK_FRIDAY", "TET_SALE")
    }

    // No synchronization, only use if sure the property is accessed from a single thread
    val formatter: java.text.DecimalFormat by lazy(LazyThreadSafetyMode.NONE) {
        java.text.DecimalFormat("#,###")
    }
}

fun main() {
    val calculator = PriceCalculator()
    println(calculator.taxTable["VN"])
    println(calculator.discountRules.first())
    println(calculator.formatter.format(1_250_000))
}
0.1
BLACK_FRIDAY
1,250,000

You can choose the mode according to the table below:

Mode Initializer runs When to use
SYNCHRONIZED Exactly once Default, safe for most cases
PUBLICATION Possibly multiple times, first value is kept Cheap initializer, no side effects, want to avoid lock
NONE Not guaranteed Code runs only on a single thread (UI thread, internal loop)

Note: NONE is slightly faster because there’s no lock. If you use it in a Spring singleton bean accessed by multiple requests in parallel, the initializer may run multiple times. Then different threads may see different instances. Don’t change the mode just to “optimize” without profiling.

4.3. When the initializer throws an exception

If the lambda throws an exception, the value is not cached. The next read will run the lambda again:

class ConfigLoader {
    var attempts = 0

    val apiKey: String by lazy {
        attempts++
        if (attempts < 2) error("Failed to read config file")
        "secret-key-123"
    }
}

fun main() {
    val loader = ConfigLoader()
    runCatching { loader.apiKey }.onFailure { println("Error: ${it.message}") }
    println(loader.apiKey)
    println("Attempts: ${loader.attempts}")
}
Error: Failed to read config file
secret-key-123
Attempts: 2

This behavior is helpful when the error is temporary. But if the initializer is very expensive and the error persists, each access will incur that cost again.

4.4. Checking if initialization has occurred

If you need to know whether the value has been created, keep the Lazy object in a separate variable and delegate to it:

class CacheHolder {
    private val productCacheDelegate = lazy { mutableMapOf<String, Int>() }
    val productCache: MutableMap<String, Int> by productCacheDelegate

    fun isCacheReady(): Boolean = productCacheDelegate.isInitialized()
}

fun main() {
    val holder = CacheHolder()
    println(holder.isCacheReady())
    holder.productCache["SKU-1"] = 10
    println(holder.isCacheReady())
}
false
true

This approach is useful when you want to free resources during shutdown, but only if the resource has actually been created.

4.5. lazy or lateinit?

These two approaches are often confused:

  • lazy: use with val. The value is calculated by the property itself, on the first read.
  • lateinit: use with var and non-null types, doesn’t work with primitives like Int. The value is assigned from outside later, for example via dependency injection or a @BeforeEach function in tests.

If you know how to calculate the value within the class, use lazy. If the value is “provided” from outside, use lateinit.

5. Tracking changes with Delegates.observable

Delegates.observable takes an initial value and a callback. The callback runs after the new value has been assigned.

import kotlin.properties.Delegates

enum class OrderStatus { NEW, PAID, SHIPPED, DELIVERED }

class Order(val id: String) {
    val history = mutableListOf<String>()

    var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { property, old, new ->
        history += "${property.name}: $old -> $new"
    }
}

fun main() {
    val order = Order("ORD-001")
    order.status = OrderStatus.PAID
    order.status = OrderStatus.SHIPPED
    order.status = OrderStatus.SHIPPED
    order.history.forEach(::println)
}
status: NEW -> PAID
status: PAID -> SHIPPED
status: SHIPPED -> SHIPPED

Explanation:

  • OrderStatus.NEW is the initial value. Assigning this value doesn’t trigger the callback.
  • The callback receives three parameters: property (metadata), old (old value), and new (new value).
  • The third line shows that the callback still runs when you assign the same value again.

If you only want to react when the value actually changes, filter it yourself in the callback:

var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { _, old, new ->
    if (old != new) history += "$old -> $new"
}

observable is typically used to write audit logs, mark an object as “dirty” needing to be saved, or update a derived value.

6. Blocking invalid values with Delegates.vetoable

vetoable is similar to observable, but the callback runs before assignment and returns a Boolean. If the callback returns false, the old value is kept.

import kotlin.properties.Delegates

class CartItem(val sku: String) {
    var quantity: Int by Delegates.vetoable(1) { _, _, new ->
        new in 1..99
    }
}

fun main() {
    val item = CartItem("SKU-42")
    item.quantity = 5
    println(item.quantity)
    item.quantity = 0
    println(item.quantity)
    item.quantity = 150
    println(item.quantity)
}
5
5
5

The assignments of 0 and 150 are rejected silently, and quantity remains 5.

Note: Since vetoable doesn’t throw an exception, the calling code won’t know the assignment failed. For important business data, like payment amounts, you should use a custom setter with require(...) to expose the error clearly. vetoable is suitable for cases where ignoring invalid values is the desired behavior, like limiting values from a slider on the UI.

7. Writing tests for delegates

The test section below checks three main behaviors: lazy initializes only once, observable records history, and vetoable rejects invalid values.

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger

class DelegatedPropertiesTest {

    class ExpensiveHolder(private val counter: AtomicInteger) {
        val value: String by lazy {
            counter.incrementAndGet()
            Thread.sleep(50)
            "ready"
        }
    }

    @Test
    fun `lazy runs the initializer only once even with multiple threads accessing`() {
        val counter = AtomicInteger()
        val holder = ExpensiveHolder(counter)
        val executor = Executors.newFixedThreadPool(8)

        repeat(8) { executor.submit { holder.value } }
        executor.shutdown()
        executor.awaitTermination(5, TimeUnit.SECONDS)

        assertThat(holder.value).isEqualTo("ready")
        assertThat(counter.get()).isEqualTo(1)
    }

    @Test
    fun `observable records every state change`() {
        val order = Order("ORD-002")

        order.status = OrderStatus.PAID
        order.status = OrderStatus.DELIVERED

        assertThat(order.history).containsExactly(
            "status: NEW -> PAID",
            "status: PAID -> DELIVERED"
        )
    }

    @Test
    fun `vetoable keeps the old value when rejected`() {
        val item = CartItem("SKU-7")

        item.quantity = 10
        item.quantity = -3

        assertThat(item.quantity).isEqualTo(10)
    }
}

The first test uses 8 threads reading value concurrently. With the default mode SYNCHRONIZED, counter is always 1. If you switch to LazyThreadSafetyMode.NONE and run it again several times, the test will sometimes fail. That’s a quick way to verify the difference between the modes yourself.

8. Common mistakes and when not to use

Using delegates in JPA entities. The actual field that the compiler generates is status$delegate, which has type ObservableProperty, not OrderStatus. Hibernate can’t map such a field naturally. For entities, use a regular field with a custom setter or entity listener.

The lazy lambda holds references. The lambda is retained until it runs. If it captures a large object and the property is never read, that object is still held in memory.

Reading lazy in init block too early. If the lambda uses a property declared below, and you read the lazy property right in init, you might get an uninitialized value, like null or 0.

Replacing framework mechanisms. In Spring, don’t use by lazy to create beans yourself or manage the lifecycle of a connection pool. Use @Lazy or ObjectProvider so the container can still manage resource cleanup.

Heavy side effects in observable. The callback runs synchronously right in the setter. Calling HTTP or writing to a database in the callback makes a simple assignment slow and error-prone. Fire an event instead of doing heavy work right there.

Confusing observable as thread-safe. observable and vetoable have no synchronization mechanism. If multiple threads assign concurrently, the callback might receive an incorrect old value.

9. Conclusion

Delegated properties help you separate property access logic from the class, making code more concise and readable. Use by lazy for expensive-to-create values, and keep the default SYNCHRONIZED mode unless there’s a clear reason to change it. Use Delegates.observable when you need to react after each change, and remember the callback still runs when the value doesn’t change. Use Delegates.vetoable when you want to silently ignore invalid values, but when you need to report errors clearly, switch to require in a custom setter.

Once you understand getValue and setValue, you can completely write your own delegate for custom needs like normalizing strings, reading values from a Map, or logging access. You just need to avoid using them where the framework needs to see the real field, like in JPA entities.

kotlindelegated propertieslazyobservabledelegates