In the NestJS documentation, we can find the following sentence:
Nest provides an out-of-the-box application architecture which allows developers and teams to create highly testable, scalable, loosely coupled, and easily maintainable applications.
Let’s test this statement by checking if Nest allows us to easily follow one of the SOLID principles: the Dependency Inversion Principle.
Nest is highly inspired by Angular, so all recipes in this article work in Angular as well.
For a quick reminder, the principle states:
- 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 abstractions.
Example
To explain how to implement DIP, let’s first consider the following example.
We create an application that will analyze data about our repositories on the GitHub platform. Our current task is to implement an endpoint that will return the number of active pull requests for a given repository.
That functionality can be quickly implemented as shown below, but it does not comply with our principle:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import { Controller, Get, HttpModule, HttpService } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { PullRequest } from 'app/domain'; @Controller() export class AppController { constructor(private http: HttpService) {} @Get('repository/:id/pending-prs') getNumberOfPendingPrs(id: string): Observable<number> { return this.http .get<PullRequest[]>(`https://api.github.com/repos/${id}/pulls`) .pipe(map(res => res.data.length)); } } |
Even moving that HTTP call to a dedicated service is not a proper way to hide the implementation from the controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import { Controller, Get, HttpService } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { GithubService } from 'app/infrastructure'; @Controller() export class AppController { constructor(private githubService: GithubService) {} @Get('repository/:id/pending-prs') getNumberOfPendingPrs(id: string): Observable<number> { return this.githubService.getPullRequests(id).pipe(map(prs => prs.length)); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import { HttpService, Injectable } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { PullRequest } from 'app/domain'; @Injectable() export class GithubService { constructor(private http: HttpService) {} getPullRequests(id: string): Observable<PullRequest[]> { return this.http .get<PullRequest[]>(`https://api.github.com/repos/${id}/pulls`) .pipe(map(res => res.data)); } } |
Implementation of the Dependency Inversion Principle
The proper implementation should look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import { Controller, Get } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { RepositoryService } from 'app/interfaces'; @Controller() export class AppController { constructor(private repositoryService: RepositoryService) {} @Get('repository/:id/pending-prs') getNumberOfPendingPrs(id: string): Observable<number> { return this.repositoryService .getPullRequests(id) .pipe(map(prs => prs.length)); } } |
1 2 3 4 5 6 |
import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { GithubInfrastructureModule } from 'app/infrastructure-github'; @Module({ imports: [GithubInfrastructureModule], controllers: [AppController] }) export class AppModule {} |
1 2 3 4 5 6 7 8 9 10 11 12 |
import { HttpModule, Module } from '@nestjs/common'; import { RepositoryService } from 'app/interfaces'; import { GithubRepositoryService } from './github-repository.service'; @Module({ imports: [HttpModule], providers: [ { provide: RepositoryService, useClass: GithubRepositoryService } ], exports: [RepositoryService] }) export class GithubInfrastructureModule {} |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import { HttpService, Injectable } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { PullRequest } from 'app/domain'; import { RepositoryService } from 'app/interfaces'; @Injectable() export class GithubRepositoryService implements RepositoryService { constructor(private http: HttpService) {} getPullRequests(id: string): Observable<PullRequest[]> { return this.http .get<PullRequest[]>(`https://api.github.com/repos/${id}/pulls`) .pipe(map(res => res.data)); } } |
1 2 3 4 5 6 |
import { Observable } from 'rxjs'; import { PullRequest } from 'app/domain'; export abstract class RepositoryService { abstract getPullRequests(id: string): Observable<PullRequest[]>; } |
- We need to create an abstraction layer for getting the collection of pull requests. This abstraction is achieved by the
RepositoryService
abstract class. - To the
AppController
, we are trying to inject something that is hidden by theRepositoryService
injection token. - The
GithubInfrastructureModule
says that in that place, it should be provided aGithubRepositoryService
class.
OK, but why couldn’t we use an ordinary interface as an abstraction layer?
The answer is what happens with interfaces during the transpiling process from TypeScript to JavaScript. The problem is that we will lose all information about the RepositoryService
interface’s existence and what should be injected to the AppController
, and basically, it is not possible to pass an interface as a property value inside module providers.
The situation is completely different when it comes to classes. Even an abstract class after transpilation will be an ordinary class that can be used as an injection token. In TypeScript, we can implement a class by another class that is the same as an interface.
Why Is This So Important?
Let’s say that the requirements of our application have changed. We need to also support the repositories stored on Bitbucket as a dedicated application instance. If we hadn’t added it earlier, now we would have to add a lot of ifs
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { GithubInfrastructureModule } from 'app/infrastructure-github'; import { BitbucketInfrastructureModule } from 'app/infrastructure-bitbucket'; @Module({ imports: [ ...(process.env.provider === 'GITHUB' ? [GithubInfrastructureModule] : []), ...(process.env.provider === 'BITBUCKET' ? [BitbucketInfrastructureModule] : []) ], controllers: [AppController] }) export class AppModule {} |
Now the GithubRepositoryService
or the BitbucketRepositoryService
will be injected into the AppController
depending on the environment without any changes in the code of the outer layers.
Good examples of where this applies in an Angular application are when:
- We are creating web and mobile applications and they are loading data in a different way.
- We are building an application with SSR. While receiving data on the server side, we also want to save them in the
TransferState
. And on the browser side, we want to get them from it.
In both cases, following the Dependency Inversion Principle will help us implement and reduce changes and logic in high-level modules that use our data access layer. The only difference is that we basically shouldn’t depend on the environment to decide which module should be imported.
Summary
Following the SOLID Principles gives us a lot of benefits. Moreover, they make our system reusable, maintainable, scalable, testable, and more. Nest and Angular allow us to use them in an easy and elegant way.
If you are interested in how we can use GithubRepositoryService
and BitbukcetRepositoryService
in the same application instance, please check this repo.
After all, the statement from Nest’s documentation can be considered as a truth!
Leave a Reply