Most state bugs I’ve chased in Android apps were not caused by a wrong value being computed. They were caused by a wrong value being accepted: a progress bar that reads 140%, a screen that slides from “Done” back to “Loading”, a connection that jumps straight from “Offline” to “Streaming”.
The usual cure is a hand-written setter full of if statements. It works, until the third property needs the same treatment and the setters start to look like a copy-paste museum. Kotlin ships a small, underused tool for exactly this: Delegates.vetoable().
The mental shift
Delegates.observable() and Delegates.vetoable() have nearly identical signatures, and that is why people file them in the same mental drawer. They answer different questions.
-
observable(): "The value changed. Let me react." -
vetoable(): "The value wants to change. Should I allow it?"
With observable(), the write has already happened by the time your lambda runs. You can log it, publish it, or write it back to something else, but you cannot stop it. With vetoable(), your lambda runs first and returns a Boolean. Return true and the new value is stored. Return false and the old value stays.
The smallest useful example
import kotlin.properties.Delegates class Transfer { var percent: Int by Delegates.vetoable(0) { _, _, proposed -> proposed in 0..100 } } fun main() { val transfer = Transfer() transfer.percent = 40 transfer.percent = 140 // vetoed println(transfer.percent) // 40 } The lambda receives three arguments: the KProperty<*>, the current value, and the proposed value. Here I only need the last one, so the first two are underscores.
That is the entire rule, expressed once, attached to the property it protects, and impossible to forget at a call site.
What actually happens on an assignment
Notice the right-hand branch. A vetoed write does not throw, does not return anything, and does not log anything. The caller’s code continues as if nothing special occurred. That design is deliberate, and it is also the source of the first failure mode below.
Rule one: keep numbers inside their range
Rejecting an out-of-range value and clamping it are two different policies, and both are legitimate. vetoable() implements rejection. If you want clamping, a custom setter or coerceIn at the call site is the honest tool.
For a transfer that must only move forward, add the old value to the check:
class Transfer { var percent: Int by Delegates.vetoable(0) { _, current, proposed -> proposed in 0..100 && proposed >= current } } Now a late, out-of-order progress callback from a worker thread cannot drag the bar backwards. That is a very common real-world glitch when two chunks report progress in the wrong order.
Rule two: stop UI stages from running backwards
A forward-only integer is easy. Enumerated stages need a map of what is legal from where.
enum class Stage { Idle, Connecting, Downloading, Verifying, Done, Failed } private val legalMoves: Map<Stage, Set<Stage>> = mapOf( Stage.Idle to setOf(Stage.Connecting), Stage.Connecting to setOf(Stage.Downloading, Stage.Failed), Stage.Downloading to setOf(Stage.Verifying, Stage.Failed), Stage.Verifying to setOf(Stage.Done, Stage.Failed), Stage.Done to emptySet(), Stage.Failed to setOf(Stage.Idle), ) class DownloadSession { var stage: Stage by Delegates.vetoable(Stage.Idle) { _, current, proposed -> proposed == current || proposed in legalMoves.getValue(current) } } Two details worth copying. First, proposed == current is allowed so that redundant writes are harmless instead of "illegal". Second, getValue fails loudly if someone adds an enum entry and forgets to give it a row in the map. Crashing in a test beats silently locking a new stage out.
Rule three: protect network transitions
The same shape guards connectivity. Imagine a socket wrapper that reports its own phase:
enum class Link { Offline, Handshaking, Online, Draining } class LiveSocket { var link: Link by Delegates.vetoable(Link.Offline) { _, current, proposed -> when (current) { Link.Offline -> proposed == Link.Handshaking Link.Handshaking -> proposed == Link.Online || proposed == Link.Offline Link.Online -> proposed == Link.Draining || proposed == Link.Offline Link.Draining -> proposed == Link.Offline } || proposed == current } } A stray Online write that arrives while the socket is Offline (say, from a stale callback after a reconnect) is refused instead of painting a green "connected" badge over a dead connection. Using when as an expression also gives you exhaustiveness checking on the sealed set of states.
Failure modes you will meet in production
vetoable() is small, so it is easy to trust it with more than it can do. These are the six places I would look first when a vetoable property misbehaves.
1. Silent rejection
Because a veto produces no signal, a bug in a producer can hide for weeks: the UI simply refuses to update and nobody knows why. Give writers a way to learn the outcome.
class DownloadSession { var stage: Stage by Delegates.vetoable(Stage.Idle) { _, current, proposed -> proposed == current || proposed in legalMoves.getValue(current) } fun advance(to: Stage): Boolean { stage = to val accepted = stage == to if (!accepted) println("Rejected $to while in $stage") // use your logger return accepted } } This reads the property back after the write. It is cheap and keeps the delegate in charge of the rule.
2. The check-then-set race
The delegate performs “read old value, run lambda, write new value” as separate steps. It is not atomic. Two threads can both see current == 50, both propose values that pass, and the last writer wins regardless of what the rule intended. Either confine every write to one thread (the main thread or a single-thread dispatcher), or serialize writers:
private val lock = Any() fun advance(to: Stage): Boolean = synchronized(lock) { stage = to stage == to } Readers on other threads still need a visibility story, and @Volatile is not allowed on delegated properties. If many threads genuinely share the state, move to an atomic primitive instead (see the next section).
3. The initial value is never vetted
The seed passed to vetoable(initial) skips the lambda entirely. vetoable(250) with a 0..100 rule happily starts at 250. Keep seeds as constants that satisfy your own rule, and cover it with a test.
4. Mutation that bypasses the setter
The delegate intercepts assignment. It does not see what happens inside the value.
class Playlist { var tracks: MutableList<String> by Delegates.vetoable(mutableListOf()) { _, _, proposed -> proposed.size <= 50 } } // Playlist().tracks.add("one more") // never reaches the veto Expose an immutable type and replace the whole value on change, so every change flows through the property:
var tracks: List<String> by Delegates.vetoable(emptyList()) { _, _, proposed -> proposed.size <= 50 } // tracks = tracks + "one more" // goes through the veto 5. Restoring saved state collides with the rules
On process death, a SavedStateHandle or a database row might hold Stage.Verifying. If restore code builds a fresh DownloadSession and assigns that value, the rule sees Idle -> Verifying and refuses it. The screen quietly resumes in the wrong place. Let construction take the saved value, or give restoration its own deliberate path:
class DownloadSession(restored: Stage = Stage.Idle) { var stage: Stage by Delegates.vetoable(restored) { _, current, proposed -> proposed == current || proposed in legalMoves.getValue(current) } } Note the trade-off: the seed is now an input, so failure mode 3 applies to it, and you should validate the restored value yourself.
6. A rule that is too strict for retries
A forward-only percentage means a retry can never start from zero, and a terminal Done stage means you can never re-run the job. When the rule is correct for normal operation but wrong for a reset, do not loosen it. Add an explicit, named path that rebuilds the state on purpose:
class Transfer { var percent: Int by Delegates.vetoable(0) { _, current, proposed -> proposed in 0..100 && proposed >= current } fun restart(): Transfer = Transfer() // new instance, fresh rule, explicit intent } Replacing the object keeps the guarded property honest while still letting the product do what it needs.
Side effects and exceptions inside the lambda
Keep the lambda pure. It can run on whatever thread performs the write, it runs before the value is committed, and anything it throws propagates to the caller of the setter. Doing I/O or posting events from inside it means you may announce a change that the lambda itself then rejects, or never confirm. Decide in the lambda, act afterwards.
Pair it with observable() when you want both: you cannot stack two delegates on one property, so the common pattern is to let vetoable guard the field and notify listeners from the function that wraps the write (like advance above) only when it returns true.
Where it stops being the right tool
- Compose state and
StateFlow.vetoable()keeps a plain property. It will not trigger recomposition by itself, and it does not emit. For UI state, put the rule in a reducer and let the flow carry the value:
class DownloadViewModel : ViewModel() { private val _stage = MutableStateFlow(Stage.Idle) val stage: StateFlow<Stage> = _stage.asStateFlow() fun advance(to: Stage): Boolean { var accepted = false _stage.update { current -> accepted = to in legalMoves.getValue(current) if (accepted) to else current } return accepted } } update retries its lambda if another thread wins the compare-and-set, so assign accepted on every run rather than only setting it to true. This version is also atomic, which fixes failure mode 2 for shared state.
- Data classes. Delegated properties must live in the class body, so they do not take part in
equals,copy, ortoStringthe way constructor properties do. - Rules that depend on other properties. A veto that needs three other fields is a sign the object wants a proper state machine or a single
reducefunction.
Testing the gate
Vetoes are the easiest kind of logic to test because the behaviour is observable from the outside:
class TransferTest { @Test fun `progress never moves backwards`() { val transfer = Transfer() transfer.percent = 60 transfer.percent = 40 assertEquals(60, transfer.percent) } @Test fun `progress rejects values above one hundred`() { val transfer = Transfer() transfer.percent = 140 assertEquals(0, transfer.percent) } } Add a test that walks every (from, to) pair of your enum against the expected set. When a new state is added, that test is the one that tells you to update the map.
🔚 Takeaways
-
observable()reports a change;vetoable()decides whether the change may happen. - Put invariants on the property that owns them, not in every call site.
- A veto is silent, so give writers an outcome (
Boolean) and log rejections. - The delegate is not atomic and does not see inside mutable values, so confine threads and prefer immutable types.
- Plan the reset, restore, and retry paths before the rule hardens, or it will reject the very thing the product needs.
A bouncer is only useful if the club also has a way to let the staff in. Write the rule once, on the property, and then build the side door deliberately.
📱 Go Beyond Using AI — Learn How to Actually Build It Into Your Android Apps
If you’re building Android apps in 2026, “adding AI” can’t just mean bolting on a chatbot API — it means understanding on-device inference, model trade-offs, and production realities well enough to ship a feature users actually trust. I highly recommend “Mastering Android AI: App to Intelligence” It’s a practical, code-first journey through the entire Android AI stack — from ML Kit and LiteRT to Gemma, Gemini, and MediaPipe — covering everything from your first “hello inference” screen to thermal throttling, model drift monitoring, and full applied projects like an AI-powered camera app and a health companion, with hands-on Kotlin builds at every step.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover
Originally published on Medium.




