awesome-everything RU
↑ Back to the climb

Backend Architecture

DI scopes and lifecycles: singleton, request, transient

Crux A wired object has a lifetime: created once, once per request, or once per injection. Choosing wrong is two opposite bugs — a singleton holding per-request state leaks data between users, while request scope quietly recreates half your graph on every call.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at middle altitude — in the sky
◷ 15 min

A bug report says user A occasionally sees user B’s account name. The auth code is correct. The leak is a currentUser field on a service that was registered as a singleton — one instance shared by every concurrent request. Request A sets the field, request B overwrites it a millisecond later, and request A reads B’s value. Nobody wrote a security bug. Someone chose the wrong lifetime.

Three lifetimes

A DI container can create an instance at three cadences:

  • Singleton — one instance for the whole process, created once and shared by every request. The default in NestJS, Spring, and most containers. Fast and memory-light, because nothing is recreated.
  • Request-scoped — one instance per incoming request, shared within that request, discarded after. Useful for per-request context (the current user, a request ID, a per-request transaction).
  • Transient — a fresh instance every time it is injected. Useful for stateful helpers that must not be shared at all.

The singleton-with-state trap

The Hook’s bug is the canonical singleton mistake: a singleton must be stateless with respect to a request, because it is shared across all concurrent requests. Storing this.currentUser on a singleton is a data-leak waiting to happen — concurrent requests race on the shared field. The fix is either to make the per-request data flow through method arguments (pass user in, do not store it) or to make the service request-scoped so each request gets its own instance.

The opposite trap: scope bubbling

Request scope looks like the safe default, so teams reach for it — and pay a hidden cost. Scope bubbles up the injection chain: if a request-scoped provider is injected into a controller, that controller becomes request-scoped too, and so does anything that depends on it. One request-scoped logger near the bottom can “infect” the entire graph above it, so the container reinstantiates a large slice of your services on every single request instead of reusing singletons. That is extra allocation, extra GC pressure, and measurable latency — for what is often just a convenience.

Why this works

Why does request scope bubble but transient does not? Scope bubbling is about correctness of sharing. If service A is request-scoped (one instance per request) and controller C injects A, then C cannot be a singleton — a single shared C would have to hold a reference to some A, but there is a different A per request. So C must also be created per request; the request lifetime propagates up to every consumer. Transient is different: a singleton can safely hold a transient dependency because the transient was created fresh at injection time and the singleton simply keeps that one instance. Transient changes how many instances exist, not how long the consumer must live, so it does not force its consumers to a shorter lifetime.

Picking the lifetime

The rule that avoids both traps: default to singleton and keep singletons stateless; reach for request scope only when you genuinely need per-request identity that cannot be passed as an argument; use transient sparingly for deliberately unshared stateful helpers. Most “I need the current user here” cases are better solved by passing it through the call than by making the service request-scoped and dragging the whole graph down with it.

ScopeInstancesBest forTrap
SingletonOne per processStateless services, clients, poolsHolding per-request state → data leak
RequestOne per requestCurrent user, request transactionBubbles up, recreates the graph per request
TransientOne per injectionUnshared stateful helpersSurprising count of instances
Quiz

A singleton service stores `this.currentUser` and occasionally one user sees another's data. Why?

Quiz

Why can making one low-level provider request-scoped hurt performance across many services?

Quiz

A singleton needs the current request's user. What is the cleanest fix that avoids both traps?

Recall before you leave
  1. 01
    What are the three DI lifetimes and what is each best for?
  2. 02
    Why is storing per-request state on a singleton a data-leak bug, and how is it fixed?
  3. 03
    Explain scope bubbling and why request scope can be expensive while transient is not.
Recap

Every object the container wires has a lifetime, and the choice is a correctness-and-cost decision. Singleton — one instance per process — is the default and the right home for stateless services, clients, and pools, but storing per-request state on one leaks data across concurrent users, the canonical singleton bug. Request scope gives each request its own instance and is the honest fix for genuine per-request identity, but it bubbles up the injection chain: one request-scoped provider can force its consumers, and theirs, to be recreated on every request, trading reuse for allocation and latency. Transient creates a fresh instance per injection without forcing consumers to a shorter lifetime. The discipline is to default to stateless singletons and pass per-request values as arguments, escalating scope only when there is no cleaner option. Lifetimes set up the next concern: with the graph wired and scoped, how DI becomes the seam that makes the system testable.

Connected lessons
appears again in185
Continue the climb ↑DI as a testing seam: fakes, mocks, and the boundary that matters
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.