DEV Community

Cover image for NestJS - Circular Dependency Hell and How to Avoid it
Scott Molinari
Scott Molinari

Posted on • Edited on

NestJS - Circular Dependency Hell and How to Avoid it

Before you begin, if you haven't read my other article about project structure, please do. It is important in your journey to learning Nest.

The Conundrum

Many development teams, especially those building CRUD apps, frequently run into the dreaded circular dependency error. That cryptic message signals a deeper issue in your application’s architecture. A circular dependency is a code smell because it makes your modules tightly coupled, difficult to test and reuse, and hard to refactor.

So, why does this happen to so many of us? The most common reason is a simple, yet fundamental, mental model error.


The Mental Model Mistake: Confusing the Data in the Database with a Module's "Work"

This is the central flaw that leads to most circular dependencies in NestJS. Developers building applications often try to model their database relationships directly in their modules.

  • Database relationships can be bi-directional: An Author can have many Books, and a Book can have one Author. This is a two-way relationship that a database and ORMs are designed to handle with ease.
  • Module dependencies must be uni-directional: An AuthorModule can expose an AuthorService that's consumed by the BookModule. But if the BookModule then tries to import something from the AuthorModule— and the AuthorModule already depends on the BookModule— you've created a cycle. I'm absolutely certain everyone has faced this.

Your application's modules are not a mirror of your database. Their purpose is to encapsulate functionality, and their dependencies should reflect the flow of application logic, not the structure of your data.


The Right Mental Model: Modules as a City with One-Way Streets

Let's use your application as an analogy of a city. But, instead of thinking about your city with two-way streets, picture them as a city with strictly one-way streets. Each module is a neighborhood in the city (e.g., UserModule, AuthModule, AuthorModule, BookModule, etc.), and the dependencies are the roads. A car can travel from the BookModule neighborhood to the AuthorModule to get author information, yet that same car cannot travel from AuthorModule back to the BookModule.

What you are visualizing with your module dependencies in this city of one-way roads is a directed acyclic graph (DAG): .

  • Directed: The relationships flow in a single direction. A depends on B, not the other way around.
  • Acyclic: There are no cycles. You cannot start at A, follow the dependencies, and end up back at A.

The Package Carrier Analogy: Your Delivery Route in the City

This is where your NestJS application becomes a delivery service. Think of a request coming into your application as a package carrier starting a delivery route. The carrier enters the city and proceeds down the one-way streets, visiting each module to perform a task. The key rule is that the carrier never turns around and goes back to a house they've already visited.

The entire "delivery route" forms the directed acyclic graph. The carrier starts at the beginning (AppModule), proceeds through the dependencies, and at the end of the route, the last module sends a result back, confirming the "delivery is complete." This model reminds us that the flow of execution should always be forward and purposeful, never circling back on itself.


Practical Rules to Avoid the Cycle

  1. Define a Clear Hierarchy: Arrange your modules in layers. Core modules should be at the bottom, feature-specific modules in the middle, and entry-point modules at the top. Dependencies should only flow down the hierarchy. This principle is a cornerstone of architectural patterns like Clean Architecture, popularized by Robert C. Martin ("Uncle Bob").

  2. Separate Shared Logic: If two modules both need the same shared utility, create a third, separate UtilModule that both can import. This is the "extract common concerns" rule. These things go into a "common" or "shared" module.

  3. Use a Higher-Level Module to Orchestrate: Rather than having two modules directly depend on each other, create a higher-level module that depends on both. This module acts as the "middleman," orchestrating the flow of data without creating a circular dependency. This kind of module should be "doing things" and not representing a specific data(base) model.

A Concrete Example: Counting an Author's Books

Let's use the Author and Book example. We need to get the number of books an author has written.

  • AuthorsModule: Responsible for all things authors.
  • BooksModule: Responsible for all things books.

Instead of having AuthorsModule import BooksModule (to get the book count) and BooksModule import AuthorsModule (to find author info), we introduce a new, higher-level module: PublishingModule. This module acts as our "package carrier," orchestrating the request.

src/authors/authors.module.ts

import { Module } from '@nestjs/common'
import { AuthorsService } from './authors.service'

@Module({
  providers: [AuthorsService],
  exports: [AuthorsService],
})
export class AuthorsModule {}
Enter fullscreen mode Exit fullscreen mode

src/books/books.module.ts

import { Module } from '@nestjs/common'
import { BooksService } from './books.service'

@Module({
  providers: [BooksService],
  exports: [BooksService],
})
export class BooksModule {}
Enter fullscreen mode Exit fullscreen mode

src/publishing/publishing.module.ts

import { Module } from '@nestjs/common'
import { AuthorsModule } from '../authors/authors.module'
import { BooksModule } from '../books/books.module'
import { PublishingService } from './publishing.service'
import { PublishingResolver } from './publishing.resolver'

@Module({
  imports: [
    AuthorsModule,
    BooksModule,
  ],
  providers: [PublishingService, PublishingResolver],
})
export class PublishingModule {}
Enter fullscreen mode Exit fullscreen mode

The PublishingModule correctly models the package carrier's route. It orchestrates the process by visiting the AuthorsModule to get the author and then the BooksModule to get the books, all while maintaining a unidirectional dependency flow. The AuthorsModule and BooksModule know nothing about the PublishingModule and remain decoupled and reusable.


Taking it a Step Further: Abstraction with an Interface

The concrete example above is a great starting point, but what if our application grows? What if we add new content types like Blogs or Articles? We would have to update our PublishingModule to import BlogsModule, ArticlesModule, and so on, making the module cluttered and difficult to manage.

This is where the power of abstraction comes in. Instead of depending on concrete implementations, we can rely on a shared contract, or interface. This makes our code more flexible and scalable.

1. Define the Interface and a Shared Token

First, you need a shared interface to establish a common contract for all your services. This provides type safety. Additionally, define a unique token using a Symbol to avoid naming conflicts and serve as the key for your injection.

src/publishing/interfaces/publishable.interface.ts

export interface IPublishable {
  getPublishableType(): string;
  getContentCountByAuthorId(authorId: string): Promise<number>;
}
Enter fullscreen mode Exit fullscreen mode

src/publishing/interfaces/publishable-service-token.ts

export const PUBLISHABLE_SERVICE_TOKEN = Symbol('PUBLISHABLE_SERVICE');
Enter fullscreen mode Exit fullscreen mode

2. Implement the Interface in Each Service

Each of your services, like BooksService and a new BlogsService, will implement the IPublishable interface. They will each have a getPublishableType() method to uniquely identify themselves.

src/books/books.service.ts

import { Injectable } from '@nestjs/common';
import { IPublishable } from '../publishing/interfaces/publishable.interface';

@Injectable()
export class BooksService implements IPublishable {
  getPublishableType(): string {
    return 'book';
  }

  getContentCountByAuthorId(authorId: string): Promise<number> {
    // Logic to get book count
    return Promise.resolve(10);
  }
}
Enter fullscreen mode Exit fullscreen mode

src/blogs/blogs.service.ts

import { Injectable } from '@nestjs/common';
import { IPublishable } from '../content/interfaces/publishable.interface';

@Injectable()
export class BlogsService implements IPublishable {
  getPublishableType(): string {
    return 'blog';
  }

  getContentCountByAuthorId(authorId: string): Promise<number> {
    // Logic to get blog count
    return Promise.resolve(25);
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Create a Multi-Provider Factory in the Central Module

This is the most crucial step. Instead of each module exporting a provider with the same token, you'll create a single, central module that gathers all the individual services and provides them as an array under the shared token. This prevents the previous providers from being overwritten.

In this pattern, the central module knows about all the concrete implementations and orchestrates their provision. Other modules, like BooksModule and BlogsModule, can be simple and focused on their specific business logic.

src/publishing/publishing.module.ts

import { Module } from '@nestjs/common';
import { PUBLISHABLE_SERVICE_TOKEN } from './interfaces/publishable-service-token';
import { BooksService } from '../books/books.service';
import { BlogsService } from '../blogs/blogs.service';
import { PublishingService } from './publishing.service';

@Module({
  imports: [], 
  providers: [
    BooksService,
    BlogsService,
    {
      provide: PUBLISHABLE_SERVICE_TOKEN,
      useFactory: (booksService: BooksService, blogsService: BlogsService) => {
        return [booksService, blogsService];
      },
      inject: [BooksService, BlogsService],
    },
    PublishingService,
  ],
  exports: [PUBLISHABLE_SERVICE_TOKEN],
})
export class PublishingModule {}
Enter fullscreen mode Exit fullscreen mode

4. Inject and Use the Array in the Service

Now, your PublishingService can correctly inject the array of IPublishable services. NestJS's container will use the factory we defined to provide a single array containing all the services. This allows you to iterate over them and perform actions polymorphically.

src/publishing/publishing.service.ts

import { Injectable, Inject } from '@nestjs/common';
import { IPublishable } from '../content/interfaces/publishable.interface';
import { PUBLISHABLE_SERVICE_TOKEN } from './token';

@Injectable()
export class PublishingService {
  constructor(
    @Inject(PUBLISHABLE_SERVICE_TOKEN)
    private readonly publishableServices: IPublishable[],
  ) {}

  async getAuthorTotalContentCount(authorId: string): Promise<number> {
    const counts = await Promise.all(
      this.publishableServices.map(service =>
        service.getContentCountByAuthorId(authorId),
      ),
    );
    return counts.reduce((sum, count) => sum + count, 0);
  }

  async getAuthorCountByPublishableType(authorId: string, type: string): Promise<number> {
    const service = this.publishableServices.find(s => s.getPublishableType() === type);

    if (!service) {
      throw new Error(`No service found for publishable type: ${type}`);
    }

    return service.getContentCountByAuthorId(authorId);
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the real power of the one-way street analogy. Our PublishingService doesn't care if the content is a book, a blog, or a new content type we create next week. It only cares that it can talk to a service that fulfills the IPublishable contract, maintaining a clean, decoupled architecture. This new method shows how the carrier can use a key ('book') to bypass all other modules and go straight to the one it needs, all while following the one-way streets.


The Bottom Line

The next time you're building a new module, pause for a moment. Instead of thinking about data retrieval ("I need to get posts for this user"), think about the process being accomplished ("I need to get all content published by this author"). This subtle but powerful shift in perspective, combined with the one-way street mind-frame, will guide you toward a clean, maintainable, and scalable architecture. And you'll finally avoid the hair pulling issue of circular dependency hell.

How do you avoid circular dependencies? Or how do you work to make your modules even less dependent? Let me know in the comments below.

Top comments (2)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.