Skip to content

Circular Dependencies

Circular dependencies occur when two or more services depend on each other, creating a cycle. This can lead to infinite loops or stack overflow errors during service resolution.

Understanding Circular Dependencies

Example of Circular Dependency

typescript
class ServiceA {
  constructor(private serviceB: ServiceB) {}
}

class ServiceB {
  constructor(private serviceA: ServiceA) {} // Circular!
}

In this example:

  • ServiceA depends on ServiceB
  • ServiceB depends on ServiceA
  • This creates a cycle: ServiceA → ServiceB → ServiceA → ...

How .NET Core Handles Circular Dependencies

.NET Core's dependency injection container (Microsoft.Extensions.DependencyInjection) does not automatically resolve circular dependencies. In most cases, circular dependencies will cause:

  • Stack overflow exceptions during service resolution
  • Infinite loops when trying to resolve services
  • Application startup failures

.NET Core Behavior

  1. Singleton Services: May sometimes work due to object reference creation order, but not guaranteed and can still fail
  2. Scoped Services: Typically fails with stack overflow
  3. Transient Services: Always fails with stack overflow

Example in .NET Core

csharp
// This will typically FAIL in .NET Core
public class ServiceA
{
    public ServiceA(ServiceB serviceB) { }
}

public class ServiceB
{
    public ServiceB(ServiceA serviceA) { } // Circular!
}

// Registration
services.AddSingleton<ServiceA>();
services.AddSingleton<ServiceB>();

// Resolution will likely throw:
// System.InvalidOperationException: A circular dependency was detected
// or StackOverflowException

Key Differences

Aspect.NET Core@nodelibraries/ioc
DetectionRuntime (throws exception)✅ Runtime (via resolution stack)
Singleton Support⚠️ May fail✅ Works
Scoped Support❌ Fails✅ Works (within scope)
Transient Support❌ Fails✅ Works (within same resolution)
Error MessagesStack overflow or exception✅ Clear error messages

How @nodelibraries/ioc Handles Circular Dependencies

Current Behavior

@nodelibraries/ioc now supports circular dependencies for all service lifetimes (Singleton, Scoped, and Transient). The container uses a resolution stack mechanism to detect and handle circular dependencies:

  • Singleton services - Circular dependencies are fully supported
  • Scoped services - Circular dependencies work within the same scope
  • Transient services - Circular dependencies work within the same resolution call (each new call creates a new instance, maintaining transient behavior)

How It Works

The container maintains a resolution stack that tracks services currently being constructed:

  1. When resolving a service, it's added to the resolution stack
  2. If a dependency is already in the resolution stack (circular dependency detected):
    • Returns the partially constructed instance (works for all lifetimes)
    • For Transient: The same instance is used within the same resolution call, but each new getRequiredService call creates a new instance
  3. Once construction is complete, the service is removed from the resolution stack

Example: Working Circular Dependency (Singleton)

typescript
const services = new ServiceCollection();
const ServiceAToken = Symbol('ServiceA');
const ServiceBToken = Symbol('ServiceB');

class ServiceA {
  constructor(private serviceB: ServiceB) {}

  getName(): string {
    return 'ServiceA';
  }

  getServiceBName(): string {
    return this.serviceB.getName();
  }
}

class ServiceB {
  constructor(private serviceA: ServiceA) {}

  getName(): string {
    return 'ServiceB';
  }

  getServiceAName(): string {
    return this.serviceA.getName();
  }
}

services.addSingleton(ServiceAToken, ServiceA, [ServiceBToken]);
services.addSingleton(ServiceBToken, ServiceB, [ServiceAToken]);

const provider = services.buildServiceProvider();

// ✅ This now works! Circular dependency is handled automatically
const serviceA = await provider.getRequiredService<ServiceA>(ServiceAToken);
const serviceB = await provider.getRequiredService<ServiceB>(ServiceBToken);

console.log(serviceA.getName()); // "ServiceA"
console.log(serviceB.getName()); // "ServiceB"
console.log(serviceA.getServiceBName()); // "ServiceB"
console.log(serviceB.getServiceAName()); // "ServiceA"

Example: Transient Circular Dependency (Now Works!)

typescript
// Transient services now support circular dependencies
services.addTransient(ServiceAToken, ServiceA, [ServiceBToken]);
services.addTransient(ServiceBToken, ServiceB, [ServiceAToken]);

const provider = services.buildServiceProvider();

// ✅ This now works! Circular dependency is handled within the same resolution call
const serviceA1 = await provider.getRequiredService<ServiceA>(ServiceAToken);
const serviceB1 = await provider.getRequiredService<ServiceB>(ServiceBToken);

console.log(serviceA1.getName()); // "ServiceA"
console.log(serviceB1.getName()); // "ServiceB"
console.log(serviceA1.getServiceBName()); // "ServiceB"
console.log(serviceB1.getServiceAName()); // "ServiceA"

// Each new call creates a new instance (transient behavior)
const serviceA2 = await provider.getRequiredService<ServiceA>(ServiceAToken);
console.log(serviceA1 === serviceA2); // false - different instances

Solutions and Best Practices

1. Refactor to Break the Cycle

The best solution is to refactor your code to eliminate the circular dependency:

Option A: Extract Common Interface

typescript
// Create a common interface
interface ICommonService {
  doSomething(): void;
}

class ServiceA implements ICommonService {
  constructor(private serviceB: ServiceB) {}

  doSomething() {
    this.serviceB.doSomething();
  }
}

class ServiceB implements ICommonService {
  constructor(private commonService: ICommonService) {} // No cycle!

  doSomething() {
    // Implementation
  }
}

Option B: Use Dependency Inversion

typescript
// ServiceA depends on an interface
interface IServiceB {
  doSomething(): void;
}

class ServiceA {
  constructor(private serviceB: IServiceB) {}
}

class ServiceB implements IServiceB {
  // ServiceB doesn't need ServiceA anymore
  doSomething() {
    // Implementation
  }
}

Option C: Introduce a Mediator

typescript
// Create a mediator service
class ServiceMediator {
  constructor(private serviceA: ServiceA, private serviceB: ServiceB) {}

  // Coordinate between services
  coordinate() {
    // Implementation
  }
}

class ServiceA {
  // No direct dependency on ServiceB
}

class ServiceB {
  // No direct dependency on ServiceA
}

2. Use Lazy Initialization

For cases where you truly need bidirectional communication, consider lazy initialization:

typescript
class ServiceA {
  private serviceB?: ServiceB;

  setServiceB(serviceB: ServiceB) {
    this.serviceB = serviceB;
  }

  doSomething() {
    if (this.serviceB) {
      this.serviceB.doSomething();
    }
  }
}

class ServiceB {
  constructor(private serviceA: ServiceA) {}

  doSomething() {
    this.serviceA.doSomething();
  }
}

// Register and wire manually
const services = new ServiceCollection();
services.addSingleton(ServiceAToken, ServiceA);
services.addSingleton(ServiceBToken, ServiceB, [ServiceAToken]);

const provider = services.buildServiceProvider();
const serviceA = await provider.getRequiredService<ServiceA>(ServiceAToken);
const serviceB = await provider.getRequiredService<ServiceB>(ServiceBToken);

// Manually wire the circular reference
serviceA.setServiceB(serviceB);

3. Use Factory Pattern

Factories can help break circular dependencies by deferring instantiation:

typescript
const services = new ServiceCollection();

services.addSingleton(ServiceAToken, (provider) => {
  const serviceB = provider.getRequiredService<ServiceB>(ServiceBToken);
  return new ServiceA(serviceB);
});

services.addSingleton(ServiceBToken, (provider) => {
  const serviceA = provider.getRequiredService<ServiceA>(ServiceAToken);
  return new ServiceB(serviceA);
});

Note: This approach may still cause issues depending on the resolution order.

Detection and Prevention

Manual Detection

Before registering services, review your dependency graph:

  1. Draw a dependency graph - Visualize service dependencies
  2. Look for cycles - Check if any path leads back to itself
  3. Test in isolation - Create minimal examples to test dependencies

Future Improvements

Future versions of @nodelibraries/ioc may include:

  • Resolution stack tracking - ✅ Implemented! Circular dependencies are now supported for Singleton and Scoped services
  • Partial instance support - ✅ Implemented! Partially constructed instances are used for circular dependencies
  • 🔄 Build-time detection - Detect circular dependencies when building the provider (optional validation)
  • 🔄 Clear error messages - Show the dependency cycle path in error messages
  • 🔄 Graph visualization - Help developers understand their dependency structure

Summary

ApproachProsCons
RefactorClean architecture, no cyclesRequires code changes
Lazy InitWorks for bidirectional needsManual wiring required
FactoryFlexibleMay still have issues
MediatorDecouples servicesAdditional abstraction layer

Best Practices

  1. Design with dependency direction in mind - Prefer one-way dependencies
  2. Use interfaces - Depend on abstractions, not concrete classes
  3. Keep dependencies minimal - Fewer dependencies = fewer cycles
  4. Review dependency graphs - Regularly check for potential cycles
  5. Avoid circular dependencies - Refactor when possible

Released under the ISC License. If you find this project helpful, consider buying me a coffee