What happens when code touches a not-yet-loaded association after the unit of work that loaded the object has closed?
answer
- the error is outside the transaction
- the link still owes you a query
- stand-in outlives its unit of work
- fails at touch, not at load
basics
~20 sThe read fails at that moment. A deferred link still needs its own query to fill itself, and the unit of work and connection that would run it are gone, so most layers raise an error instead of returning data.
solid answer
~50 sA deferred association is not data yet - it is a stand-in that knows how to fetch itself, wired to the unit of work that created it. While that unit of work is open, touching the link quietly runs a query and the data appears. Once it closes, the tracked set, the transaction and the connection are gone, but the stand-in is still sitting inside the object you are holding. Touching it now asks for a query that nothing can run, so the layer fails at the point of the touch, not at the point of the load. Layers differ in the failure: most raise a runtime error naming the object and the link, some hand back an empty or null link, and a few silently open a fresh connection and read outside any transaction.
go deeper
Remember the shape: a deferred link is a promise to query, and the promise can only be kept while its unit of work is open. The error appears where the link is touched, not where the object was loaded.
Be able to explain what closing takes away - tracked set, transaction, connection - and why the stand-in survives it. Note that some layers raise and others hand back an empty link, which turns the same bug into a silent one.
Show that you read the stack bottom-up to the boundary component, and that you check whether tests hold a transaction open around assertions - that is usually why the path is green locally and red in production.
Frame it as where a system pays for its data shape: implicitly at touch time, or explicitly at read time. Teams that never make that choice end up with output format quietly deciding query plans.
## What a deferred link is holding When a data-access layer defers part of an object graph, it does not give you the linked data and it does not give you nothing. It gives you a **stand-in**: a placeholder that carries the key it would query on and a reference back to the **unit of work** that produced it. The first read of that link is what runs the query. That is the whole point of deferring - the parent is cheap to load, and the rest is paid for only if somebody asks. While the unit of work is open, none of this is visible. `order.lines` looks exactly like a field read, and it behaves like one, just with a query hidden behind it. ## Why closing the unit of work breaks it Closing a unit of work retires three things at once: - the **tracked set** that would hold the newly loaded objects and give them identity; - the **transaction** the extra read would have run in; - the **connection** it would have run on. The stand-in survives all three, because it does not live in the unit of work - it lives inside the object you are still holding and passing around. So you keep an object that promises to fetch on demand, wired to a fetcher that no longer exists. The touch is the moment the promise is called in. Two consequences follow, and both catch people out. First, **the failing line is not the buggy line**. The load site is fine; the touch site is where the error appears, often in code nobody associates with the database - a serialiser, a template, a mapper. Second, **the failure is state-dependent**: the same line works whenever the link happens to have been filled earlier, and fails when it has not, so it can hide behind a warm path in one request and appear in another. ## What layers actually do on the touch Data-access layers differ here, and the difference decides how much you suffer: | behaviour on the touch | what the caller sees | why it hurts | |---|---|---| | raise a runtime error naming object and link | a loud failure, often mid-response, with a half-written payload already emitted | noisy, but honest and easy to find | | return an empty collection or null link | a plausible-looking response missing data | silent wrong answers that no alert catches | | open a fresh connection and query anyway | correct-looking data, extra statements | reads outside the intended transaction, unbounded query count, inconsistent snapshot | The loud version is the kindest of the three. A quietly empty collection is the same defect with the alarm disconnected. ## What the failure is not - **Not missing data.** The rows exist; nothing was deleted. Only the ability to go and get them was lost. - **Not a pooling or connectivity problem.** A bigger pool changes nothing, because the code never asks the pool - it asks a unit of work that has been closed. - **Not fixed by a null check.** The link is not null; it is a live object that fails when read. Guarding it usually just moves the failure. - **Not fixed by retrying.** Nothing about the second attempt is different, because the closed unit of work does not reopen. ## Reading the failure in practice Three observations usually pin it down in a minute: 1. **Where the stack ends.** The frame at the bottom is a boundary component - the thing turning objects into output, or a worker picking up an object from somewhere else. 2. **What is already written.** If part of a response went out before the error, the touch happened during output, and the boundary saw a graph it was allowed to walk. 3. **What the test suite says.** If a test covering the same path passes, look at whether the test keeps a transaction open around its assertions. That single difference explains most production-only occurrences of this failure. The fix is never to make the touch survive; it is to decide, at the read, what the caller is going to need, so no touch is left outstanding when the unit of work ends.
- If the layer returns an empty collection instead of raising, is that better or worse?Worse in practice. The request succeeds, the response looks well-formed, and the missing rows read as a legitimately empty result. Nothing alerts, nothing appears in an error budget, and the defect is usually reported later by a user noticing absent data rather than by monitoring.
- Why does re-reading the same link a second time not help?Because nothing about the second read differs. The stand-in still needs a query, and the unit of work that would run it is still closed. A retry only repeats the same failure, or, on a layer that quietly opens a new connection, repeats the same unintended out-of-transaction read.
- Can the same failure happen while the unit of work is still open?Not from closure, but a comparable failure exists: an object explicitly detached from the tracked set while the unit of work continues loses the same ability to fill its links. The trigger is losing the association with a live tracked set, not the clock running out on the request.
A stand-in is a coat-check ticket: worth something only while the cloakroom is open. Present it after closing time and you get an error, not your coat - and the coat was never missing.
saying these in an interview costs you the question
- Says the data is missing from the database rather than unfetched
- Blames connection pool exhaustion or a network timeout
- Thinks a null check on the link prevents it
- Believes retrying the read succeeds the second time
- Assumes making every association eager is the correct fix