1. Introduction
When you first see Delegates.observable, a natural question comes up: “Why do I need some magic with by if I can just write set(value) and check/log everything there?” Fair question. The honest answer: observable is useful when you need to react to assignments in a centralized way and avoid smearing the same code across multiple methods or setters—for example, for logging, counting changes, setting an “object changed” flag.
It is important to set expectations right away: this is an overview lecture. We will not write our own delegates and we will not dive into advanced topics: how getValue/setValue are implemented, reflection, and multithreading subtleties. We will learn to confidently use Delegates.observable as a ready-made tool from the standard library.
What by means in properties
The syntax of a delegated property looks like this: val/var name: Type by <expression>. You can read it roughly as: “reading and (if var) writing this property is handled by another object—the delegate.” In the Kotlin documentation, this is explained as property accessors being “forwarded” to the delegate methods (getValue() and setValue()).
If you recall the previous lecture, by lazy { ... } is also a delegate (it is just the most popular one, so it can seem “special”). Delegates.observable is a delegate of a different kind: it stores the value and calls a handler after each assignment.
So it does not feel like we are “suddenly” jumping into new syntax, here is a small guide:
| Mechanism | What it solves | When code runs | Is there caching? |
|---|---|---|---|
|
Write control (validation/normalization) | Every time on assignment | Depends on you |
computed property () |
Computing a representation | Every time on read | No |
|
Lazy computation | First read access | Yes (built-in) |
|
Reaction to changes | Every time on assignment (after the write) | Yes (the delegate stores the current value) |
2. How Delegates.observable works
Basic contract and when the handler fires
Delegates.observable takes an initial value and a change handler. The handler is called every time you assign to the property, and there is an important nuance: it is called after the assignment has already happened.
The handler has three parameters: “property description”, the old value, and the new value. In code, it usually looks like this:
import kotlin.properties.Delegates
class User {
var name: String by Delegates.observable("<no name>") { _, old, new ->
println("$old -> $new") // <no name> -> Alice, then Alice -> Bob
}
}
fun main() {
val u = User()
u.name = "Alice"
u.name = "Bob"
}
Notice: we replaced the first handler parameter with _. This is normal practice—if the parameter is not needed, do not get in your own way when reading the code.
Two important consequences of “after assignment”.
First, inside the handler you can read the property itself and you will get the new value (it is already stored). This is convenient, but it can be confusing if you expected “notify first, then write”.
Second, observable is not the place where you “stop” an invalid value. If you need to block an assignment, that is a different tool (and we are not covering it today to avoid spreading across topics).
The first handler parameter and why you sometimes need it
The first handler parameter is an object that describes the property (essentially: “metadata about the property”). The documentation shows that this parameter comes into the handler together with old/new.
We have not studied reflection deeply yet (and we do not need to), but we can take one practical thing: a property has a name. Sometimes this is handy for a debug log, especially if you have multiple observable properties and want a consistent message style.
Let’s make the wallet a bit more talkative:
import kotlin.properties.Delegates
class Wallet {
var balanceCents: Int by Delegates.observable(0) { prop, old, new ->
println("${prop.name}: $old -> $new") // balanceCents: 0 -> 100
}
private set
fun deposit(cents: Int) {
require(cents > 0) { "deposit must be > 0" }
balanceCents += cents
}
}
Yes, it looks like “a bit of magic”. But in practice you treat prop.name as a neat bonus: if you want—use it; if not—put _.
A quick look under the hood: why prop$delegate exists
A mini-overview for understanding, without diving into writing your own delegates. When delegating, Kotlin usually generates a hidden field that stores the delegate, and the property accessors simply call the delegate’s methods. The documentation demonstrates this with an example where a field like prop$delegate appears.
If we oversimplify, you can imagine it like this:
obj.prop = value
↓
delegate.setValue(obj, property_description, value)
↓
delegate writes the new value
↓
delegate calls the observable handler (old/new)
That is why observable looks “like it is built into the language”: in reality it is just a ready-made delegate from the standard library, and the by syntax is language support for delegation.
It is important not to overdo it: understanding the general idea is useful, but “how to write your own delegate” is a separate topic, and we are not going there today.
3. Practice: where observable helps, and where it gets in the way
Mini budget tracker: logging balance changes
So the examples are not “in a vacuum”, let’s continue with a hypothetical app: a simple budget tracker (wallet) with a balance and deposit/withdraw operations. We already know how to encapsulate state: the balance cannot be changed directly, only through methods that maintain invariants.
Let’s make a Wallet class and add a reaction to balance changes: we will print a change log to the console. This is a typical observable scenario: observing assignments, without trying to replace business logic.
import kotlin.properties.Delegates
class Wallet {
var balanceCents: Int by Delegates.observable(0) { _, old, new ->
println("balance: $old -> $new") // e.g.: balance: 0 -> 500
}
private set
fun deposit(cents: Int) {
require(cents > 0) { "deposit must be > 0" }
balanceCents += cents
}
}
And let’s test it in main:
fun main() {
val w = Wallet()
w.deposit(500) // balance: 0 -> 500
w.deposit(250) // balance: 500 -> 750
}
Two useful things happen here. First, we preserved a correct design: outsiders cannot change the balance (private set). Second, the logging logic “sticks” to the property itself and is not duplicated in every method.
observable and encapsulation: where rules and checks should live
The most common beginner mistake is to see observable, get excited, and start stuffing all business logic into it: “if the new value is bad, roll it back”, “if the balance goes negative—fix it”, and so on. The problem is that observable is a reaction after assignment. That means you have already stored the value, and only then you start “fixing something”.
It is better to keep rules where you designed them before: in methods that change state, and/or in setters with require(...). And keep observable for side effects: logging, a change counter, setting a “dirty” flag.
For example, let’s make deposit and withdraw methods and forbid going negative. And in observable we will only mark that the state has changed.
import kotlin.properties.Delegates
class Wallet {
var isDirty: Boolean = false
private set
var balanceCents: Int by Delegates.observable(0) { _, old, new ->
if (old != new) isDirty = true
}
private set
fun withdraw(cents: Int) {
require(cents > 0) { "withdraw must be > 0" }
require(balanceCents >= cents) { "not enough money" }
balanceCents -= cents
}
}
And a small run:
fun main() {
val w = Wallet()
println(w.isDirty) // false
w.withdraw(0) // IllegalArgumentException (withdraw must be > 0)
}
The key idea here: we do not “treat consequences”, we prevent them. observable only records the fact of a change; it does not “drive” correctness.
observable vs a regular setter: what to choose
observable is not a replacement for setters. It is a different tool.
If your task is to normalize a string (trim, case conversion), validate a range, maintain an invariant, then a setter (or a method) is usually clearer and more straightforward. If your task is to react to changes (log, count changes, notify), observable often makes the code more compact.
Imagine that in our app we have a “notification volume level”, and we want to: store a value from 0 to 10, and print a log on change. Let the rules live in a method, and the log live in observable.
import kotlin.properties.Delegates
class NotificationSettings {
var volume: Int by Delegates.observable(5) { _, old, new ->
println("volume: $old -> $new")
}
private set
fun setVolume(x: Int) {
require(x in 0..10) { "volume must be in 0..10" }
volume = x
}
}
Now the “class contract” reads nicely: setVolume is the only door for changing the value, volume can only be read, and logging is centralized.
observable in real life: a change counter
In large projects, observable is often associated with UI: “when a field changes—update the screen”. But even in a console app it can be handy.
For example, you want to count the number of balance changes so that at the end you can show: “there were N financial operations in this session”. Yes, you can increment a counter in every method. But if operations spread out in the future (discounts, transfers, fees), you will forget to update the counter somewhere—and then it becomes “why are the stats lying”.
With observable, you can tie the counter to the fact that the property changed:
import kotlin.properties.Delegates
class Wallet {
var changeCount: Int = 0
private set
var balanceCents: Int by Delegates.observable(0) { _, old, new ->
if (old != new) changeCount++
}
private set
fun deposit(cents: Int) {
require(cents > 0) { "deposit must be > 0" }
balanceCents += cents
}
}
Check:
fun main() {
val w = Wallet()
w.deposit(100)
w.deposit(100)
println(w.changeCount) // 2
}
Yes, this is still a side effect. But it is localized and understandable: “every balance change increments the counter”.
4. Common mistakes when working with Delegates.observable
Mistake No. 1: placing business logic and correctness rules into the observable handler.
It is very easy to start doing everything in the handler: checking ranges, rolling back values, “fixing” the object. The problem is that the handler is called after assignment, which means you are working with already-changed state. It is much more reliable to keep invariants in methods (deposit/withdraw) or in a setter with require(...), and leave logging and simple reactions in observable.
Mistake No. 2: accidentally creating recursion—assigning to the observed property inside the handler.
If in the handler you do something like balanceCents = ..., you trigger an assignment again, then the handler again—and so on until StackOverflowError or until you start questioning reality. If you need to “fix” the value, it is usually a sign you need a different design (validate before assignment), not an attempt to patch what is already stored.
Mistake No. 3: forgetting that the handler is called even when assigning the same value.
observable reacts to the fact of assignment. If you do x = x, the handler may still run. So if you are counting changes or setting an isDirty flag, it is almost always useful to compare old and new and react only to a real change: if (old != new) ...
Mistake No. 4: turning observable into a hidden logger that is always noisy.
Debug println calls in the handler are convenient in learning examples, but in a real project such logging quickly turns into noise. Even in a learning app, it is better to gradually get used to: making logging switchable (at least via a flag), printing only important things, or limiting output.
Mistake No. 5: expecting that observable replaces encapsulation.
Sometimes it feels like: “well, if I observe changes, I can make a public var and let anyone change it, and I will control it in the handler”. That is a slippery slope. Observation does not replace access restriction. If a property must not be changed from the outside—use private set and change it through methods. And let observable be your neat “sensor”, not the only guard at the entrance.