After a test fixture writes an object's private field directly, which invariants can silently break, and why does the object not notice?
answer
- invariants live in code, not in fields
- the constructor establishes, the mutator restores
- a cached total keeps the old answer
- a key computed once is not recomputed
- no hook fires on a field write
basics
~20 sInvariants live in the code a forced write skips — the constructor and the mutators. A direct field write can leave a cached derived value stale, validation unrun, a keyed container pointing at the wrong place and change notifications unsent, and nothing runs to detect it.
solid answer
~50 sA type's invariants are not properties of its fields; they are maintained by the code that assigns them. A constructor validates and establishes them, and each mutator restores them after it changes state. A reflective write reaches the storage and runs none of that, so four things can break at once: a derived value cached from the field no longer matches its source, a validity condition the type assumed was never checked, an entry stored in a container keyed on that field is now filed under a key nobody will compute again, and observers or dirty flags that would have been signalled were not. The object has no hook that fires on a field write, so it cannot detect any of it — the damage surfaces later, somewhere else, as a wrong total or a lookup that misses.
code
pseudocode · 17 linesclass Invoice:
private lines = []
private cachedTotal = 0 // invariant: equals the sum of lines
function addLine(line):
lines.append(line)
cachedTotal = sum(lines) // the only place the invariant is restored
function total():
return cachedTotal
// fixture reaching past visibility
handle = memberHandle(Invoice, "lines")
handle.suppressAccessCheck()
handle.write(invoice, [line(10), line(5)])
invoice.total() // 0 — no code ran to recompute itgo deeper
Remember that setters usually do more than assign: they check the value and update whatever else depends on it. Writing the field straight past them keeps the value and loses the rest.
Name the concrete breakages — a stale cached derivation, skipped validation, an entry filed under a key nobody recomputes, an unsent notification — and say why no hook exists to catch any of them.
Trace a real symptom back: a total disagreeing with its rows, or a lookup missing an object that iteration finds, and connect it to a fixture that forced state before the object was stored.
Treat a test suite that routinely forces hidden state as a design report: the types admit states their published construction paths cannot reach, and the fix belongs in the type.
Encapsulation is often described as hiding data. Operationally, the more useful description is that **a type owns the only code allowed to move its state from one valid configuration to another**. A forced field write keeps the data and discards the code, and that is where the damage comes from. ## Where invariants actually live An invariant is a condition a type promises holds whenever the outside world can observe it. It is established in one place and restored in others: - the **constructor** establishes it, usually after validating its arguments; - each **mutator** restores it after changing state, which frequently means doing more than the assignment the caller asked for; - everything else in the type is written assuming it currently holds. A reflective write lands in the storage and executes none of that code. From the type's perspective, state changed without any of its methods running. ## The four invariants a forced write commonly breaks 1. **A derived value cached from the source.** If the type keeps a precomputed total, length, digest or index alongside the data it summarises, the recompute lives in the mutator. Write the source directly and the cached value keeps the old answer — and it will be trusted, because the type has no reason to doubt it. 2. **Validation that never ran.** Range limits, non-emptiness, cross-field consistency ("an end date is never before its start"), and normalisation of the incoming value all live on the public path. 3. **Position in a keyed structure.** If the object is already stored in a container that placed it by a key computed from that field, changing the field does not move it. The entry stays where the old key put it, and lookups computing the new key miss — while iteration still shows the object present. This one confuses people for hours. 4. **Notification.** Change events, dirty flags, audit entries and cache invalidations that the mutator would have raised simply do not happen, so anything downstream keeps a consistent view of an object that has changed. ## What each path does | Step | Assignment through the type's own mutator | Forced reflective write | |---|---|---| | validate the incoming value | yes | no | | assign the field | yes | yes | | run-time check that the value fits the field's type | yes | yes | | recompute derived and cached values | yes | no | | reposition in keyed containers | yes, where the type manages it | no | | raise notifications and set dirty flags | yes | no | The row worth reading twice is the type-compatibility one: a reflective write is not free-for-all memory writing, and it does still check that the value fits the field's declared type. What it skips is the **type's own bookkeeping**, not the runtime's. ## Why the object cannot notice There is no hook that fires on a field write. A field is storage, and the only reason writing one normally triggers anything is that a method was doing the writing. Remove the method and nothing observes the change — no validation, no recompute, no event. So: - the object continues to report values derived before the write; - assertions inside the type keep passing, because they check conditions the type believed were maintained; - the failure surfaces far from the fixture, often in code with no reflective access anywhere in it. That distance is the real cost. A forced write in a fixture and a wrong figure in an unrelated report are separated by enough layers that nobody connects them quickly. ## Using the technique without the damage - **Write the whole cluster.** If a field has a derived companion, set both, and know which other fields the invariant relates. - **Write before the object enters anything.** Force state before the object is placed into keyed containers or observed by anything. - **Assert the invariant yourself.** The fixture took over the mutator's job; the fixture should finish it, by checking afterwards that the object's own accessors agree. - **Prefer a construction path.** A factory or constructor the declaring unit publishes for the awkward state does all of this correctly, once, for every test that needs it.
- Which forced write is least likely to break an invariant?One to a field that nothing derives from, nothing keys on and nothing observes — a purely descriptive value with no cached companion, no validation attached and no notification on change. Even then the write is only safe for the type as it is today: a later refactor that adds a derived companion breaks the fixture silently.
- How does the damage from a forced write usually surface?Far from the write and long after it. A total that disagrees with the rows behind it, a lookup that misses an object iteration clearly shows, an assertion failing inside code that touches nothing reflective. The distance between cause and symptom is what makes the technique expensive to debug.
- If the fixture must force the state, what should it do afterwards?Finish the mutator's job. Write every field the invariant relates, do it before the object is placed into any keyed container or observed by anything, and then assert through the type's own accessors that the object reports the state you intended rather than the one it cached.
A building's front door signs every visitor into the register; climbing in through a window leaves the visitor inside and the register wrong. The window did not break the building — it skipped the bookkeeping the door was doing.
saying these in an interview costs you the question
- Believes a direct field write also skips the run-time type compatibility check
- Thinks cached derived values refresh lazily on the next read
- Assumes a keyed container repositions an entry when the key field changes
- Treats encapsulation as hiding data rather than owning the state transitions
- Expects the object to detect and reject an inconsistent state on its own
- Says nothing breaks because the same value could have been set through the API