For a quick reminder, the Dependency Inversion Principle (DIP) states that:
- High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g. interfaces).
- 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 Advanced Concepts Course now LIVE!
- NestJS Authentication / Authorization Course now LIVE!
- NestJS GraphQL Course (code-first & schema-first approaches) are now LIVE!
- NestJS Authentication / Authorization Course now LIVE!