Skip to content
ExponaBLOG
NESTJS · DAY 084 / 100

Circular dependencies in NestJS: lessons from production

Why they happen and how to remove them properly.

Expona Admin6 min read

Circular dependencies in NestJS: When modules depend on each other

You're scaling a NestJS application, and suddenly the application refuses to start with a cryptic error about unresolved dependencies. You trace it back and discover that ModuleA needs ModuleB, but ModuleB also needs ModuleA—a circular dependency. It's a common pain point that forces you to make quick decisions, and those decisions often lead you down the wrong path.

The symptom: "Nest cannot resolve dependencies"

Circular dependencies manifest in predictable ways. During startup, NestJS performs dependency injection in a specific order. When it encounters a provider that depends on another provider that, in turn, depends on the first one, the framework cannot determine which provider to instantiate first. This results in one of two symptoms:

  • A hard error: Nest cannot resolve dependencies of the AuthService (?). Please make sure that argument at index [0] is available in the AuthModule context.
  • Silent failures: A provider is injected as undefined, and errors surface deep in your business logic rather than at startup.

Let me show you a real example. Imagine you're building a SaaS platform with user authentication and subscription management:

typescript
// auth.service.ts
@Injectable()
export class AuthService {
  constructor(private subscriptionService: SubscriptionService) {}

  async validateUser(userId: string) {
    // Check if user is subscribed before allowing access
    const subscription = await this.subscriptionService.getActive(userId);
    return subscription !== null;
  }
}

// subscription.service.ts
@Injectable()
export class SubscriptionService {
  constructor(private authService: AuthService) {}

  async getActive(userId: string) {
    // Verify token before returning subscription
    const user = await this.authService.getCurrentUser();
    return this.db.subscription.findUnique({ where: { userId } });
  }
}

Both services need each other. NestJS cannot break this deadlock, and your application fails to start. The error message points at the symptom, not the root cause: a design problem.

forwardRef is a band-aid: It works, but it masks the real issue

NestJS provides forwardRef() to handle circular dependencies. It delays resolution until both providers are registered, which technically solves the error:

typescript
// auth.service.ts
@Injectable()
export class AuthService {
  constructor(
    @Inject(forwardRef(() => SubscriptionService))
    private subscriptionService: SubscriptionService,
  ) {}

  async validateUser(userId: string) {
    const subscription = await this.subscriptionService.getActive(userId);
    return subscription !== null;
  }
}

Your application starts. The error vanishes. You move on to the next feature. Three months later, you're debugging a race condition or a subtle initialization bug that stems from this very decision. I've watched this happen on real production platforms.

Here's why forwardRef() is a band-aid: it doesn't address the underlying architectural problem. It tells NestJS to ignore the issue, not to fix it. The circular dependency still exists in your codebase; you've just made it invisible. Future developers (including yourself) will spend time tracing how dependencies actually flow. It also makes testing harder because you need to carefully manage which service initializes first.

Use forwardRef() only as a last resort, and even then, treat it as a red flag for a refactor.

Extract the shared part: Move logic into a third provider

The cleanest solution is to identify what both services actually need and extract that into a separate provider. In our auth and subscription example, the core issue is that AuthService needs to validate subscriptions, and SubscriptionService needs to validate auth tokens. Both depend on user identity verification.

Extract a UserContextService:

typescript
// user-context.service.ts
@Injectable()
export class UserContextService {
  constructor(private db: DatabaseService) {}

  async getUserById(userId: string) {
    return this.db.user.findUnique({ where: { id: userId } });
  }

  async getUserWithSubscription(userId: string) {
    return this.db.user.findUnique({
      where: { id: userId },
      include: { subscription: true },
    });
  }
}

// auth.service.ts
@Injectable()
export class AuthService {
  constructor(private userContext: UserContextService) {}

  async validateUser(userId: string) {
    const user = await this.userContext.getUserWithSubscription(userId);
    return user?.subscription?.active ?? false;
  }
}

// subscription.service.ts
@Injectable()
export class SubscriptionService {
  constructor(private userContext: UserContextService) {}

  async getActive(userId: string) {
    const user = await this.userContext.getUserWithSubscription(userId);
    return user?.subscription ?? null;
  }
}

Now both AuthService and SubscriptionService depend only on UserContextService. No cycles. The dependency graph is a tree, not a graph with loops. This is testable, maintainable, and debuggable.

When to use this approach: When the circular dependency exists because two services share common business logic or data access patterns. This is the most common case in real applications.

Use events: Decouple modules through asynchronous messaging

Sometimes services don't need to call each other synchronously. They're really responding to business events. In those cases, decouple them using NestJS's built-in EventEmitter2 or a message broker like Kafka.

Consider a scenario where AuthService emits a "user.authenticated" event, and SubscriptionService listens to that event to perform some validation or logging:

typescript
// auth.service.ts
import { EventEmitter2 } from '@nestjs/event-emitter';

@Injectable()
export class AuthService {
  constructor(private eventEmitter: EventEmitter2) {}

  async validateUser(userId: string) {
    const isValid = await this.checkCredentials(userId);
    if (isValid) {
      this.eventEmitter.emit('user.authenticated', { userId });
    }
    return isValid;
  }
}

// subscription.service.ts
import { OnEvent } from '@nestjs/event-emitter';

@Injectable()
export class SubscriptionService {
  constructor(private db: DatabaseService) {}

  @OnEvent('user.authenticated')
  async handleUserAuthenticated(payload: { userId: string }) {
    const user = await this.db.user.findUnique({
      where: { id: payload.userId },
    });
    // Perform subscription-related work
    console.log(`User ${payload.userId} authenticated, subscription status: active`);
  }
}

Now AuthService doesn't know SubscriptionService exists. SubscriptionService listens to an event. Both modules are independent. They can be deployed separately, tested in isolation, and scaled independently.

When to use this approach: When the interaction is event-driven or when you want to add new handlers without modifying existing code. Common in e-commerce (order placed → send confirmation email → update inventory), ride-hailing (driver accepted → notify passenger → update location), and trading platforms (price changed → recalculate portfolio → trigger alerts).

For truly distributed systems, replace EventEmitter2 with Kafka or RabbitMQ. The pattern remains the same; the infrastructure scales differently.

Common mistakes

  • Using forwardRef without questioning the design: It feels like a quick win, but you're storing up technical debt. Ask yourself: "Do these services really need to depend on each other?" Often, the answer is no.
  • Over-extracting shared logic: Don't create a god service that knows about everything. If you extract UserContextService and it ends up with 30 methods, you've overcorrected. Services should have a single, clear responsibility.
  • Mixing events and direct injection: If AuthService calls SubscriptionService and also emits an event that SubscriptionService listens to, you've created two paths of communication. Choose one. Be explicit about the contract.
  • Ignoring the symptom at module boundaries: Circular dependencies often hint that your module structure is misaligned with your domain. Consider if AuthModule and SubscriptionModule should actually be a single module or if they should live in different layers of your application.

Key takeaways

  • Circular dependencies are a symptom of a design problem, not a framework limitation. Fix the design, not the error.
  • forwardRef() solves the immediate problem but masks the underlying issue. Use it sparingly and only as a temporary measure.
  • Extract shared logic into a third provider when two services both need the same operation or data. This keeps the dependency graph acyclic.
  • Use events or a message broker to decouple services that respond to business events. This is especially powerful in distributed systems and event-driven architectures.
  • Evaluate your module structure. Circular dependencies often indicate that your modules don't align with your domain boundaries.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles