Engineering

Improving Node code with Nest.js dependency injection

Apr 14, 2023
6
MIN READ

Writing large scale software with big teams can get messy fast. Quick decisions made on the architecture design slowly stack up a lot of tech debt, at times even rendering projects unmanageable. Design patterns were created to help write code that is more maintainable and extendable. This blog will be discussing the Inversion of Control principle, and Dependency Injection as an implementation of it.

What is dependency injection

Dependency injection is where an object's initial condition and ongoing lifecycle is handled by its environment rather than by the object itself. Imagine we have the following dependency graph.

AuthService depends on UserService, which itself depends on UserRepository and ExternalUserService. If we don't know anything about dependency injection, we're going to create the classes ourselves wherever we need them. This means AuthService has to create an instance of UserService, but to do that it first needs to create an instance of UserRepository and ExternalUserService. Here's a couple reasons why this is bad:

Dependency injection solves these problems, transforming the graph above into the one below.

AuthService now has no need to create UserService. It only needs to state that it requires an instance of UserService. The Injector is responsible for finding and creating the dependencies of all classes. It retrieves already existing instances from the Service Container.

Dependency Injection allows us to separate the creation of an object from it’s usage, which in turn makes it easier to switch dependencies and reduces code in the constructors. It is also really useful for unit testing, allowing a class to be tested on its own, by mocking its dependencies and their behavior to our needs. Dependency Injection is based on the Inversion of Control principle. Instead of letting AuthService control the creation of UserService, we invert the control and give that responsibility to the environment.

Dependency injection in Node

Traditionally, developers haven’t given much thought to dependency injection in Node. Dependencies were usually imported using require.

const userService = require('./user.service.ts');
class AuthService {
 login(username, password) {
 const user = userService.findByUsername(username);
... }

Testing this class would require mocking the UserService module and remembering to clear those mocks everywhere. We can avoid this by passing UserService in the constructor of AuthService.

class AuthService {
 constructor(userService) {
 this.userService = userService;
 }
 login(username, password) {
 const user = this.userService.findByUsername(username);
 ...

And just like that we have achieved dependency injection. There is an obvious problem to this. We are still required to instantiate everything on our own. To create an instance of AuthService we must first create an instance of UserService, which might have it’s own dependencies that we need to take care of. This is where Nest.js comes in.

Dependency injection in Nest.js

Nest is a framework for building efficient, scalable Node.js server-side applications. It provides an out-of-the-box application architecture which allows developers and teams to create highly testable, scalable, loosely coupled, and easily maintainable applications. Dependency injection is one of many of its built in features.

Nest applications are structured in modules. A module ties code relevant for a specific feature, keeping it organized and establishing clear boundaries. Modules can import other modules, creating a graph of dependencies. This is what Nest uses to map relationships and resolve dependencies. Let’s see an example of this using the classes from before.

@Injectable()
export class UserService {
 findByUsername(username: string) {
 ...
} }

UserService is decorated with @Injectable(), this tells Nest.js that we want to use it as a provider and it will add an instance of the class to the service container.

@Module({
 providers: [UserService],
 exports: [UserService]
})
export class UserModule {}

UserModule declares UserService as a provider and then exports it, allowing any other modules to use it.

@Module({
 imports: [UserModule],
 providers: [AuthService]
})
export class AuthModule {}@Injectable()
export class AuthService {
 constructor(private readonly userService: UserService) {}
 login(username, password) {
 const user = this.userService.findByUsername(username);
 ...
} }

Finally, we import UserModule in AuthModule. This allows us to use UserService as a parameter in AuthService 's constructor and the rest is handled by Nest. When testing, we can now pass in any mock instead of UserService. By default everything is instantiated once and used as a singleton. This is safe because of Node’s single threaded nature. Nest does provide other injection scopes, such as request scoped or transient.

Limitations and how to overcome them

Nest’s dependency injection relies heavily on the metadata emitted by the Typescript compiler. Let’s look at our AuthService after it has been compiled to Javascript

AuthService = __decorate(
 [__metadata("design:paramtypes", [user_service_1.UserService])],
 AuthService
);

AuthServicehas been decorated with metadata about its constructor parameters. During runtime, Nest can use the Reflect API to retrieve this metadata and find out what dependencies must be provided.

Reflect.getMetadata('design:paramtypes', AuthService);
// Response will be an array of types, in this case [UserService]

The problem arises when we use types that exist in Typescript but not Javascript, such as interfaces and generics. Let’s see the same example but instead of UserService we try to inject an interface IUserService.

Reflect.getMetadata('design:paramtypes', AuthService);
// Response will be [Object]

Because interfaces don’t exist in Javascript, Typescript isn’t able to give us any relevant information for the type. If you’re coming from statically typed languages like C# or Java, this might be a deal breaker for you. Interfaces are a big part of Dependency Inversion principle (rely on abstractions, not on concrete implementations). There is a workaround to this. Nest allows us to use decorator next to a parameter to explicitly tell it which token we want to resolve when injecting that parameter. Let’s see this in action.

export interface IUserService {
  login(username: string, password: string): LoginResponse;
}
export const IUserService = Symbol("IUserService");

Notice that IUserService refers to two different things here. One is an interface and the other is a Javascript symbol.

import { IUserService } from '../interfaces/user-service.interface';
@Injectable()
export class UserService implements IUserService {
  login(username: string, password: string): LoginResponse {
    ...
} }

Here UserService uses IUserService as an interface.

@Module({
 providers: [{
 provide: IUserService,
 useClass: UserService,
 }],
 exports: [UserService]
})
export class UserModule {}

In UserModule, IUserService is used as a symbol. This tells Nest to give us an instance of UserService whenever we inject IUserService.

And finally, after importing UserModule to AuthModule, we can inject IUserService like

@Injectable()
export class AuthService {
 constructor(@Inject(IUserService) private readonly userService: IUserService) {}
 login(username, password) 
 {const user = this.userService.findByUsername(username);
... }
}

This allows us to decouple the UserService functionality from it’s implementation. If the implementation needs to be changed, we simply import another module that provides IUserService.

To sum up

In this blog post we have seen Nest.js in action. It is an opinionated framework which gives us the tools and guides us to use common design patterns to make our Node apps easier to manage and scale. We have seen one of the most famous design patterns and how Nest allows us to effortlessly implement it. While the framework provides some much needed architecture to Node apps, it is important to remember that it is still Javascript and there will be language limitations. Some of these limitations, like interface injection, can be overcome by understanding the internals of the framework.

Table of Contents
    AUTHOR:
    Ardit Baloku
    Read more posts by this author.
    Back to Blog

    RELATED ARTICLES