open atlas
↑ Back to track
Backend Architecture BE · 02 · 06

DI containers in production: resolution graphs, circular deps, and when not to

A container is a graph resolver: it topologically sorts dependencies and instantiates them once. The sharp edges — circular deps it cannot order, eager startup that surfaces config bugs at boot, and a real cost meaning not every app needs one.

BE Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

An app boots fine in development and crashes on the production server with Nest can't resolve dependencies of the UserService (?, AuthService). Nothing about UserService changed. What changed is that AuthService now imports UserService, and UserService already imported AuthService — a cycle. In dev the modules happened to load in an order that papered over it; in prod they did not. The container is not buggy. It is telling you it cannot put a circle in a line.

A container is a graph resolver

What does a container actually do under those decorators — and why does that matter when things go wrong? Strip away the decorators and a DI container does one thing: it reads the dependency declarations, builds a directed graph, and instantiates in dependency order — leaves first, then the things that depend on them, up to the root. This is a topological sort. To create OrderService(db, payments), it must first create db and payments; to create those it resolves their dependencies, recursively. For singletons it does this once and caches the instance, so the second consumer of db gets the same object. The container is the automated composition root from earlier in the unit — the one place that knows concretions, now mechanized.

Why circular dependencies break it

A topological sort only exists for a directed acyclic graph. If A depends on B and B depends on A, there is no “leaves first” order: to build A you need B, but to build B you need A. The container detects the cycle and refuses, which is the Hook’s error. Frameworks offer an escape hatch — NestJS’s forwardRef(() => Other) — that breaks the deadlock by deferring one side’s resolution until after construction. But the escape hatch is a smell, not a solution: a cycle almost always means two classes that should be one, or a missing third class that both should depend on. Reaching for forwardRef to silence the error preserves the design flaw.

Why this works

Why does a cycle sometimes appear only in production? Resolution order can depend on module load order, and module load order can differ between a dev server doing hot-reload and a cold production boot, or between two bundlers. With a true cycle, some orders happen to resolve (the first class is partially constructed when the second asks for it and gets the half-built reference) while others throw. That makes the cycle a latent bug that surfaces nondeterministically — green locally, red on deploy. The real fix is to remove the cycle: extract the shared logic both classes need into a third class they both depend on, turning the circle into a tree. forwardRef only hides that the graph is not acyclic.

Eager vs lazy: when failure surfaces

Containers differ on when they instantiate. Eager instantiation builds the whole singleton graph at startup, before serving traffic — NestJS does this by default. Lazy instantiation creates each provider on first use. The tradeoff is when bad configuration bites: eager startup means a missing env var or unresolvable dependency crashes the boot — loud, immediate, before any user is affected. Lazy means the same misconfiguration hides until the first request hits that code path, possibly hours later, possibly only on one endpoint. For server applications the senior preference is overwhelmingly eager: pay a slightly slower boot to convert a 3 a.m. runtime page into a deploy-time failure your CI catches.

The hidden costs

A container is not free. It adds startup time (building the graph), a learning and debugging tax (stack traces run through framework resolution code, and “cannot resolve” errors are their own skill to read), and a temptation toward hidden global state — the container becomes a place to stash singletons that are really just globals with extra steps. For a small service, a CLI, or a serverless function with a tiny graph, hand-wiring in a composition root — plain new calls in one file at the entry point — is often clearer and faster than a container. The container earns its cost when the graph is large, lifetimes are mixed, and the wiring would otherwise be a sprawling manual mess. “Use a DI container” is a scale decision, not a default.

Quantify the cost so the decision is not a vibe. Hand-wired construction — new calls in one file — completes in microseconds; the graph is built before the event loop even spins. A reflection-and-decorator container instead reads metadata for every provider at boot: a NestJS app with a few hundred providers commonly spends tens to low-hundreds of milliseconds resolving and instantiating its graph at startup. On a long-running server that one-time tax is invisible against months of uptime — pay 150ms once, amortize it to zero. In a serverless function it is the opposite: that same 150ms lands on every cold start, billed per millisecond and felt as p99 latency by the unlucky request that triggered the cold init, which is why heavy reflective containers are a poor fit for Lambda-style workloads and teams there often hand-wire or use a compile-time DI approach that does the resolution at build time instead of boot. The break-even is roughly: graph under ~10 objects with uniform lifetimes → hand-wire and skip the framework tax; graph of dozens-to-hundreds with mixed scopes and a long-lived process → the container’s automation is worth its startup cost. Match the tool to the deployment shape, not to habit.

ConcernEager (startup)Lazy (first use)
Missing dependencyCrashes at boot, before trafficCrashes mid-request, later
Boot timeSlower (builds whole graph)Faster
Best forLong-running serversCLIs, rarely-hit paths
Failure visibilityLoud, deploy-timeQuiet, runtime
Quiz

Why can a DI container not resolve a circular dependency between two services?

Quiz

Why do server applications usually prefer eager instantiation of the dependency graph at startup?

Quiz

When is hand-wiring in a composition root often preferable to adopting a DI container?

Recall before you leave
  1. 01
    What does a DI container actually do under the hood, and how does it handle singletons?
  2. 02
    Why do circular dependencies break a container, why might one appear only in production, and what is the real fix?
  3. 03
    Compare eager and lazy instantiation, and explain when a container is worth its cost versus hand-wiring.
Recap

Beneath the decorators, a DI container is a graph resolver: it turns dependency declarations into a directed graph and instantiates them in topological order, building singletons once and caching them — the composition root, mechanized. That framing explains its sharpest edge: a circular dependency has no valid construction order, so the container refuses, and because resolution can follow module load order the same cycle can pass in dev and crash in prod. forwardRef defers one side to break the deadlock but only masks a graph that should have been made acyclic by extracting a shared third class. Containers also choose when to build: eager startup instantiation turns misconfiguration into a loud deploy-time crash, which long-running servers should prefer over a quiet first-request failure. And the machine is not free — startup cost, a debugging tax, and the pull toward hidden global state mean a small graph is often better hand-wired in a plain composition root. With middleware and DI both understood — the request axis and the wiring axis, their mechanics, scopes, seams, and production edges — the track can move on to how blocking and async work shape throughput under load. Now when you see Nest can't resolve dependencies, you read it as a graph problem: find the cycle, extract a shared third class, and the container’s refusal becomes your earliest design feedback.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons
appears again in188

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

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.