skip to content

In a mapper with change detection, why can an in-place edit to a document-shaped converted attribute go unwritten?

level: seniorimportance: should knowfreq 52%

answer

  1. the snapshot may be the same object
  2. comparison sees no difference
  3. immutable values cannot be edited in place
  4. re-read after clearing the tracked set

basics

~20 s

Change detection compares the current state against the snapshot taken at load. If the snapshot holds the same mutable instance the code edited, both sides show the edit, the comparison finds no difference, and no update is emitted.

solid answer

~50 s

A mapper decides what to write by comparing each loaded object against a snapshot captured when the row was read. For plain columns that snapshot is a copy of the value, so a new assignment is visible. For an attribute whose object-side form is a mutable structure — a nested document, a map, a list, a value object with setters — a layer that snapshots by holding the same reference sees the mutation on both sides and concludes nothing changed. Layers that instead snapshot the *converted* form, re-run the converter at flush and compare the outputs do catch in-place edits, at the cost of one conversion per loaded row per flush. The dependable practice is to treat converted attributes as immutable: build a replacement and assign it, so the change is a reassignment the comparison cannot miss.

go deeper

for a junior

Remember that the layer works out what to write by comparing against a snapshot taken at load, so a change it cannot see is a change it will not write.

for a middle

Explain why a mutable attribute defeats that comparison when the snapshot holds the same instance, and why assigning a replacement value always works instead.

for a senior

Describe how you would prove it in a test — flush, clear the tracked set, re-read — and weigh the layer's snapshot strategies, including what re-converting every loaded document costs on a read-heavy path.

for a principal

Set the convention rather than the fix: converted and flattened types are immutable by policy, accessors do not leak internal structures, and no code depends on how deeply the layer happens to snapshot.

## How a change gets noticed at all A mapper that tracks loaded objects does not know what you changed; it works it out. When a row is read it records a **snapshot** of the object's state, and at flush time it compares the current state against that snapshot, field by field, and emits an update for whatever differs. Nothing about that mechanism requires you to announce a change — that is exactly its appeal, and exactly why it can be fooled. The comparison is only as good as the snapshot. For an attribute whose object-side form is **immutable** — a number, a text value, a date, a value type with no setters — a change can only arrive as an assignment of a new value, and the old snapshot still holds the old one. The difference is visible. For an attribute whose object-side form is **mutable**, that guarantee is gone. ## Why the edit vanishes 1. The row is loaded. The converter turns a stored document into an object-side structure — a nested map, a list of entries, a settings tree. 2. The snapshot records that attribute. If it records **the reference**, the snapshot and the live object are now the same instance. 3. The code edits the structure in place: adds an entry, flips a nested flag, appends to a list. 4. At flush, the comparison reads the snapshot and reads the current value. Both are the same instance, so both show the edit. 5. No difference is found, and no update is emitted. The transaction commits successfully and the change is gone at the next read. The failure is total and silent: no exception, no warning, a green test if the test re-reads from the same tracked set rather than a fresh one — because the edited instance is still in the identity map and is handed back unchanged. ## Two snapshot strategies | Strategy | Catches an in-place edit | Cost per flush | Memory | |---|---|---|---| | Hold the loaded reference | no | none | none | | Keep the converted stored form and re-convert to compare | yes | one conversion and a comparison per loaded row | the stored form is retained for every loaded row | A third middle ground exists: snapshot a **deep copy** of the object-side structure and compare structurally. It catches the edit without re-running the converter, and it pays in memory and in an equality definition that must be correct for the whole nested shape. Since layers genuinely differ here, the honest interview answer names the trade rather than asserting one behaviour, and the honest engineering answer does not depend on which one you got. ## The same trap outside document columns - **Mutable value objects.** A flattened or converted value with setters has exactly the same exposure; content equality does not save you if the snapshot is the same object. - **Structures nested inside an otherwise immutable value.** An immutable wrapper around a mutable list is mutable where it counts. - **Values shared between two owners.** One instance assigned to two objects turns one edit into an ambiguous write, on top of the detection problem. - **Values handed out from an accessor.** Returning the internal structure lets any caller mutate it far from the code that owns the attribute, which is where these bugs are found weeks later. ## Making writes deterministic 1. **Make the converted type immutable** and change it only by assigning a replacement. This is the fix that works regardless of the layer's snapshot strategy, and it is why immutable value types and data-access layers get along. 2. **Return defensive copies** from accessors if the type cannot be made immutable, so nobody can edit the live structure by accident. 3. **Assert on the round trip in tests.** Write, flush, clear the tracked set, re-read from the database — not from the identity map — and check the edit survived. A test that skips the clear passes on a broken write path. 4. **Watch the cost of the safe strategy.** If the layer re-converts every loaded document on every flush, a large document on a hot read path becomes measurable work with no writes involved; a smaller column, fewer loaded rows, or a read path that does not track objects at all are the levers. ## The rule of thumb Change detection compares values; it cannot observe mutations. Any attribute whose object-side form can be edited in place is therefore an attribute whose changes may or may not be noticed, depending on machinery outside your code. Assigning a replacement instead of editing in place removes the dependency entirely, and costs one line.

  • Why can a test pass while the change is silently lost?
    Because the test re-reads within the same tracked set. The identity map hands back the very instance that was edited in memory, so the assertion sees the new value whether or not any statement was emitted. Clearing the tracked set, or reading in a fresh unit of work, forces a real read from the database and exposes the missing update.
  • What does the safe snapshot strategy cost on a read-heavy path?
    A conversion and a comparison for every loaded row at every flush, plus the retained stored form in memory. For a large document loaded in bulk that is real CPU spent to discover that nothing changed. Loading such rows in a mode that does not track them, or narrowing what is loaded, removes the cost outright.
  • How do you force a write when the layer cannot see the change?
    Assign a replacement value, which turns the mutation into a comparison the layer cannot miss. Where the layer offers an explicit way to mark an attribute changed, that works too but leaves a landmine for the next edit that forgets it. Bumping an unrelated field to trigger the update is a hack that writes the right row for the wrong reason.

It is like proofreading a document against a photocopy, except the photocopy is really the same sheet of paper: whatever you change appears on both, so the comparison finds nothing to report.

saying these in an interview costs you the question

  • Assumes change detection observes mutations rather than comparing values
  • Mutates a converted structure in place and expects a write
  • Verifies the write by re-reading within the same tracked set
  • Hands out the internal structure from an accessor with no copy
  • Thinks a commit guarantees the change was written
  • Believes every layer snapshots deeply enough to catch it