Dependency Inversion Principlewith NestJS

Maciej Sikorski | Trilon Consulting
Maciej Sikorski

For a quick reminder, the Dependency Inversion Principle (DIP) states that:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g. interfaces).
  2. Abstractions should not depend on details. Details (concrete implementations) should depend on the abstractions.

This principle from SOLID is the most connected to our Dependency Injection (DI) system.

That pattern helps us to combine the pieces of the software that follow the Dependency Inversion Principle into a working application. So, it simply provides a specific implementation from our low-level module to the high-level consumer.

NestJS thankfully provides us with a really nice DI system, and in this article I want to show you how to use it when following the Dependency Inversion Principle.

Example

Aside from how to implement DIP, I want to show why it's beneficial to follow it. Let's consider the following example.

We create an application that will analyze data about our repositories on GitHub. Our first task is to implement an endpoint that returns active pull requests for a given repository that should be reviewed by a specific contributor.

That functionality can be quickly implemented as shown below, but it does not comply with the DIP principle:

import {
Controller,
Get,
HttpModule,
HttpService,
Param,
} from '@nestjs/common';
import { GithubPullRequest } from './github-pull-request';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { PullRequest } from 'app/model';
@Controller()
export class RepositoryStatisticsController {
constructor(private http: HttpService) {}
@Get('repository/:repositoryId/reviewer/:reviewerId/pending-prs')
getReviewerPendingPrs(
@Param('repositoryId') repositoryId: string,
@Param('reviewerId') reviewerId: string
): Observable<GithubPullRequest[]> {
return this.http
.get<{ data: GithubPullRequest[] }>(
`https://api.github.com/repos/${repositoryId}/pulls`
)
.pipe(
map((res) => res.data),
map((prs) =>
prs.filter((pr) =>
pr.reviewers.some((reviewer) => reviewer.id === reviewerId)
)
)
);
}
}

Moving that HTTP call to a dedicated service is not enough to ensure that we are properly detaching the implementation from the high-level module. We still exactly know that our low-level layer uses Github.

import { Controller, Get, HttpService, Param } from '@nestjs/common';
import { GithubPullRequest } from './github-pull-request';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { GithubService } from 'app/infrastructure';
@Controller()
export class RepositoryStatisticsController {
constructor(private githubService: GithubService) {}
@Get('repository/:repositoryId/reviewer/:reviewerId/pending-prs')
getReviewerPendingPrs(
@Param('repositoryId') repositoryId: string,
@Param('reviewerId') reviewerId: string
): Observable<GithubPullRequest[]> {
getReviewerPendingPrs(id: string): Observable<number> {
return this.githubService.getReviewerPendingPrs(repositoryId, reviewerId);
}
}
import { HttpService, Injectable } from '@nestjs/common';
import { GithubPullRequest } from './github-pull-request';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class GithubService {
constructor(private http: HttpService) {}
getReviewerPendingPrs(
repositoryId: string,
reviewerId: string
): Observable<GithubPullRequest[]> {
return this.http
.get<{ data: GithubPullRequest[] }>(
`https://api.github.com/repos/${id}/pulls`
)
.pipe(
map((res) => res.data),
map((prs) =>
prs.filter((pr) =>
pr.reviewers.some((reviewer) => reviewer.id === reviewerId)
)
)
);
}
}

Implementation of the Dependency Inversion Principle

To be able to say that we properly hid the implementation from the controller we have to introduce an abstraction.

It can be an abstract class because TypeScript allows us to implement any Type.

import { Observable } from 'rxjs';
import { PullRequest } from 'app/model';
export abstract class RepositoryDataFetcher {
abstract getReviewerPendingPrs(
repositoryId: string,
reviewerId: string
): Observable<PullRequest[]>;
}

Of course, as an alternative, it can be an interface just this time we also need something that will be used as an Injection Token. If you are curious about why it is necessary, read more about it in this thread.

import { Observable } from 'rxjs';
import { PullRequest } from 'app/model';
export RepositoryDataFetcherToken = Symbol('RepositoryDataFetcher');
export interface RepositoryDataFetcher {
getReviewerPendingPrs(repositoryId: string, reviewerId: string): Observable<PullRequest[]>;
}

Notice!
The abstraction can no longer depend on the GithubPullRequest as the return type. It's associated with the second part of the principle.

This is already something that we can already use in our high-level module.

import { Controller, Get, Inject, Param } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { RepositoryDataFetcher, RepositoryDataFetcherToken } from 'app/interfaces';
import { PullRequest } from 'app/model';
@Controller()
export class RepositoryStatisticsController {
// as an abstract class
constructor(private repositoryDataFetcher: RepositoryDataFetcher) {}
// or as an interface
constructor(
@Inject(RepositoryDataFetcherToken) private repositoryDataFetcher: RepositoryDataFetcher
) {}
@Get('repository/:repositoryId/reviewer/:reviewerId/pending-prs')
getReviewerPendingPrs(
@Param('repositoryId') repositoryId: string,
@Param('reviewerId') reviewerId: string
): Observable<PullRequest[]> {
getReviewerPendingPrs(id: string): Observable<number> {
return this.repositoryDataFetcher.getReviewerPendingPrs(repositoryId, reviewerId);
}
}

Of course, we have to put the actual implementation somewhere too.

import { HttpService, Injectable } from '@nestjs/common';
import { GithubPullRequest, GithubPullRequestMapper } from './github-pull-request';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { PullRequest } from 'app/model';
import { RepositoryService } from 'app/interfaces';
@Injectable()
export class GithubRepositoryService implements RepositoryDataFetcher {
constructor(private http: HttpService) {}
getReviewerPendingPrs(repositoryId: string, reviewerId: string): Observable<PullRequest[]> { {
return this.http
.get<{ data: GithubPullRequest[] }>(`https://api.github.com/repos/${id}/pulls`)
.pipe(
map(res => res.data),
map(prs => prs.filter(pr => pr.reviewers.some(reviewer => reviewer.id === reviewerId)),
map(prs => prs.map(GithubPullRequestMapper.toModel))
);
}
}

Merge it with the Dependency Injection system

Modules are building blocks that allow us to specify what implementation our high-level code will receive. First, we should add the GithubRepositoryService to the providers array of a module, but in a special way that will connect our implementation with the abstraction.

import { HttpModule, Module } from '@nestjs/common';
import { RepositoryDataFetcher } from 'app/interfaces';
import { GithubRepositoryService } from './github-repository.service';
@Module({
imports: [HttpModule],
providers: [
GithubRepositoryService,
{ provide: RepositoryDataFetcher, useExisting: GithubRepositoryService },
],
exports: [RepositoryDataFetcher],
})
export class GithubInfrastructureModule {}

Or like this for interfaces.

import { HttpModule, Module } from '@nestjs/common';
import { RepositoryDataFetcherToken } from 'app/interfaces';
import { GithubRepositoryService } from './github-repository.service';
@Module({
imports: [HttpModule],
providers: [
GithubRepositoryService,
{
provide: RepositoryDataFetcherToken,
useExisting: GithubRepositoryService,
},
],
exports: [RepositoryDataFetcherToken],
})
export class GithubInfrastructureModule {}

Note that it can be also a single useClass. But useExisting sometimes protects you in case GithubRepositoryService is going to be an adapter with two different interfaces. Then for each of them, Nest will create a different instance of the service which is often an unwanted behavior.

The last thing we have to do is to put this module into the imports of our higher-level module.

import { Module } from '@nestjs/common';
import { RepositoryStatisticsController } from './repository-statistics.controller';
import { GithubInfrastructureModule } from 'app/infrastructure-github';
@Module({
imports: [GithubInfrastructureModule],
controllers: [RepositoryStatisticsController],
})
export class RepositoryStatisticsModule {}

The flexibility of using different low-level modules

Let's say that the requirements of our application have changed. We also need to support repositories stored on Bitbucket, as a dedicated application instance. If we hadn't added it earlier, we would now have to add a lot of conditions in our services and controllers to prepare a proper HTTP call for the needed data.

With that elegantly hidden data source layer, we can just create a dedicated module for Bitbucket's services and properly import our feature model as follows.

There are various ways to do that and depending on your personal preferences, you can choose the one you like.

When you follow Hexagonal Architecture maybe you want to make the high-level module completely independent of how the infrastructural modules look. Then you can consider making that module dynamic.

import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
import { RepositoryStatisticsController } from './repository-statistics.controller';
@Module({
controllers: [RepositoryStatisticsController],
})
export class RepositoryStatisticsModule {
static withInfrastructure(
infrastructure: ModuleMetadata['imports']
): DynamicModule {
infrastructure = infrastructure ?? [];
return {
module: RepositoryStatisticsModule,
imports: [...infrastructure],
};
}
}
import { Module } from '@nestjs/common';
import { RepositoryStatisticsModule } from './repository-statistics.module';
import { GithubInfrastructureModule } from 'app/infrastructure-github';
import { BitbucketInfrastructureModule } from 'app/infrastructure-bitbucket';
const infrastructure =
process.env.provider === 'BITBUCKET'
? [BitbucketInfrastructureModule]
: [GithubInfrastructureModule];
@Module({
imports: [RepositoryStatisticsModule.withInfrastructure(infrastructure)],
})
export class AppModule {}

If you don't expect to require this level of independence, then you can put that condition inside your module.

import { Module } from '@nestjs/common';
import { RepositoryStatisticsController } from './repository-statistics.controller';
import { GithubInfrastructureModule } from 'app/infrastructure-github';
import { BitbucketInfrastructureModule } from 'app/infrastructure-bitbucket';
@Module({
imports: [
...(process.env.provider === 'BITBUCKET'
? [BitbucketInfrastructureModule]
: [GithubInfrastructureModule]),
],
controllers: [RepositoryStatisticsController],
})
export class RepositoryStatisticsModule {}

Or delegate it to a dedicated module that will resolve the proper infrastructure by itself.

import { Module } from '@nestjs/common';
import { RepositoryStatisticsController } from './repository-statistics.controller';
import { RepositoryStatisticsInfrastructureModule } from './repository-statistics-infrastructure.module';
@Module({
imports: [RepositoryStatisticsInfrastructureModule.register()],
controllers: [RepositoryStatisticsController],
})
export class RepositoryStatisticsModule {}

When should you use it?

Dependency Inversion is one of the SOLID principles. We can definitely consider it as a best practice. However, I am against the idea that you must always follow a specific rule. Let me highlight some other benefits that will come along with the Dependency Inversion Principle. After that, you can make the decision whether you want to get these in your project or not.

Keeping Options Open

Citing Uncle Bob's Clean Architecture book:

"The way you keep software soft is to leave as many options open as possible, for as long as possible. What are the options that we need to leave open? They are the details that don't matter"

When we use interfaces and abstractions to define how our classes communicate with each other, we leave the implementation details as open options. This enables us to run more experiments and try different underlying strategies to accomplish whatever we want.

Testing

Many of the best practices are invented to support testing.

Even if your application is stable and you will always use the same services at runtime, unit testing is another case when you will consider providing a different implementation than the one you always use.

I know there are many fancy libraries that help you mock the dependencies of the service under test. However, if you are not a fan of having a library for every problem, then this is a much simpler way to provide a testing version of those dependencies.

You just have to match a simple abstraction.

Work distribution

Do you have a database or another technology master on your team? Or are you the business logic expert, and you don't want to waste your knowledge and time studying Github's API now? That's reasonable! Just do your part, share the interface you need, add the tests, and merge it.

Now you can take the next feature and your colleague will provide the implementation of the interface for you.

Summary

The IT industry and software development has been operating on the market for a long time. During this time, wise heads have already found patterns that help us avoid common problems that we can face while working. Our role today is most often the skillful use of these patterns with the new tools we use. Principles from SOLID are one of the basic patterns in object-oriented programming, and NestJS supports us very well in using them in our projects.


Learn NestJS - Official NestJS Courses 📚

Level-up your NestJS and Node.js ecosystem skills in these incremental workshop-style courses, from the NestJS Creator himself, and help support the NestJS framework! 🐈

🚀 The NestJS Fundamentals Course is now LIVE and 25% off for a limited time!

🎉 NEW - NestJS Course Extensions now live!
#NestJS
#Patterns
#Productivity

Share this Post!

📬 Trilon Newsletter

Stay up to date with all the latest Articles & News!

More from the Trilon Blog .

Jay McDoniel | Trilon Consulting
Jay McDoniel

NestJS Metadata Deep Dive

In this article we'll be doing a deep-dive and learning about how NestJS uses Metadata internally for everything from dependency injection, to decorators we use everyday!

Read More
Kamil Mysliwiec | Trilon Consulting
Kamil Mysliwiec

NestJS v10 is now available

Today I am excited to announce the official release of Nest 10: A progressive Node.js framework for building efficient and enterprise-grade, server-side applications.

Read More
Manuel Carballido | Trilon Consulting
Manuel Carballido

Implementing data source agnostic services with NestJS

Learn how to implement data source logic in an agnostic way in yours NestJS applications.

Read More

What we do at Trilon .

At Trilon, our goal is to help elevate teams - giving them the push they need to truly succeed in today's ever-changing tech world.

Trilon - Consulting

Consulting .

Let us help take your Application to the next level - planning the next big steps, reviewing architecture, and brainstorming with the team to ensure you achieve your most ambitious goals!

Trilon - Development and Team Augmentation

Development .

Trilon can become part of your development process, making sure that you're building enterprise-grade, scalable applications with best-practices in mind, all while getting things done better and faster!

Trilon - Workshops on NestJS, Node, and other modern JavaScript topics

Workshops .

Have a Trilon team member come to YOU! Get your team up to speed with guided workshops on a huge variety of topics. Modern NodeJS (or NestJS) development, JavaScript frameworks, Reactive Programming, or anything in between! We've got you covered.

Trilon - Open-source contributors

Open-source .

We love open-source because we love giving back to the community! We help maintain & contribute to some of the largest open-source projects, and hope to always share our knowledge with the world!

Explore more

Write us a message .

Let's talk about how Trilon can help your next project get to the next level.

Rather send us an email? Write to:

hello@trilon.io
Š 2019-2023 Trilon.