skip to content

Which call sites commonly touch a deferred link after its unit of work has closed, and why do they escape review?

level: middleimportance: must knowfreq 62%

answer

  1. after the service method returns
  2. output, rendering, async, cached
  3. a deferred read has no syntax
  4. the transaction marker sits one frame in

basics

~20 s

Four sites dominate: output serialisation, view rendering, async or queued work, and an object held past its request in a cache or field. All escape review because a deferred read looks like a plain field access, with no query at the call site.

solid answer

~40 s

The touch almost always happens **after** the code that looks database-related has finished. The classic sites are output serialisation at the response boundary, view or template rendering, work handed to another thread or a queue, and an object stashed in a cache or a long-lived field and read later. They escape review for one structural reason: at the call site there is no query, no repository and no transaction marker - just what looks like reading a field or iterating a collection. Reviewers also read the transaction boundary as a property of the service method, while the risky read happens one or two frames further out, often in code generated or driven by a framework, which never appears in the diff at all.

go deeper

for a junior

Learn the four places to look: output serialisation, view rendering, async work, and an object held past its request. All of them run after the service method has returned and the transaction has closed.

for a middle

Explain why the call sites are invisible - a deferred read looks like a field access, with no query, repository or transaction marker present. Note that the transaction marker sits on a different frame from the touch.

for a senior

Demonstrate the audit: mapped types crossing layer signatures, queue payloads, cached values, and tests re-run with the unit of work already closed so outstanding stand-ins fail in the build rather than in production.

for a principal

Argue the boundary rule instead of a ban list. Which types may leave a transaction is a design decision that survives new output formats; a list of forbidden call sites has to be rewritten every time one is added.

## The boundary is narrower than people picture it A unit of work usually spans one service call. Everything before it and everything after it runs with no loader available. The mental model that fails is thinking of the request as the boundary: in most designs the transaction closes when the service method returns, and a meaningful amount of work still happens afterwards - mapping, output, logging, dispatch. Every deferred link still unfilled at that instant is a landmine, and the mine is stepped on wherever output is produced. ## The four places it hides 1. **Output serialisation.** A mapped object is handed to the component that turns objects into a response, and that component walks whatever it finds - including links nobody intended to expose. It touches everything reachable, so the graph shape is decided by the output format, not by the use case. 2. **View or template rendering.** The same thing, one layer later: a template iterates a collection the read never fetched. Because rendering happens after the service call, the transaction is already gone. 3. **Async and background work.** An object is passed to another thread, a scheduled task or a queued job. By the time it is read, its unit of work has ended - and in the worst version the object also outlived the process boundary it was serialised across. 4. **An object kept past its request.** Stored in a cache, in a long-lived field, in a session, or returned upward and held. It works until somebody reads the one link that was never filled. ## Why each escapes review | site | what triggers the touch | why the diff looks innocent | |---|---|---| | output serialisation | reflective walk of every reachable link | there is no call site at all - a framework does the walking | | view rendering | an iteration in a template | templates are rarely reviewed as data-access code | | async work | any read in the worker | the worker looks self-contained; its input was loaded elsewhere | | cached or held object | a read minutes or hours later | the read is far away in both code and time from the load | The common factor is worth stating plainly: **a deferred read has no syntax.** A query has a call, a repository, a statement. A deferred read looks like reading a field. Reviewers can see the first and cannot see the second, so the rule *do not query in the view* is enforceable while *do not touch an unfetched link in the view* is not, unless the type system stops it. Two secondary reasons complete the picture: - **The boundary marker is on a different frame.** Whatever marks the transaction sits on the service method, so the reviewer looking at that method sees a correct, protected read - and the risky touch is not in that method. - **Tests do not reproduce the shape.** A test that wraps its assertions in an open transaction fills every link on demand and passes, so the suite is evidence of nothing here. ## The timing variant nobody plans for Three of the four sites share a second property: the gap between load and touch can be arbitrarily long. A cached object may be read an hour later; a queued job may run after a deploy; a scheduled task may pick up an object left behind by a request that has long since finished. That matters because it decouples the failure from the change that caused it. The commit that started caching a mapped object can be weeks old by the time someone adds a field whose link was never filled, and the incident then points at the innocent change. Treat *how long an object lives* as part of the review, not just *where it is read*. ## What to look for in a diff - A **mapped object type in a signature that crosses a layer** - returned from a service, accepted by a renderer, put into a queue payload, stored in a cache. - A **mapped object reaching the output boundary directly**, rather than a shape built for the response. - A collection **iterated outside the method that loaded it**, especially in output-shaping code. - Anything **stored for later** that was loaded inside a transaction. ## The structural read All four sites are the same event seen from different distances: something loaded under a transaction is read outside it. That is why the durable fix is a boundary rule - decide what leaves the transaction and in what shape - rather than a list of banned call sites. The list changes with every new output format; the rule does not.

  • Why is output serialisation the most dangerous of the four sites?
    Because nothing chooses which links it touches. A walker follows every reachable reference, so the graph fetched is decided by the output format rather than the use case, and a mapping change can silently add work. It also fails mid-write, after part of the payload has been emitted.
  • Does moving the transaction boundary outward remove the problem?
    It removes the symptom at the sites it now covers, and moves the cost. Queries then fire during output, while a connection is held for the whole span, and the shape of the graph is still chosen implicitly. It is a mitigation of last resort, not a repair.
  • How would you find existing instances rather than wait for them to fail?
    Search for mapped types crossing layer boundaries: in the signatures of things returned to output code, in queue payloads, and in cached values. Then re-run integration tests with the unit of work closed before the assertions, so any outstanding stand-in fails loudly.

saying these in an interview costs you the question

  • Thinks the transaction covers the whole request by default
  • Says a code review can spot deferred reads reliably
  • Treats output serialisation as harmless because it only reads
  • Assumes a passing test suite proves the path is safe
  • Believes putting an object in a cache freezes its links