skip to content

In a bidirectional mapping, code adds a child to the parent's collection and nothing is written — why, and what is the fix?

level: middleimportance: must knowfreq 66%

answer

  1. the collection is not the writer
  2. no diff on the owning field, no statement
  3. the mirror case leaves a stale graph
  4. one method sets both ends
  5. assert after a fresh read

basics

~20 s

The collection is the inverse end, and the layer builds link statements only from the owning end, so the flush finds nothing to write. Fix it with one method that sets both ends, and keep the raw setters private.

solid answer

~50 s

A bidirectional link is stored once, and the mapping says which side the layer reads. Adding to the inverse collection changes memory only: the child's own link field is still unset, the dirty check sees no change, and the commit succeeds with the column null — or with no statement at all. The mirror defect is just as bad: setting only the owning end writes correct data but leaves the already-loaded parent's collection stale for the rest of the unit of work, so later code computes on a graph that disagrees with the database. The fix is a single method on the parent that mutates the collection *and* assigns the child's reference, plus a matching removal method, with the raw collection and setter kept out of the public surface. Assert against a fresh read, not against the objects you just built.

code

pseudocode · 8 lines
pseudocode
// raw collection and raw reference stay non-public
function Parent.addChild(child):
    children.add(child)          // inverse end: in-memory view
    child.parent = this          // owning end: this is what the flush writes

function Parent.removeChild(child):
    children.remove(child)
    child.parent = null          // clears the link column

go deeper

for a junior

Learn the rule before the theory: change a bidirectional link through a method that touches both sides, never by adding to a collection alone. If a write seems to vanish, check the field on the child row's class first.

for a middle

Explain why nothing was written — the dirty check runs over the owning field, which the code never touched — and describe the mirror case where the data is right and the in-memory graph is stale.

for a senior

Show how you keep it from recurring: encapsulated add and remove methods, no public setter for either end, and tests that assert after a fresh read rather than on the object graph the test itself constructed.

for a principal

The interesting question is why the model allowed a half-set link at all. Decide whether bidirectional mapping earns its keep for a given link, since a one-sided mapping plus an explicit query removes the whole defect class rather than guarding against it.

The symptom is specific and common: code loads a parent, adds a freshly built child to the parent's mapped collection, the unit of work commits without error, and the statement log shows an `INSERT` for the child with a null link column — or no statement at all for the link. Nothing failed, so nothing was reported. The cause is almost always that the collection is the **inverse end** of a bidirectional link, and the layer builds link statements only from the **owning end**. ## Why nothing was written The stored link is one foreign-key column on the child's row, or one junction row. When a link is mapped from both sides, the mapping names which side the layer reads at flush time. If the collection is the inverse side, then at flush the layer: - diffs the owning field on each tracked object — here, the child's reference to its parent, which the code never set; - finds no difference, because the field is still null; - emits no statement for the link, and reports success, because nothing went wrong. The collection's new element is a purely in-memory fact. Note the child itself may still be inserted if the collection declares a cascade of saves — which is why the confusing outcome is often "the child row exists, but its link column is null". ## The mirror-image defect Setting only the owning end has the opposite failure mode, and it is worse because the data is right. The `UPDATE` runs, the database is correct, and the parent object that is already loaded in this unit of work still holds its old collection contents. Any code later in the same unit of work that iterates the parent's collection sees a graph that disagrees with the stored data — a total that omits the new child, a rule that fires on the wrong count, a response body missing a row. Re-reading the parent through the layer usually returns the very same tracked instance, so the stale view survives the re-read. ## The fix: one method that sets both ends The reliable fix is to stop letting either end be set on its own: 1. Put a method on the parent that mutates the collection **and** assigns the child's reference, and a matching one for removal that clears both. 2. Keep the raw collection and the raw reference out of the public surface, so no caller can set half a link by accident. 3. Call that method everywhere, including in test setup, so tests exercise the same path production does. This costs one small method per link and removes the whole defect class, including the stale-graph half that no database assertion would catch. ## What does not fix it | Attempted fix | What actually happens | |---|---| | Flush explicitly before commit | The flush still derives nothing from the inverse end; the link stays unwritten | | Re-read the parent inside the same unit of work | The layer hands back the same tracked instance, so the graph looks fine and the data is still wrong | | Declare a cascade of saves on the collection | The child row gets inserted, but its link column is set only if the owning field was set | | Switch the collection to load eagerly | Changes when the collection is read, not what the flush writes | | Swap which end is declared the owner | Moves the problem to the other side rather than removing it | ## Testing for it A test that only asserts on objects it still holds in memory will pass on both halves of the defect, because the in-memory graph is exactly what the buggy code just built. Two habits make the test honest: - assert against a **fresh read** — start a new unit of work, or clear the tracked set, then load the parent again and check the collection and the child's link; - assert on the **statement count or the column value** for the link, not only on the object graph. ## What an interviewer is listening for The strong answer names the mechanism before the fix: the collection is mapped for reading, the write comes from the other side, therefore no statement. It then volunteers the mirror case — that setting only the owner leaves a stale in-memory graph — and lands on the both-ends helper as the fix, rather than a flush, a re-read, or a hopeful cascade. Candidates who have actually debugged this also mention that some layers will wire the second end for you and others will not, so the helper is what makes the code behave the same either way.

  • Why does re-reading the parent in the same unit of work fail to reveal the problem?
    A tracking layer returns the instance it already holds for that row rather than rebuilding it, so the re-read hands back the same object with the same in-memory collection. The graph looks right and the stored data is still wrong. Clearing the tracked set, or reading in a new unit of work, is what exposes it.
  • Would declaring a cascade of saves on the collection fix it?
    No. Cascade decides whether a reachable new object is inserted at all; it does not set the link field. You typically get the child row written with a null link column, which looks even more like a layer bug than getting nothing.
  • How do you write a test that catches both halves of this defect?
    Commit, clear or discard the tracked objects, then load the parent again and assert two things: the child's link column points at the parent, and the parent's collection contains the child. Asserting only on the objects the test just built passes in both broken cases.

saying these in an interview costs you the question

  • Adds an explicit flush and expects the missing link to appear
  • Re-reads the parent in the same unit of work and calls it verified
  • Sets only the owning end and leaves the loaded collection stale
  • Expects a cascade declaration to supply the missing link value
  • Assumes every layer wires the second end automatically