Skip to main content

Circular Dependencies

A circular dependency is a cycle in the constructor graph: A's constructor needs B, B's constructor needs A. The container cannot construct either without the other being already constructed. Nexus detects this and throws CircularDependencyError.

import { CircularDependencyError, forwardRef } from '@omnitron-dev/titan/nexus';

What you see

CircularDependencyError: cycle in container graph
UsersService → SessionService → AuthService → UsersService

The error names every node in the cycle, in order.

Why cycles are bad

Beyond "the container cannot do its job":

  • Construction order is undefined. If both A and B could be constructed in some order, that order leaks into runtime semantics. Tests pass under one order, fail under another.
  • Refactoring is fragile. A method moves from A to B and the cycle suddenly closes. The build breaks and nobody knows why.
  • Architectural smell. Two modules that mutually depend usually have a shared concept they both need to know about. Extracting it is almost always the right move.

Three ways to break a cycle

1. Extract the shared concept

Most cycles hide a third concept that should be its own module.

// Before — cycle:
// UsersService → AuthService
// AuthService → UsersService

// After — extract:
class UsersDirectory { /* read-only user lookup */ }

class UsersService { constructor(private dir: UsersDirectory, private auth: AuthService) {} }
class AuthService { constructor(private dir: UsersDirectory) {} }
// Now AuthService no longer depends on UsersService.

This is the cleanest fix. The third concept is usually a "directory", "registry", "context", or "core" of the domain.

2. Invert the dependency

If A and B genuinely need each other at runtime, restructure so the constructor dependency goes one way:

// Before:
class A { constructor(private b: B) {} foo() { this.b.bar(); } }
class B { constructor(private a: A) {} bar() { this.a.foo(); } }

// After: B publishes a callback, A injects nothing from B.
interface BarHandler { bar(): void; }

class A {
private barHandler?: BarHandler;
registerBarHandler(h: BarHandler) { this.barHandler = h; }
foo() { this.barHandler?.bar(); }
}

class B {
constructor(private a: A) { a.registerBarHandler(this); }
bar() { /* … */ }
}

3. Lazy resolution with forwardRef

forwardRef defers reference resolution until first use, breaking the construction-time cycle:

import { forwardRef, Inject } from '@omnitron-dev/titan/nexus';

class UsersService {
constructor(
@Inject(forwardRef(() => AuthService)) private auth: AuthService,
) {}
}

The container resolves AuthService when the field is first accessed, not when UsersService is constructed. Use sparingly — it hides the wiring; future readers will struggle to trace the graph.

Lazy tokens (createLazyToken) serve the same purpose with a different API.

Cycles between modules vs cycles between providers

ErrorWhereDetection
Module import cycle@Module({ imports: [...] })Module discovery (boot time)
Provider constructor cycleConstructor signaturesContainer graph analysis

Both throw at boot. Both name the cycle. Both have the same three fixes.

Avoiding cycles in design

Layer your modules

Sort modules into layers: core (no domain deps), domain (depends on core), feature (depends on domain). A cycle implies a layer violation.

┌─ feature: BillingModule, UsersModule, OrdersModule
├─ domain: AuthModule
└─ core: LoggerModule, ConfigModule, DatabaseModule

Lower layers can be imported by higher layers, never the reverse.

Domain types in their own module

If both UsersModule and OrdersModule need a User type, put the type in a UsersTypesModule (or a shared domain/users.types file that exports types but no providers). Both modules import the types without depending on the implementation.

Diagnosing a cycle in a large graph

When the cycle path is long, use DevTools to render the full container graph and visually locate the cycle.

→ Next: DevTools.