skip to content

A price-list type documented as immutable returns its live tier-row container from an accessor — which promises to callers are now false?

level: seniorimportance: should knowfreq 45%

answer

  1. one crack sinks a universal claim
  2. what the label was buying
  3. construction-time checks expire
  4. cached totals go quietly stale
  5. no reassignment, so no signal fires

basics

~20 s

Three promises collapse at once: that a check made at construction still holds at read time, that two reads agree so a derived result can be cached, and that the value can be passed around and treated as stable. One writable path falsifies all of them.

solid answer

~40 s

Callers never wanted immutability for its own sake; they wanted its consequences, and the leak takes those away. First, **validation expires**: a constructor that checked the rows are sorted and non-overlapping now certifies one instant, not the value. Second, **derived results go stale silently**: a total computed once and cached can stop matching what a read returns, with no reassignment anywhere to notice. Third, **handing the value on stops being safe**: the receiver is holding something that can change underneath it. And one accessor is enough, because deep immutability is a universal claim about everything reachable — the fields that really are fixed stay fixed, but the single claim callers were relying on is gone.

code

pseudocode · 6 lines
pseudocode
function tiersOf(priceList)
    return priceList.tiers    // the internal container itself

rows = tiersOf(published)
rows[0].amount = 0            // published now reads 0 for tier zero
rows.sortDescending()         // and the constructor's ordering check is void

go deeper

for a junior

Notice that the accessor gives the caller the real container, not a view of it. Anything the caller does to that container is something the published price list will report afterwards.

for a middle

Name the mechanism and then the consequence: an in-place write produces no reassignment, so nothing fires that could invalidate a cache or re-run a check made at construction.

for a senior

Reason from the licences callers were issued. Say which ones are revoked, show that a universal guarantee falls to one counterexample, and describe how you would localise the escaping reference in a live system.

for a principal

Treat the published label as an interface commitment. Decide what evidence a type owes before it may claim immutability, since every consumer that trusted the claim has already built on the licences it issued.

The leak here is small and the loss is not. Understanding why takes separating the label from the thing the label was selling. ## What the label was selling Nobody asks for an immutable price list because unchangeability is pleasant. They ask because immutability licenses a set of moves that are otherwise unsafe: - A property checked once is still true later, so it need not be re-checked on every read. - Two reads return the same value, so anything derived from one read may be kept and reused. - The value may be handed to another part of the system, stored in a registry, or logged as a record of what was published, with no further defence. Each of those is a licence, and each is issued on the strength of the same claim: nothing reachable from this value can change. Returning the live container revokes the claim, so every licence issued on it is revoked with it. ## The promises, one at a time **Construction-time validation.** Suppose the constructor rejects a price list whose rows are out of threshold order, or whose tiers overlap. That check runs against the rows as they were at construction. A caller who obtains the live container and reorders it has not defeated the constructor — the constructor is simply not consulted again. The invariant is now a historical fact about one moment, and any code reading the value later that relies on sorted rows is relying on something nobody is enforcing. **Cached derived values.** A total, a highest applicable tier, a rendered summary: these are computed from the rows and kept because the rows were supposed to be stable. An in-place write to a row changes the rows without changing any reference, so nothing that could plausibly act as an invalidation signal ever fires. The cache does not become wrong loudly; it becomes wrong quietly, and typically stays wrong until something unrelated forces a rebuild. **Safe hand-off.** A value that cannot change can be given to a second component as-is. A value that can change underneath the receiver cannot, and the receiver has no way to tell the two apart, because both of them look like an immutable price list at the point of hand-off. ## Why one accessor is enough Deep immutability is a universal claim — *no object reachable from this value can be written by anyone*. A universal is falsified by a single counterexample. It is tempting to answer that only the tiers are affected and the rest of the type is fine, and that is true about the fields while being useless to the caller: the caller cannot condition on which sub-part of a guarantee survived, because the guarantee was what they were handed. | Promise callers were relying on | Survives the leak? | Why | |---|---|---| | The root keeps referring to one container | yes | The binding was never the problem | | Construction-time checks hold at read time | no | Rows can be rewritten after the check | | Two reads return the same value | no | An in-place write changes what a read sees | | A derived total stays valid | no | Nothing signals that the rows changed | | The value can be passed on unguarded | no | The receiver holds a changing value | ## What actually survives Be precise rather than dramatic. The fields that cannot be rebound still cannot be rebound. Sub-structures that genuinely are unwritable are still unwritable. What is gone is the single sentence the type's documentation was making, and with it every consequence that sentence was carrying. That distinction matters in a real discussion, because the repair is chosen against what is actually broken rather than against a sense that everything is. ## How this shows up in production The characteristic signature is a bug report about a value that changed with nothing in the history to explain it. There is no reassignment, no new publication, no version bump — just two observations that disagree. Investigation walks the closure of the published value and looks for a reference that escaped, and the accessor is usually where it escaped. The second signature is a cached figure that disagrees with a freshly computed one, which is the same defect seen from the derived side. ## The answer an interviewer is listening for They want the consequences named, not the word *encapsulation*. A strong answer says which licence each caller had been issued, shows that an in-place write produces no observable event, and says plainly that a guarantee about everything reachable cannot survive one reachable thing being writable. Mentioning that a documentation comment is not an enforcement mechanism is a useful closing line; leading with it is not an answer.

  • Does documenting the accessor as internal-use-only restore any of the promises?
    None of them. A promise about what can change is not repaired by a note about what people should do; the write remains available to anyone holding the returned container, including code written later by someone who never read the note. Documentation narrows intent, and the guarantee callers were relying on is about capability.
  • Which of the three losses usually gets noticed first, and why?
    The stale derived value, because it produces a visible disagreement — a cached total against a freshly computed one. The broken invariant tends to surface later and further away, as a reader that assumed sorted rows behaving oddly. The unsafe hand-off is often never traced to this cause at all, because the receiver looks like the guilty party.
  • Two reads disagree and nothing was reassigned. How do you localise the write?
    Walk the published value's reachable closure and list every point where a reference to part of it can leave the type: accessors, anything handed to a callback, anything stored in a shared index. In-place writes leave no reassignment trail, so the search is over escape points rather than over assignments.

saying these in an interview costs you the question

  • Saying the leak affects only that one accessor's field
  • Treating a documentation comment as an enforcement mechanism
  • Assuming construction-time validation still holds at read time
  • Believing a cached derived value cannot go stale without a reassignment
  • Arguing the type is immutable because callers are well behaved
  • Insisting nothing is wrong until someone actually performs a write