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 (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.
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:
TypeScript1import { 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:
TypeScript1import { 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.
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.
First, let's define an interface that both of our "dog" providers will implement:
TypeScript1export 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.
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:
TypeScript1import { 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:
TypeScript1import { 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.
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.
TypeScript1import { 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.
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:
TypeScript1import { 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
:
TypeScript1import { 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
:
TypeScript1import { 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.
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!