DEV Community

Cover image for Implementing Dependency Inversion Principle in Nest.js using Abstract Classes
Mirza Leka
Mirza Leka

Posted on • Updated on

Implementing Dependency Inversion Principle in Nest.js using Abstract Classes

What is Dependency Inversion?

The Dependency Inversion Principle (DIP) is one of the five SOLID principles. The principle says High-level modules (classes) should not depend on low-level modules. Both should depend on abstractions.

Let's start with an example that does not use this principle. I created a UserService class that is in charge of creating new users.

class UsersService {

    public createUser(data: User) {
        // ...
    }

}
Enter fullscreen mode Exit fullscreen mode

However, we need to add logs as well. Luckily, the is a LoggerService that does the logging for the whole application. Let's apply it:

class UsersService {

    private logger;

    public createUser(data: User) {
        logger = new LoggerService();

        loggger.log('Started creating users...');

        // ...
    }

}
Enter fullscreen mode Exit fullscreen mode

The logger is in place and it does the job, but, there are a few problems:

1. Difficult to maintain

What if the implementation of LoggerService changes? For example, new methods are added, existing ones are changed, and there is a parameter added to the constructor of a LoggerService. If that happens, we'd need to go to all places where Logger Service is used and make the necessary changes:

logger = new LoggerService('parameter');
Enter fullscreen mode Exit fullscreen mode

2. Hard to swap dependencies

What if sometime in the future we need to swap the existing LoggerService that logs in the console with another that logs in the database? Or if we have a Payment class that uses PayPal and we need to swap it with another class that uses Stripe?

In either case, a huge refactor is unavoidable.

3. Hard to Test

Because the UserService is directly calling the logger, it means that when testing the service, we'd make actual logs to the console/database and we don't want that. Imagine if this was an Email Service and it sent emails to customers while running tests on each build. It'd be very expensive for us and we'd also spam the customers.

Enter Dependency Inversion. Instead of invoking LoggerService directly, the UserService will call its interface. This pattern solves all previously mentioned problems, as we're not bound to work with actual classes but rather their abstractions.

The abstraction will have the methods available on the LoggerService and in case the underlying implementation or framework changes, it won't matter.

Implementing Dependency Inversion in Nest.js

Now we're going to apply this principle in Nest.js.

Quick Note

Nest.js however does not allow using interfaces as providers because injection tokens only work strings, symbols, and classes (as explained here). However, it does work with Abstract classes which we'll use here.

App overview

📁 src 
|__ 📁 models
|_____ loggerService.class.ts
|__ 📁 services
|_____ 📁 ConsoleLogger
|_______ consolelogger.service.ts
|_____ 📁 FileLogger
|_______ filelogger.service.ts
|__ app.module.ts 
|__ app.controller.ts 
Enter fullscreen mode Exit fullscreen mode

Generate abstract class

// ./models/loggerservice.class.ts
export default abstract class LoggerService {
  abstract log(data: string): void;
}
Enter fullscreen mode Exit fullscreen mode

Generate Two Services

  • ConsoleLoggerService - Implementation of LoggerService that logs to the console
  • FileLoggerService - Implementation of LoggerService that logs to the file

Each service extends the abstract LoggerService class and implements the log() method.

// ./services/ConsoleLogger/consolelogger.service.ts
import { Injectable } from '@nestjs/common';
import LoggerService from '../../models/loggerservice.class';

@Injectable()
export class ConsoleLoggerService extends LoggerService {
  log(data: string): void {
    console.log(data);
  }
}
Enter fullscreen mode Exit fullscreen mode
// ./services/FileLogger/filelogger.service.ts
import { Injectable } from '@nestjs/common';
import LoggerService from '../../models/loggerservice.class';
// Node.js built-in File-System API
import { createWriteStream } from 'fs';


@Injectable()
export class FileLoggerService extends LoggerService {
  log(data: string): string {

    const stream = createWriteStream('<your-file>.log', { flags: 'a' });
  // 'a' means append to the existing file

  stream
    .end(data) // insert data into file
    .on('error', () => console.log('Unable to log to file!'));
  }
}
Enter fullscreen mode Exit fullscreen mode

Setup Appmodule

In AppModule we'll inject our services using the abstract class as a provider token.

import LoggerService from './models/loggerservice.class';
import { ConsoleLoggerService } from './services/ConsoleLogger/consolelogger.service';
import { FileLoggerService } from './services/FileLogger/filelogger.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [
    AppService,
    {
      provide: LoggerService, // our custom injection token
      useClass: /* either implementation of LoggerService */
    },
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Setup AppController

import { Controller, Get, Inject } from '@nestjs/common';
import LoggerService from './models/loggerservice.class';

@Controller()
export class AppController {
  constructor(private readonly logger: LoggerService) {}

  @Get()
  getHello(): string {
    this.logger.log('Greeting sent to the user');
    return 'Hello World!';
  }
}
Enter fullscreen mode Exit fullscreen mode

The beauty here is that the consumer has no clue what logger service is used.

Let's give it a whirl!

Set a ConsoleLoggerService inside the AppModule and run the app:

  providers: [
    AppService,
    {
      provide: LoggerService, // our custom injection token
      useClass: ConsoleLoggerService
    },
  ],
Enter fullscreen mode Exit fullscreen mode
npm run start:dev
Enter fullscreen mode Exit fullscreen mode
curl localhost:3000
Enter fullscreen mode Exit fullscreen mode

The console output should be visible in the terminal.

console-logger

Now to the same for the FileLoggerService. Important note - remember to create your log file first, e.g. access.log.

  providers: [
    AppService,
    {
      provide: LoggerService, // our custom injection token
      useClass: FileLoggerService
    },
  ],
Enter fullscreen mode Exit fullscreen mode
curl localhost:3000
Enter fullscreen mode Exit fullscreen mode

The information is logged into the file as expected.

file-logger

And that's it!
If you'd like to learn more about logging in Node.js, be sure to check out my blog on Automated Logging in Express.js. For everything else awesome, follow me on Dev.to and on Twitter to stay up to date with my content updates.

Top comments (4)

Collapse
 
kostyatretyak profile image
Костя Третяк

Here we have to use @Inject() when importing dependencies as we're using a custom injection token.

What? In fact, you don't have to use @Inject() in your example, because LoggerService is a class that can be associated with the appropriate dependency. @Inject() makes sense to use if your token is not a class.

Collapse
 
mirzaleka profile image
Mirza Leka

You're right.
I'll fix that right away. Thanks for pointing it out.

Collapse
 
kostyatretyak profile image
Костя Третяк

NestJS objectively has an architecture that is poorly suited for modularity.

Collapse
 
mirzaleka profile image
Mirza Leka

Perhaps... I think it's in the sweet spot between Express.js and .NET Web API regarding OOP.