How would you stop deferred-read-after-close failures from recurring across a whole codebase, and what does each policy cost?
answer
- pay at design time or at runtime
- make the rule about types
- tests hide it by staying open
- retire the open-context setting last
basics
~20 sPick where the system pays: at design time, letting only fully materialised boundary types leave a transaction, or at runtime, keeping units of work open and absorbing implicit queries. The first costs mapping work; the second costs latency nobody sees.
solid answer
~40 sTreat it as a boundary decision, not a bug backlog. The durable policy is that **no mapped object leaves a transaction** - services return materialised shapes, and output, messaging and caching only ever see those. It costs real mapping work and a second family of types, and it needs an escape hatch for paths that genuinely operate on the graph, which then use named per-use-case read plans. The cheap alternative - keep units of work open across output - costs nothing to adopt and charges you forever in implicit queries and held resources. Whichever you choose, make the failure loud early: run integration tests with the unit of work closed before assertions, so any outstanding stand-in fails in the build rather than in production.
go deeper
The takeaway is the rule, not the strategy: objects loaded in a transaction should not travel past it. Return a shape built for the caller instead, and let each piece of work do its own reading.
Be able to say why the test suite is not evidence here - assertions inside an open transaction fill every link on demand - and what to change so the same path fails in the build instead.
Show enforcement rather than intent: type-level boundary checks, statement-count assertions, and tests that close the unit of work before asserting, so a repair cannot silently regress.
Own the tradeoff explicitly. Strict boundaries cost mapping code and buy predictable query sets; loose ones cost diffuse latency that no one owns. State which you chose, why, and the condition that would make you revisit it.
## The choice underneath all the options Every policy here is a decision about **when the system pays for its data shape**. Pay at design time, by stating in each read exactly what will be needed, or pay at runtime, by letting whatever touches the object later trigger the loads. Neither is free; teams get into trouble by never choosing, which means the runtime option is chosen by default and the bill arrives as latency nobody can attribute. ## The policies, honestly costed | policy | what it buys | what it costs | |---|---|---| | only materialised boundary types leave a transaction | the failure becomes structurally impossible; payload shape is explicit | mapping work, a second family of types, discipline at every service edge | | named read plans per use case | real objects where behaviour needs them, with a stated graph | plans multiply if tied to entities rather than use cases; needs curation | | keep units of work open across output | adoption costs nothing; existing code keeps working | implicit queries decided by output, resources held across the span, cost invisible in review | | make everything load eagerly | this class of failure disappears | every reader pays for the needs of the greediest one; unbounded graphs | | catch and log the failure centrally | dashboards look calmer | silently wrong responses; the strongest signal you had is discarded | The top two are the same policy at different strictness, and they compose: boundary types for read paths, named plans for the behavioural ones. The bottom two are not policies but ways of not having one. ## Making the failure loud, and early The reason this class survives so long in a codebase is that the safest-looking environment - the test suite - is the one place it cannot reproduce. A test that wraps assertions in a live transaction fills every link on demand and passes. Three changes fix that: 1. **Close the unit of work before assertions** in integration tests, so an outstanding stand-in fails in the build. 2. **Assert statement counts** on the paths you care about, so a repair that replaced one failure with per-row reads is rejected too. 3. **Where the layer permits it, turn off on-touch loading in test runs**, so any deferred access errors immediately rather than working by accident. These also protect the repair: without them, a fix and a regression look identical from the outside. ## Enforcing at the type level rather than by vigilance Human review cannot see a deferred read, because it has no syntax. It can very easily see a **type**. So make the rule about types: - no mapped type in a signature reachable from output code; - no mapped type in a message, task argument or cached value; - mapped types confined to the module that owns the transaction. Whether this is checked by an architecture test, a module boundary or review convention matters less than that it is checked mechanically. Vigilance decays; a failing build does not. ## Sequencing the adoption Doing this everywhere at once is rarely affordable. A workable order: 1. **Stop the bleeding on new code** - the boundary rule applies to anything added or substantially changed. 2. **Convert the read-heavy paths first**, where projections both remove the failure and cut latency, so the policy pays for itself visibly. 3. **Leave the behavioural paths for last** and give them named plans rather than shapes. 4. **Retire any global open-context setting last**, after the paths that depend on it have been converted - removing it first just converts hidden cost into a wave of incidents. ## The judgment to state out loud The honest position is that the strict boundary policy is more code, and that the extra code buys **predictability**: the set of queries a request runs becomes a property of its reads instead of a property of its output format. On a small system with one output shape, the loose policy may genuinely be cheaper. On a system with several consumers, background work and a caching layer, the loose policy stops being cheap long before anyone notices, because its cost shows up as diffuse latency rather than as an error somebody owns.
- Why is a strict boundary rule enforceable when a review rule is not?Because a deferred read has no syntax to spot, while a type in a signature does. A check on which types may cross a boundary can be automated and fails the build; a request to watch for out-of-transaction touches relies on attention that decays with every new contributor.
- Your team already keeps units of work open across rendering. How do you unwind it safely?Not by removing the setting first. Convert paths in order - new code, then read-heavy endpoints to projections, then behavioural paths to named plans - with tests that close the unit of work before assertions so converted paths stay converted. Remove the global setting only when nothing depends on it.
- When is the loose policy actually the right choice?On a small system with one consumer, one output shape and no background work, where the extra types would outweigh the diffuse cost. Say so explicitly and write down the trigger for revisiting it: a second consumer, a queue, or a cache in front of the reads.
saying these in an interview costs you the question
- Treats it as a bug backlog rather than a boundary decision
- Adopts open units of work everywhere and calls it a standard
- Catches the failure centrally and logs it as a warning
- Claims code review can reliably catch out-of-transaction touches
- Removes the global open-context setting before converting the paths
- Ignores that tests pass because they hold a transaction open