Lesson 5
Advanced Dependency Injection and Custom Providers
Advanced Dependency Injection in NestJS

Welcome to the final unit of this course! Throughout the previous lessons, we’ve encountered dependency injection in action, such as injecting services into controllers. In this lesson, we’ll take a deeper dive into dependency injection and inversion of control, understanding their purposes and exploring advanced examples with custom providers.

Dependency Injection as a Design Pattern

Dependency Injection (DI) is a design pattern used to implement Inversion of Control (IoC) in software applications. In simple terms, DI allows a class to receive its dependencies from an external source rather than creating them itself. This design pattern helps to reduce tight coupling between classes, making the code more modular, easier to test, and more maintainable.

In traditional software development, a class might instantiate its dependencies directly within its methods or constructor. However, this approach often leads to code that is difficult to maintain and test because the dependencies are hardcoded. Dependency injection solves this problem by allowing an external framework or system to inject these dependencies into the class, making the code more flexible and modular.

Dependency Injection in NestJS

NestJS is built around the strong design pattern of dependency injection. This pattern is a cornerstone of the framework, allowing you to manage dependencies efficiently. Thanks to TypeScript's capabilities, managing dependencies in NestJS is straightforward because they are resolved by type. For instance, when a service is injected into a controller, NestJS automatically resolves and provides the correct instance of the service.

Consider a simple example of dependency injection we’ve seen in previous units:

TypeScript
1import { Injectable } from '@nestjs/common'; 2 3@Injectable() 4export class CorgiService { 5 getMessage(): string { 6 return 'Exploring the world with Corgis!'; 7 } 8}

In this snippet, the CorgiService class is annotated with the @Injectable() decorator. This decorator marks the class as a provider, making it eligible for dependency injection. The CorgiService contains a simple method getMessage() that returns a string. This method encapsulates the business logic that can be reused across the application.

Next, let's see how this service is injected into a controller:

TypeScript
1import { Controller, Get, Render } from '@nestjs/common'; 2import { CorgiService } from './corgi.service'; 3 4@Controller('corgi') 5export class CorgiController { 6 constructor(private readonly corgiService: CorgiService) {} 7 8 @Get() 9 @Render('index') 10 showCorgis(): { message: string } { 11 return { message: this.corgiService.getMessage() }; 12 } 13}

In the CorgiController, the CorgiService is injected via the constructor. The private readonly corgiService: CorgiService syntax tells NestJS to inject an instance of CorgiService into the controller. The method showCorgis() then calls the getMessage() method of the service and returns its result to be rendered in the view.

This simple example demonstrates how NestJS handles dependency injection seamlessly, allowing services to be easily injected and utilized within controllers without any manual instantiation or tight coupling.

Advanced Example: Dependency Injection with Custom Providers

Let’s expand on this concept with a more complex example involving custom providers and interfaces. We'll be creating providers for different dog breeds and using them to showcase advanced dependency injection.

Defining an Interface

First, let's define an interface that both of our "dog" providers will implement:

TypeScript
1export interface Dog { 2 getBreed(): string; 3}

This interface defines a contract for any Dog by specifying the getBreed method. Any class that implements this interface must provide an implementation for the getBreed method. Interfaces in TypeScript are used to enforce a structure on the classes that implement them, ensuring that these classes adhere to a specific contract.

Creating Custom Providers

Now that we have an interface, let's create two classes that implement this Dog interface: one for Corgis and another for Huskies.

For the Corgi provider:

TypeScript
1import { Injectable } from '@nestjs/common'; 2import { Dog } from '../interfaces/dog.interface'; 3 4@Injectable() 5export class Corgi implements Dog { 6 getBreed(): string { 7 return 'Corgi'; 8 } 9}

Here, the Corgi class implements the Dog interface by providing an implementation of the getBreed() method, which returns the string 'Corgi'. The @Injectable() decorator marks this class as a provider that can be injected into other components of the application.

For the Husky provider:

TypeScript
1import { Injectable } from '@nestjs/common'; 2import { Dog } from '../interfaces/dog.interface'; 3 4@Injectable() 5export class Husky implements Dog { 6 getBreed(): string { 7 return 'Husky'; 8 } 9}

Similarly, the Husky class also implements the Dog interface and returns the string 'Husky' from its getBreed() method.

Injecting Dependencies into a Service

With our custom providers in place, we can now create a service that utilizes these providers to compare dog breeds. This service will be responsible for coordinating the logic between different providers.

TypeScript
1import { Injectable } from '@nestjs/common'; 2import { Corgi } from '../providers/corgi.provider'; 3import { Husky } from '../providers/husky.provider'; 4 5@Injectable() 6export class CorgiService { 7 constructor( 8 private readonly corgi: Corgi, 9 private readonly husky: Husky, 10 ) {} 11 12 compareDogs(): string { 13 const corgiBreed = this.corgi.getBreed(); 14 const huskyBreed = this.husky.getBreed(); 15 return `Comparing breeds: ${corgiBreed} vs. ${huskyBreed}`; 16 } 17}

In this CorgiService, both Corgi and Husky providers are injected via the constructor. The service then compares the two dog breeds by calling the getBreed() method on each provider. This pattern of injecting multiple providers into a service allows for greater flexibility and reusability in your application’s architecture.

Connecting Everything in a Module and Controller

Finally, let's bring everything together by creating a module and connecting the service and controller.

In the CorgiModule, we declare our providers, service, and controller:

TypeScript
1import { Module } from '@nestjs/common'; 2import { CorgiController } from './corgi.controller'; 3import { CorgiService } from './corgi.service'; 4import { Corgi } from '../providers/corgi.provider'; 5import { Husky } from '../providers/husky.provider'; 6 7@Module({ 8 controllers: [CorgiController], 9 providers: [CorgiService, Corgi, Husky], 10}) 11export class CorgiModule {}

Here, the CorgiModule encapsulates all components related to our Corgi feature, keeping the code organized and modular. The module structure makes it easy to manage dependencies and understand the relationships between different parts of the application.

We then integrate the CorgiModule into our AppModule:

TypeScript
1import { Module } from '@nestjs/common'; 2import { CorgiModule } from './corgi/corgi.module'; 3 4@Module({ 5 imports: [CorgiModule], 6}) 7export class AppModule {}

Finally, the CorgiController is updated to utilize the CorgiService:

TypeScript
1import { Controller, Get, Render } from '@nestjs/common'; 2import { CorgiService } from './corgi.service'; 3 4@Controller('corgi') 5export class CorgiController { 6 constructor(private readonly corgiService: CorgiService) {} 7 8 @Get('compare') 9 @Render('index') 10 compareDogs() { 11 const comparison = this.corgiService.compareDogs(); 12 return { message: comparison }; 13 } 14}

In the CorgiController, we define an endpoint /corgi/compare that uses the compareDogs() method from the CorgiService to generate a comparison message. This message is then passed to the view template for rendering.

Why It Matters

Understanding and leveraging advanced dependency injection techniques in NestJS is essential for several reasons:

  • Modularity: By using interfaces and custom providers, you can easily swap out implementations without changing the dependent code, promoting modularity and flexibility.
  • Testability: Dependency injection makes your services easier to test because you can inject mock implementations, improving the overall reliability of your application.
  • Scalability: Managing complex dependencies efficiently is crucial as your application grows, and DI facilitates this by decoupling the logic from the dependencies.
  • Maintainability: Clean and modular code is easier to understand, maintain, and debug, leading to more maintainable codebases.

Let’s dive into the practice section and reinforce what you've learned!

Enjoy this lesson? Now it's time to practice with Cosmo!
Practice is how you turn knowledge into actual skills.