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 manyBooks
, and aBook
can have oneAuthor
. 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 anAuthorService
that's consumed by theBookModule
. But if theBookModule
then tries to import something from theAuthorModule
— and theAuthorModule
already depends on theBookModule
— 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 onB
, not the other way around. -
Acyclic: There are no cycles. You cannot start at
A
, follow the dependencies, and end up back atA
.
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
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").
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.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 {}
src/books/books.module.ts
import { Module } from '@nestjs/common'
import { BooksService } from './books.service'
@Module({
providers: [BooksService],
exports: [BooksService],
})
export class BooksModule {}
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 {}
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>;
}
src/publishing/interfaces/publishable-service-token.ts
export const PUBLISHABLE_SERVICE_TOKEN = Symbol('PUBLISHABLE_SERVICE');
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);
}
}
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);
}
}
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 {}
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);
}
}
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.