Introduction
One of the beauties of NestJS is that it allows us to separate concerns within our applications. The NestJS architecture favors creating the logic layer as dependencies (i.e., services) that can be consumed by the access layers (i.e., controllers).
Even though NestJS has a built-in dependency injection system that takes care of resolving dependencies needed in different parts of our code, care must still be taken when working with dependencies. One of the common problems encountered in this regard is circular dependencies. NestJS code will not even compile if there is an unresolved circular dependency.
In this article, we will be learning about circular dependencies in NestJS, why they arise, and how we can avoid them. Rather than just presenting the workaround NestJS provides, I will be walking us through how to avoid circular dependencies by rethinking how we couple dependencies, which will be helpful for better architecture in our backend code.
Avoiding circular dependencies also ensures that our code is easier to understand and modify, because a circular dependency means there is tight coupling in our code.
Contents
- What is a circular dependency?
- The NestJS dependency injection system
- How circular dependency issues arise
- Avoiding circular dependencies by refactoring
- Working around circular dependencies with forward references
What is a circular dependency?
In programming, a circular dependency occurs when two or more modules (or classes), directly or indirectly depend on one other. Say A
, B
, C
and D
are four modules, an example of direct circular dependency is A
→B
→A
. Module A
depends on module B
which in turn depends on A
.
An example of indirect circular dependency is A
→ B
→ C
→ A
. Module A
depends on B
which doesn’t depend on A
directly, but later on in its dependency chain references A
.
Note that the concept of circular dependency is not unique to NestJS, and in fact, the modules used here as example don’t even have to be NestJS modules. They simply represent the general idea of modules in programming, which refers to how we organize code.
Before we talk about circular dependencies (and how to avoid them) in NestJS, let us first discuss how the NestJS dependency injection system works.
Knowing how NestJS handles dependency injection will make it easier to understand how a circular reference can occur within our dependencies and why NestJS compile can’t compile until the circular reference is resolved.
The NestJS dependency injection system
In NestJS, with dependency injection (DI) we can delegate instantiation of dependencies to the runtime system, instead of doing it imperatively in our own code.
For example, say we have a UserService
defined as follows:
import { Injectable } from '@nestjs/common'; @Injectable() export class UserService { constructor() {} public async getUserById(userId: string) { ... } ... }
Now, say we use the UserService
as follows in the UserController
class:
import { Controller } from '@nestjs/common'; import { UserService } from './user.service'; @Controller('user') export class UserController { constructor(private readonly userService: UserService) {} public async getUserById(userId: string) { return await this.userService.getUserById(userId); } }
In this case, UserController
is an enquirer asking for the UserService
as one of its dependencies. The NestJS dependency injector will check for the requested dependency in a container, where it stores references to providers defined in the NestJS project.
The @Injectable()
decorator that was used in the UserService
definition marks the class as a provider that should be injectable by the NestJS dependency injection system, i.e., it should be managed by the container. When the TypeScript code is compiled by the compiler, this decorator emits metadata that the NestJS uses to manage dependency injection.
In NestJS, each module has its own injector that can access the container. When you declare a module, you have to specify the providers that should be available to the module, except in cases where the provider is a global provider.
For example, the UserModule
is defined as follows:
import { Module } from '@nestjs/common'; import { UserService } from './user.service'; import { UserController } from './user.controller'; @Module({ providers: [UserService], controllers: [UserController], exports: [UserService], }) export class UserModule {}
In a normal mode of execution, when an enquirer asks for a dependency, the injector checks the container to see if an object of that dependency has been cached previously. If so, that object is returned to the enquirer. Otherwise, NestJS instantiates a new object of the dependency, caches it, and then returns the object to the enquirer.
The declaration providers: [UserService]
is actually a shorthand for the following:
providers: [ { provide: UserService, useClass: UserService, }, ]
The value of provide
is an injection token that is used to identify the provider when it’s been enquired.
How circular dependency issues arise
The NestJS DI system relies heavily on the metadata emitted by the TypeScript compiler, so when there is a circular reference between two modules or two providers, the compiler won’t be able to compile any of them without further help.
For example, say we have a FileService
that we are using to manage files uploaded to our application, defined as follows:
import { Injectable } from '@nestjs/common'; import { UserService } from '../user/user.service'; import { File } from './interfaces/file.interface'; @Injectable() export class FileService { constructor(private readonly userService: UserService) {} public getById(pictureId: string): File { // not real implementation return { id: pictureId, url: 'https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50', }; } public async getUserProfilePicture(userId: string): Promise<File> { const user = await this.userService.getUserById(userId); return this.getById(user.profilePictureId); } }
The service has a getUserProfilePicture
method that gets the image file attached as a user profile picture. The FileService
needs the UserService
injected as a dependency to be able to fetch a user.
The UserService
is also updated as follows:
import { Injectable } from '@nestjs/common'; import { FileService } from '../file-service/file.service'; @Injectable() export class UserService { constructor(private readonly fileService: FileService) {} public async getUserById(userId: string) { // actual work of retrieving user return { id: userId, name: 'Sam', profilePictureId: 'kdkf43', }; } public async addUserProfilePicture(userId: string, pictureId: string) { const picture = await this.fileService.getById(pictureId); // update user with the picture url return { id: userId, name: 'Sam', profilePictureId: picture.id }; } }
We have a circular dependency in this case, as both UserService
and FileService
depend on each other (UserService
→ FileService
→ UserService
).
With the circular reference in place, the code will fail to compile.
Avoiding circular dependencies by refactoring
The NestJS documentation advises that circular dependencies be avoided where possible.
Circular dependencies create tight couplings between the classes or modules involved, which means both classes or modules have to be recompiled every time either of them is changed. As I mentioned in a previous article, tight coupling is against the SOLID principles and we should endeavor to avoid it.
We can remove the circular dependency easily in this example. The circular reference we have can also be represented as the following:
UserService → FileService and FileService → UserService
To break the cycle, we can extract the common features from both services into a new service that depends on both services. In this case, we can have a ProfilePictureService
that depends on both UserService
and FileService
.
The ProfilePictureService
will have it’s own module defined as follows:
import { Module } from '@nestjs/common'; import { FileModule } from '../file-service/file.module'; import { UserModule } from '../user/user.module'; import { ProfilePictureService } from './profile-picture.service'; @Module({ imports: [FileModule, UserModule], providers: [ProfilePictureService], }) export class ProfilePictureModule {}
Note that this module imports both the FileModule
and the UserModule
. Both imported modules have to export the services we want to use in ProfilePictureService
.
The ProfilePictureService
will be defined as follows:
import { Injectable } from '@nestjs/common'; import { File } from '../file-service/interfaces/file.interface'; import { FileService } from '../file-service/file.service'; import { UserService } from '../user/user.service'; @Injectable() export class ProfilePictureService { constructor( private readonly fileService: FileService, private readonly userService: UserService, ) {} public async addUserProfilePicture(userId: string, pictureId: string) { const picture = await this.fileService.getById(pictureId); // update user with the picture url return { id: userId, name: 'Sam', profilePictureId: picture.id }; } public async getUserProfilePicture(userId: string): Promise<File> { const user = await this.userService.getUserById(userId); return this.fileService.getById(user.profilePictureId); } }
ProfilePictureService
requires both UserService
and FileService
as its dependencies, and contains methods performing the actions we were previously doing in both UserService
and FileService
.
The UserService
doesn’t need to depend on FileService
anymore, as you can see here:
import { Injectable } from '@nestjs/common'; @Injectable() export class UserService { public async getUserById(userId: string) { // actual work of retrieving user return { id: userId, name: 'Sam', profilePictureId: 'kdkf43', }; } }
Similarly, FileService
doesn’t need to know a thing about UserService
:
import { Injectable } from '@nestjs/common'; import { File } from './interfaces/file.interface'; @Injectable() export class FileService { public getById(pictureId: string): File { return { id: pictureId, url: 'https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50', }; } }
The relationship between the three services can now be represented as follows:
As you can see from the diagram, there is no circular reference among the services.
Although this example on refactoring is about circular dependency between providers, one can use the same idea to avoid circular dependency among modules.
Working around circular dependencies with forward references
Ideally, circular dependencies should be avoided, but in cases where that’s not possible, Nest provides a way to work around them.
A forward reference allows Nest to reference classes that have not yet been defined by using the forwardRef()
utility function. We have to use this function on both sides of the circular reference.
For example, we could have modified the UserService
as follows:
import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { FileService } from '../file-service/file.service'; @Injectable() export class UserService { constructor( @Inject(forwardRef(() => FileService)) private readonly fileService: FileService, ) {} public async getUserById(userId: string) { ... } public async addFile(userId: string, pictureId: string) { const picture = await this.fileService.getById(pictureId); // update user with the picture url return { id: userId, name: 'Sam', profilePictureUrl: picture.url }; } }
And then the FileService
like so:
import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { UserService } from '../user/user.service'; import { File } from './interfaces/file.interface'; @Injectable() export class FileService { constructor( @Inject(forwardRef(() => UserService)) private readonly userService: UserService, ) {} public getById(pictureId: string): File { ... } public async getUserProfilePicture(userId: string): Promise<File> { const user = await this.userService.getUserById(userId); return this.getById(user.id); } }
With this forward reference, the code will compile without errors.
Forward references for modules
The forwardRef()
utility function can also be used to resolve circular dependencies between modules, but it must be used on both sides of the modules’ association. For example, one could do the following on one side of a circular module reference:
@Module({ imports: [forwardRef(() => SecondCircularModule)], }) export class FirstCircularModule {}
Conclusion
In this article, we learned what circular dependencies are, how dependency injection works in NestJS, and how issues of circular dependencies can arise.
We also learned how we can avoid circular dependencies in NestJS and why we should always try to avoid it. Hopefully, you now know how to work around it in case it can’t be avoided using forward references.
The code examples for this article are hosted here on GitHub; there are three branches in the repository named circular
, fix/forward-referencing
, and fix/refactoring
. You can use the branches to navigate to the different stages of the project.
The post How to avoid circular dependencies in NestJS appeared first on LogRocket Blog.
from LogRocket Blog https://ift.tt/mhw9Jla
via Read more