In a framework's component container, why does a singleton holding a per-request component fail only once requests overlap?
answer
- long lifetime captures short lifetime
- constructor arguments resolved once
- first request's instance, kept forever
- inject an accessor, not the instance
basics
~20 sThe singleton is built once and keeps whatever it was given, so a per-request dependency is captured from the first request and reused forever. Later requests then read the first request's state, which only shows up when requests overlap.
solid answer
~50 sThis is the **captive dependency** problem: a long-lived component holds a reference to a shorter-lived one. The container resolves a singleton's constructor arguments once, when it builds the singleton, and never revisits them — so the per-request instance created for whichever request triggered construction stays wired in for the life of the process. A single-request smoke test passes, because that first request really does see its own instance. The defect surfaces when a second request arrives and the singleton serves it the first request's identity, or fails on a resource that was already released when the first request ended. The fixes are to inject an accessor or factory that resolves per call, to pass the short-lived value in as a method parameter, or to give the holder the shorter lifetime — and to validate the graph at startup so the edge is rejected before traffic arrives.
go deeper
Remember the rule rather than the theory: a component may only hold dependencies that live at least as long as it does. A constructor argument is kept for the life of the object that takes it.
Explain the timeline out loud — construction resolves dependencies once, the reference then outlives the scope it came from — and name at least two fixes that resolve per call instead.
Recognise both symptom families in production: silent cross-request data and 'already released' failures on a borrowed resource. Say how you would prove the diagnosis and how startup validation prevents a recurrence.
Argue for the guardrail rather than the fix: eager graph construction plus lifetime validation at boot, a deliberately small set of request-scoped components, and a review rule any engineer can apply.
## What the container does, precisely A container builds a component by resolving each of its constructor dependencies and then constructing it. For a **process-wide (singleton)** component that construction happens **once**. The resolved dependencies become ordinary fields of an ordinary object. Nothing in the container reaches back into those fields afterwards: there is no per-request refresh, no re-injection, no rebinding. So if the graph says *this process-wide component depends on that per-request component*, the container has only one moment at which it can satisfy that edge — construction time — and only one instance available then: the one belonging to whatever request happened to be in flight. That instance is now **captive**. It has the per-request lifetime on paper and the process lifetime in practice. ## Why the bug hides 1. **The first request is correct.** Whoever triggers construction gets exactly the instance that belongs to them, so the happy path works the first time, every time. 2. **Sequential testing never reproduces it.** One request, assert, next request — a fresh process or a warm one, either way most tests only ever look at one request's output at a time, and the stale value often *looks* plausible. 3. **Lazy construction moves the symptom around.** If the singleton is built on first use, which request becomes the captive one depends on traffic, so the bug is not even reliably tied to the first request after a deploy. 4. **Nothing logs it.** No exception is thrown at wiring time unless the container validates lifetimes, and by default many do not. ## The two symptom families - **Stale or cross-request data.** The singleton keeps serving the captured request's identity, locale, tenant or correlation value. This is the dangerous one: no error, just work attributed to the wrong request. Under concurrency it looks like data bleeding between users. - **Use of a released resource.** If the captured component owned something released at request end — a borrowed connection, a buffer, an open unit of work — the singleton holds a closed object and later calls fail outright. Ugly, but far kinder than silence, because it points at the wiring. ## How to fix it | Fix | How it works | When to choose it | |---|---|---| | Pass the value as a method parameter | The handler is already inside the request, so it hands the singleton what it needs per call | Default choice; keeps the singleton free of scope awareness and trivial to test | | Inject a factory or accessor | The singleton holds something that resolves the current request's instance on each call, not the instance itself | The singleton needs the value deep inside its own logic, not at its entry point | | Inject a scope proxy | A stand-in of the same shape forwards each call to whatever the active scope holds | The dependency is an interface and you want callers untouched | | Demote the holder to per-request | Both live and die together, so the edge is legal | The holder is cheap to build and genuinely has request-shaped work | All four share one idea: **resolve late, or do not hold it at all.** The broken version resolves once and holds forever. ## Preventing the whole class - **Validate the graph at startup.** A container can walk every registration and reject any edge from a longer lifetime to a shorter one. That turns a silent data defect into a deterministic boot failure, which is the single highest-value guardrail available. - **Build the graph eagerly.** Lazy resolution defers the discovery of wiring problems to whichever request unlucky enough to trigger construction; eager construction at boot finds them before any traffic. - **Keep the per-request set small and documented.** The fewer components carry the request scope, the fewer edges can be wrong, and the easier the review heuristic becomes. - **Use a one-line review rule:** *a constructor parameter is captured for the lifetime of the object that takes it.* Anyone can apply that rule without knowing the container's internals. ## What does not fix it Locking, immutability and volatile fields all address *concurrent mutation*, and this is not a mutation bug. The reference is stable — it is simply the wrong object. Making the captured component thread-safe means every request now safely reads the first request's data. Likewise, retrying or restarting the process only resets which request becomes the captive one.
- How can a container catch a captive dependency before the first request arrives?By building and checking the whole graph at startup: any edge where a longer-lived component depends on a shorter-lived one is rejected. That converts a silent runtime data bug into a boot failure. Factory or accessor injection is the escape hatch such a check accepts, because it resolves per call rather than capturing an instance.
- Why does a captive dependency sometimes throw instead of quietly returning stale data?Because of what the captured component owns. If it holds a resource released when its request ended — a pooled connection, a buffer, an open unit of work — the holder keeps a reference to something already closed, and the next use fails. Stale data is the silent outcome; a released resource is the loud one.
- Is it ever acceptable for a long-lived component to keep a reference to a per-request one?Only when the reference is not the instance: a factory, an accessor or a proxy resolved per call is fine, because the identity is chosen at call time. Holding the resolved instance itself is never acceptable, even if it is read-only, because correctness depends on which request it came from.
It is like a clerk who writes down the first customer's order on a card and then reads that same card back to everyone who walks in afterwards. Nobody notices while there is only one customer in the shop.
saying these in an interview costs you the question
- Says the container re-injects a singleton's dependencies at the start of each request
- Treats it as a data race and proposes a lock or immutable fields as the fix
- Believes single-request local testing would reliably have caught it
- Claims the fix is to register the request-scoped component as a singleton as well
- Assumes an error is logged at wiring time even without lifetime validation