skip to content

Why does a lazily loaded stand-in break a type test, a cast, or an equality comparison against the mapped class?

level: middleimportance: must knowfreq 58%

answer

  1. it is not the class you mapped
  2. assignable, but not identical
  3. loading does not change a class
  4. one direction forwards, the other compares

basics

~20 s

A stand-in is an instance of a type the layer generated, not of the mapped class. Exact-class tests fail, casts to a concrete subtype fail even after loading, and equality over runtime classes is asymmetric.

solid answer

~40 s

The layer builds the stand-in as a subtype of whatever the field was declared as, so it is assignable where the mapped class is expected but its runtime class is not that class. A test against the declared base type passes; an exact-class test fails. A cast to a concrete subtype of a mapped hierarchy fails too, and keeps failing after the link is loaded, because loading fills in state without changing the object's class. Equality written by comparing runtime classes is then asymmetric: a call on the stand-in is forwarded to the loaded target and can match, while the same comparison from the real instance sees a generated subtype and refuses — which breaks hash-based lookups. Compare by identifier, define equality over a business key through accessors, and unwrap before casting.

go deeper

for a junior

Recall that the object filling a deferred reference is a substitute the layer made, not the class you wrote. Comparing it with the exact class, or casting it, is where surprises come from.

for a middle

Be able to say which operations survive the substitution and which do not: base-type tests pass, exact-class tests and downcasts fail, and equality over runtime classes is asymmetric because only one direction forwards to the target.

for a senior

Show the fixes and their costs: identifier comparison, business-key equality through accessors, unwrapping before a cast, and arranging the query so no stand-in exists where a downcast is required.

for a principal

Treat it as an interface-design question. Decide whether mapped objects may cross into code that reasons about classes at all, and where the boundary converts them into plain values instead.

## What the caller is actually holding A deferred link is not the mapped object with empty fields. It is a **separate object of a type the layer generated**, built to be assignable where the declared type is expected: it extends the mapped class, or implements the interface the field was declared as. Inside, it holds the key and a reference back to the layer; each intercepted call checks whether the target has been fetched, fetches it if not, and forwards the call. That design has one purpose — to make deferral invisible — and it succeeds for the operations the interception covers. Everything it does not cover leaks the difference between the stand-in and the real instance, and those leaks are the classic interview material. ## The three leaks | Operation | What happens | Why | |---|---|---| | Test against the **declared base type** | Passes | The generated type is a subtype of it | | Test against the **exact runtime class** | Fails | The runtime class is the generated one | | Cast to a **concrete subtype** of a mapped hierarchy | Fails | The stand-in was generated over the *declared* type | | Equality **from the real instance** | Often false | It compares runtime classes and sees a subtype | | Equality **from the stand-in** | Often true | The call is forwarded and runs on the target | | **Direct field read** on the stand-in | Empty value | The state lives on the target, not the placeholder | **1. Type identity.** Code that asks "is this object exactly this class" gets a no. This bites in dispatch tables keyed by class, in test assertions, and in anything that logs or routes on class identity. A base-type test still passes, which is why the problem shows up only in the places that insisted on exactness. **2. Downcasting in a mapped hierarchy.** When a mapped class has subtypes and a link is declared as the base type, the layer must generate the stand-in over the base type — at the time it is created, nobody has read the row, so nobody knows which subtype the row is. The subtype becomes known when the load fires, but the object handed to the caller cannot change its own runtime class. So the downcast fails **even after the link is loaded**, which is the counter-intuitive part. Escapes are to unwrap the stand-in through the layer's own helper and cast the result, to model the branch as behaviour on the base type instead of a cast, or to arrange for the link to be fetched by the query so no stand-in is ever created for it. **3. Equality and hashing.** Equality written by comparing the two objects' runtime classes answers differently depending on which object is on the left, because only one direction goes through the forwarding. That asymmetry violates the contract every hash-based container relies on, so a lookup can find an object one moment and miss it the next depending on which instance ended up in the container. Equality written over a **stable business key**, read through accessors, is symmetric and survives the stand-in. Equality written over a generated identifier has a second problem — the value is absent before the row is written — but at least it does not depend on the runtime class. ## Why direct field reads misbehave Interception usually covers calls, not raw field access. Three routes bypass it: - a read from **inside the class**, where the field is in scope directly; - a read from **another instance of the same class**, which has the same access; - **reflective tooling** that enumerates fields rather than calling accessors. Each of these reaches the *stand-in's* own field, which was never populated, rather than the target's. The value looks absent, which reads as data loss rather than as a missing load. Layers that instrument the class itself can intercept field reads too, so this is one of the places where honest advice is layer-dependent: keep mapped state behind accessors, or know that your layer rewrites field access and accept direct reads. ## What to do about it 1. **Compare by identifier, not by object.** Most of the comparisons that go wrong were really asking "is this the same row". 2. **Prefer base-type tests and behaviour over exact-class tests and casts** in code that can see mapped objects. 3. **Define equality once, over a business key, through accessors** — and if no business key exists, accept that identity comparison within one unit of work is the only reliable equality you have. 4. **Unwrap before you cast**, using whatever the layer offers for it, and treat the need to unwrap as a signal that the query should probably have fetched the link. 5. **Keep mapped objects out of generic frameworks that reason about classes** — serialisers, dispatchers, copiers — or give them the unwrapped instance.

  • Why can equality answer differently depending on which object you put on the left?
    Because only one side is a stand-in. A call on the stand-in is forwarded to the loaded target, so the comparison actually runs from a real instance and can succeed. The same call on the real instance sees the stand-in's generated runtime class and a class-identity comparison rejects it. Comparing a stable business key through accessors removes the asymmetry.
  • Why can reading a field directly on a stand-in return nothing when reading it through an accessor works?
    The stand-in's own fields were never populated; the fetched state lives on the target it forwards to. If the layer intercepts calls but not raw field access, a direct read bypasses the interception and sees the empty placeholder. Keep mapped state behind accessors, or use a layer that rewrites field access so both routes behave alike.

saying these in an interview costs you the question

  • Says a stand-in is just the mapped class with its fields left empty
  • Assumes a cast to a concrete subtype starts working once the link is loaded
  • Writes equality by comparing runtime classes and calls it symmetric
  • Reads mapped fields directly off a stand-in and trusts the values
  • Concludes from a failed exact-class test that the object is some other row