Skip to content

Why Dependency Injection Cannot Achieve Topological Concurrency: Graph Cycles, 3-Tier Caching, and the Async Deadlock

Perspective: Graph Theory Models / Runtime Deadlocks / Cross-Language Internals
Read Time: ~12 min
Core Dilemma: Why does Constructor Dependency Injection (DI)—the dominant backend paradigm for 20 years—mathematically destroy topological concurrency? Why did Java Spring have to survive on single-threaded recursive DFS and 3-tier raw pointer caches? Why was NestJS forced into a strictly serial for...of await loop when dealing with asynchronous providers?


The Masked Reality: The Cold-Start Penalty

In modern full-stack engineering, microservices, and Serverless / Edge computing architectures, engineering teams adopting traditional class-based IoC frameworks (such as NestJS or InversifyJS) invariably run into the same architectural bottleneck:

Container bootstrap latency scales linearly with the number of infrastructure and business components ($\sum t_i$).

  • Database handshake: 800ms
  • Redis sentinel cluster initialization: 500ms
  • Distributed configuration fetch: 600ms
  • Kafka / Message Queue producer handshake: 700ms

From an architectural standpoint, these four infrastructure modules are completely independent of one another with zero mutual dependencies. Within the single-threaded, non-blocking Event Loop of Node.js / V8, they ought to be dispatched concurrently at the microsecond level, yielding a total cold-start duration equal to the bottleneck node: Max(800, 500, 600, 700) = 800ms.

Yet in reality, a typical NestJS application bootstrap takes 800 + 500 + 600 + 700 = 2600ms, catastrophic for Serverless cold starts.

Developers naturally ask: If the framework explicitly knows the dependency graph through imports, providers, and inject, why does it not execute topological concurrency?

The technical truth is stark: Traditional Dependency Injection (DI) cannot achieve topological concurrency because of a fundamental violation of graph theory axioms.


1. Graph Theory Axiom: Topological Concurrency Requires a Pure DAG

To schedule concurrent executions across a dependency graph, computer science dictates one unyielding mathematical rule: The directed graph must be strictly acyclic (Directed Acyclic Graph, DAG).

Under the classic Kahn's Algorithm for Topological Sorting:

  1. Calculate the in-degree of all nodes in the graph;
  2. Identify all frontier nodes with an in-degree of $0$—these nodes possess zero unsatisfied prerequisites;
  3. Dispatch all in-degree $0$ nodes concurrently into the execution pool (Promise.all in JavaScript);
  4. Upon completion of each node, remove its outgoing edges and decrement the in-degree of downstream dependent nodes;
  5. Recursively discover the next tier of in-degree $0$ nodes and cascade downward until completion.
Tier 0 (Concurrent):   [Config: 0]         [Telemetry: 0]
                             \               /
Tier 1 (Concurrent):    [Database: 0]   [Redis: 0]
                             \           /
Tier 2:                   [Application: 0]

The Mathematical Correlative: If any cycle (环) exists within the graph, every node inside that cycle retains an in-degree of $\ge 1$. Kahn's algorithm halts immediately. Mathematically, the scheduler cannot determine which node to activate first, let alone divide the graph into concurrent execution tiers.


2. The Original Sin of DI: Conflating Invocation with Instantiation

Why do cycles appear so frequently in traditional DI applications? Is it because application developers write bad architecture?

No. Over 90% of architectural cycles are pseudo-cycles fabricated by the syntax constraints of Constructor Dependency Injection.

Real-World Business: Invocation Collaboration

In production systems, mutual runtime method calls are standard and clean:

  • OrderService.checkout() needs to invoke PaymentService.pay();
  • PaymentService.handleWebhook() needs to invoke OrderService.markSuccess().

On the physical timeline, at the precise moment of application startup ($t_0$), neither service needs to execute the other's methods. They merely need access to each other's references at future runtime execution ($t_1, t_2...$).

Constructor DI Syntax Kidnapping

Under the constructor injection paradigm popularized by Java and mirrored by NestJS:

typescript
@Injectable()
export class OrderService {
  constructor(private paymentService: PaymentService) {} // Demands: Cannot construct Order without a complete Payment instance
}

@Injectable()
export class PaymentService {
  constructor(private orderService: OrderService) {}     // Demands: Cannot construct Payment without a complete Order instance
}

The language runtime enforces:

  • "To construct OrderService, the call stack must first resolve a fully initialized PaymentService instance."
  • "To construct PaymentService, the call stack must first resolve a fully initialized OrderService instance."

In graph theory, a benign runtime method call has been mutated by DI syntax into two mutually blocking directed edges: Order <---> Payment.

The pure DAG is destroyed, replaced by a strongly connected component (cycle). Consequently, topological concurrency is rendered mathematically impossible.


3. Java Spring's Survival Mechanism: Single-Threaded DFS and 3-Tier Caching

How did Java Spring survive this cycle dilemma for two decades?

Spring architects recognized that cyclic graphs cannot be topologically sorted. They retreated to classical graph traversal: Single-threaded Depth-First Search (DFS) recursion paired with a Three-Tier Raw Pointer Cache.

[Level 1: singletonObjects]       -> Fully initialized, decorated beans
[Level 2: earlySingletonObjects]  -> Bare-allocated beans (properties not yet injected)
[Level 3: singletonFactories]    -> ObjectFactory wrapping early bean references
  1. When creating OrderService synchronously on a single thread, Spring allocates heap memory via new (properties remain null);
  2. Before property injection, Spring exposes the raw memory pointer of this incomplete bean into the Level-3 cache;
  3. Spring recurses into PaymentService, which requires OrderService. It extracts the incomplete raw pointer from the Level-3 cache and injects it;
  4. PaymentService finishes instantiation and unwinds the stack, allowing OrderService to complete.

Why Spring Never Dared Parallelize Bean Creation

Why must Spring's container bootstrap remain strictly serial?

The JVM Concurrency Reality: The three-tier cache depends entirely on the deterministic call stack of single-threaded recursive backtracking. If multiple threads concurrently created beans, Thread A could expose an incomplete bean pointer whose fields are unpopulated and whose AOP proxies are unlinked. If Thread B read that half-baked bean concurrently and invoked a method, the Java Memory Model (JMM) would trigger memory visibility corruptions, race conditions, and catastrophic NullPointerExceptions.

Conclusion: To accommodate cycles and 3-tier caches, Spring was forced to abandon concurrency, locking itself into a strictly serial pipeline.


4. The Collapse in Node.js: Why NestJS Was Forced into a Serial Loop

When NestJS attempted to duplicate Spring's architecture inside the TypeScript / Node.js ecosystem, it ran into an insurmountable barrier: The Asynchronous Event Loop.

1. Promises Cannot Serve as Early Raw Pointer Proxies

In Java, memory allocation is synchronous and raw pointers are physically tangible. In modern TypeScript backends, infrastructure initialization is overwhelmingly asynchronous (useFactory / async):

typescript
{
  provide: 'DATABASE_CONNECTION',
  useFactory: async () => {
    return await createConnection(); // Returns a pending Promise<T>
  }
}

A pending Promise possesses no underlying business instance in memory. You cannot hand an un-resolved Promise to another class as a "half-baked bean" expecting normal synchronous property access. forwardRef(() => ...) paired with async factories causes immediate deadlocks or runtime crashes (undefined is not a function).

2. NestJS Kernel Surrender: Hardcoded Serial Pipelines

Unable to build an asynchronous three-tier cache and unable to resolve async cycles, NestJS core maintainers made a pragmatic decision: Abandon topological concurrent scheduling entirely.

Inspecting the NestJS core source code in packages/core/injector/instance-loader.ts reveals the reality:

typescript
// NestJS core source implementation: Strictly serial for...of loop
for (const [key, wrapper] of providers) {
  await this.loadProvider(wrapper, moduleRef); // Awaits each provider one by one!
}

No matter how independent your async providers are, NestJS forces them into a single-file queue. Cold-start duration is forced to sum linearly: $\sum t_i$.


5. Can Userland Manual Lookup (ModuleRef.get) Fix NestJS?

Senior developers often ask:

"If constructor injection causes cycles, what if I don't inject services into constructors? In Spring, we frequently encapsulate an ApplicationContext.getBean() lookup utility to fetch dependencies dynamically at invocation time. If I use this.moduleRef.get(ServiceB) inside NestJS methods, can I unlock topological concurrency?"

Answer: It fixes your DX and kills forwardRef(), but it cannot fix NestJS's serial startup.

DimensionUserland ModuleRef.get() LookupDoes It Save NestJS Concurrency?
Eliminating Cycles100% Effective. Breaks constructor-level graph cycles; removes forwardRef.Only decouples userland classes; does not alter framework bootstrap.
Constructor Bloat100% Effective. Eliminates constructor parameter sprawl.Improves code readability and local modularity.
Topological Concurrency0% Effective.NestJS bootstrap is driven by centralized module scanning. Every provider registered in @Module({ providers: [...] }) is still evaluated by the hardcoded for...of await loop. The engine lacks a DAG concurrent scheduler.
Lazy Invocation LoadingTriggers Thundering Herd stampedes.If you defer connection initialization to runtime method calls, an incoming spike of 1,000 concurrent requests will trigger simultaneous connection attempts, exhausting pool limits and destroying P99 latencies.

6. The Clean Architecture: Path-IoC's Orthogonal Separation

If the root cause of the DI dilemma is conflating invocation dependencies with instantiation prerequisites, the solution is mathematically straightforward: Decouple them orthogonally.

Path-IoC discards class constructor DI in favor of pure function closures and physical path contracts:

typescript
// 1. Instantiation Prerequisites: Strictly declares bootstrap order (Guarantees pure DAG)
export const dependencies = ["dbMigration", "redisClient"];

// 2. Invocation Dependency Lookup (DL): Pure function closure destructuring at runtime
export const main = (container: ModularContainer) => {
  const { redisClient, orderService } = container;

  return {
    async handlePayment(orderId: string) {
      // Free to call orderService at runtime without affecting bootstrap order!
      return orderService.complete(orderId);
    }
  };
};

Why Path-IoC Achieves True Topological Concurrency

  1. 100% Mathematically Pure DAG: The dependencies array expresses only physical startup sequencing (e.g., migrations must run before database connections). Runtime method calls never enter the bootstrap graph. The probability of cycles drops to zero.
  2. Microsecond Kahn Compilation: With a pure DAG, Path-IoC's compiler performs Kahn topological sorting and tier calculation in 21 microseconds for 50+ nodes.
  3. Native Tier-Wise Promise.all Cascading Activation:
    • Independent asynchronous nodes (DB, Redis, Config) in the same tier are dispatched simultaneously.
    • Bootstrap latency drops from cumulative Sum(t) to the bottleneck node duration: Max(t).
  4. Zero Three-Tier Caches, Zero Deadlocks: When main(container) activates, all declared dependencies are 100% resolved. Destructuring is safe, immediate, and fully type-inferred.

7. Architectural Summary

Architectural DimensionTraditional DI (Spring / NestJS)Topological IoC-DL (Path-IoC)
Dependency DeclarationBound into class constructor argumentsExplicit DAG array + closure lookup (DL)
Graph TopologyHigh cycle frequency (pseudo-cycles)Guaranteed 100% pure DAG
Bootstrap SchedulingSingle-threaded serial queue (Sum(t))Native DAG tier-wise concurrency (Max(t))
Cycle MitigationsSpring 3-tier cache / NestJS deadlocksZero hacks needed (DFS Fail-Fast validation)
Serverless & Cold StartDegrades linearly with provider countInstantaneous (21µs graph compile + Max(t))

Dependency Injection was engineered 20 years ago for Java 1.4—a statically typed, object-locked language devoid of first-class functions. Transposing its constructor constraints onto the asynchronous, single-threaded Event Loop of TypeScript produced a cascade of performance penalties.

By strictly separating instantiation prerequisites from invocation lookup, we return graph theory to the compiler and runtime ergonomics to pure closures.

Released under the MIT License.