skip to content

You add a validating custom setter to enforce an invariant, but invalid values still slip in. What are the ways a custom setter can be bypassed, and how do you close the gaps?

level: seniorimportance: should knowfreq 40%

answer

  1. Setter only runs on obj.x = v assignments
  2. Initializer writes field directly, skips setter
  3. Constructor-param properties skip later-added setter
  4. Reflection/serialization write field directly
  5. Validate in init + setter; prefer val + factory

basics

~20 s

A custom setter only runs on normal assignments. The property's own initializer skips it, constructor parameters can skip it, and reflection ignores it. To truly enforce a rule, validate in the constructor or init block too.

solid answer

~40 s

A custom setter intercepts ordinary writes (`obj.x = v`) but several paths bypass it. (1) The **property initializer** `var x: T = init` writes `field` directly, never calling the setter — so the initial value is unchecked. (2) A `var` declared as a **primary-constructor parameter** with `val/var` is initialized from the parameter without invoking a separately written setter for that initial value. (3) **Reflection** (`KMutableProperty.setter.call`, or Java reflection on the field) and serialization frameworks can write the backing field directly. To enforce an invariant robustly: put the check in an **`init` block** or a private constructor that runs `require`/`check`, validate in the setter for later mutation, and consider deserialization callbacks. Treat the setter as one of several entry points, not the sole guard.

code

kotlin · 9 lines
kotlin
class Percentage(initial: Int) {
    var value: Int = 0
        set(v) { field = check(v) }
    init { value = check(initial) }      // route initial through the same check
    private fun check(v: Int): Int {
        require(v in 0..100) { "out of range: $v" }
        return v
    }
}

go deeper

for a junior

Recognizes a setter validates assignments but may not know about bypass paths.

for a middle

Knows the initializer bypasses the setter and adds an init-block check.

for a senior

Enumerates multiple bypass routes (init, constructor param, reflection/serialization) and centralizes validation.

for a principal

Chooses immutability + factory as the structural fix, weighs framework deserialization paths, and prevents subclass override of invariants.

## The problem A custom setter is only invoked on explicit assignment statements (`obj.prop = value`). Anything that writes the **backing field** directly, or constructs the object by another route, will not run your validation. ## Bypass 1: the property initializer ```kotlin var age: Int = -5 // initializer writes `field` directly set(value) { require(value >= 0) field = value } ``` The `= -5` initializer goes straight to the backing field. The setter's `require` never runs for it, so the object starts in an invalid state. Later `obj.age = -1` *would* be rejected. **Fix:** validate the initial value too, typically in an `init` block: ```kotlin class Person(initialAge: Int) { var age: Int = initialAge set(value) { require(value >= 0); field = value } init { require(age >= 0) { "age negative" } } } ``` ## Bypass 2: constructor-parameter properties For `class P(var age: Int)`, the parameter initializes the backing field. If you later attach a custom setter (only possible by moving the property into the body), the *initial* assignment from the constructor param still won't run separately-added validation. Idiom: take a plain constructor parameter, declare the property in the body with the validating setter, and validate once in `init`. ## Bypass 3: reflection and frameworks - Java reflection / Kotlin `KMutableProperty1.setter` may go through the setter, but `javaField.isAccessible = true; field.set(obj, bad)` writes the **field directly**, skipping the setter entirely. - Deserialization (Jackson, kotlinx.serialization with certain settings, JPA) can populate fields directly, bypassing setters. **Fix:** add framework-level validation (e.g. `@field:Min`, deserialization callbacks, `@PostLoad`), or make the type immutable and validate in a factory. ## Bypass 4: inheritance / open setters An `open` property whose subclass overrides the setter can drop the validation. Keep validated setters `final` (the default) or re-assert invariants. ## Design guidance - Treat the **constructor/init** as the primary guard; the setter handles subsequent mutation. - Prefer **immutable** (`val`) + factory/`require` when the invariant is critical, eliminating mutation paths. - Centralize the rule in one private function called from both `init` and `set` to avoid drift. ```kotlin class Account(initial: Long) { var balance: Long = initial private set(value) { field = validate(value) } init { balance = validate(initial) } // route initial through validate private fun validate(v: Long) = v.also { require(it >= 0) { "negative" } } } ```

  • Why does `var age: Int = -5` with a validating setter still produce a negative age?
    The initializer assigns the backing field directly and does not invoke the custom setter, so its require check never runs for the initial value.
  • If the invariant is truly critical, what's the most robust design?
    Make the field a `val`, validate once in a private constructor or factory function with `require`, and expose no mutating API — removing every bypass path.

The setter is a security guard at the front door, but the building also has a loading dock (initializer), a staff entrance (constructor), and a back window (reflection). Guarding only the front door isn't enough.

saying these in an interview costs you the question

  • Assuming a validating setter alone guarantees the invariant
  • Not knowing the property initializer bypasses the setter
  • Ignoring reflection/serialization writing fields directly
  • Duplicating validation logic in init and setter with subtle drift
  • Leaving a validated setter open so subclasses can override it

context