If you’ve made your way to this article, you’ve likely heard of Angular, Google’s popular framework for building web applications. One challenging but critical topic that you’ll inevitably encounter as you delve deeper into Angular is Dependency Injection (DI). So, buckle up as we’re about to embark on an exciting journey to understand DI, its advantages, and how to use it in Angular.
What is Dependency Injection?
In simple terms, DI is a design pattern that enables a class to receive dependencies from an external source rather than creating them itself.
Imagine a scenario where you’re constructing a house. You could either create every item (bricks, cement, windows, doors, etc.) yourself or get them delivered from various suppliers. The latter approach is similar to DI: it’s all about getting ‘dependencies’ (i.e., services or objects) ‘injected’ into a class.
class House {
constructor() {
this.brick = new Brick();
this.cement = new Cement();
}
}
In the code snippet above, House
creates its own dependencies. Here are some reasons why this is problematic:
Testing difficulty: In this approach, when you want to test the
House
class, you are forced to also test withBrick
andCement
objects. You can't replace these dependencies with mock objects for testing, because theHouse
class is directly instantiated with newBrick
andCement
instances.Less flexibility: The
House
class is rigidly configured to useBrick
andCement
. What if later you want to construct aHouse
withStone
andClay
instead? You'd need to modify theHouse
class itself, which isn't ideal, especially if theHouse
class is used widely in your application.Code reusability: By creating dependencies within the class, it becomes hard to reuse them across different classes or modules. For instance, if you need to use the same
Brick
andCement
instances in another class, you cannot do so without creating new instances.
Now, let’s see how Dependency Injection could change this.
Why Dependency Injection?
In the code snippet below, House
is no longer responsible for creating Brick
or Cement
. It simply declares what it needs, and it's up to the system (in Angular's case, the injector) to provide those dependencies. This makes your code much easier to manage and evolve over time.
class House {
constructor(brick: Brick, cement: Cement) {
this.brick = brick;
this.cement = cement;
}
}
Dependency Injection helps to solve the problems identified above. Let’s discuss how DI addresses each of those problems.
Testing made easier: With DI, testing becomes easier because you can inject mock versions of your dependencies during testing. For instance, if
Brick
andCement
services make HTTP requests to a server, and you want to isolateHouse
from these server requests during testing, you can provide mock versions ofBrick
andCement
that do not make actual HTTP requests.Flexibility: DI provides flexibility to your classes. If the
House
class needs to useStone
andClay
instead ofBrick
andCement
, you can simply change the services you inject without needing to change theHouse
class itself. This way,House
is less concerned about the specific implementation of the dependencies it uses and more focused on how it uses them. This is also known as the Dependency Inversion Principle: depend on abstractions, not on concrete classes.Code reusability: DI promotes code reusability. Once
Brick
andCement
services are created, they can be injected wherever required, avoiding the need to create new instances every time. This makes your code cleaner and more efficient.
Dependency Injection in Angular
Now that we understand the basics of DI let’s see how it works in Angular. Angular’s DI framework provides dependencies to a class upon instantiation. These dependencies are typically services that provide functionality such as fetching data from a server or logging user interactions.
To start with, let’s define a simple service called LogService
that logs messages to the console.
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class LogService {
log(message: string) {
console.log(`LogService: ${message}`);
}
}
Next, let’s inject this service into a component called AppComponent
.
import { Component } from '@angular/core';
import { LogService } from './log.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
title = 'Hello Angular!';
constructor(private logService: LogService) {
this.logService.log(this.title);
}
}
In the code above, the Angular DI system injects an instance of LogService
into AppComponent
via its constructor. We can then use this service to log a message to the console.
The @Injectable Decorator
You might have noticed the @Injectable
decorator on LogService
. This decorator tells Angular that this service might itself have dependencies. Even though our LogService
doesn't have any dependencies at the moment, it's good practice to add @Injectable
in preparation for future needs.
Providers and Injector
Two key concepts in Angular DI are providers and injectors. These two concepts are integral to Angular’s Dependency Injection (DI) system, but they can sometimes be a bit tricky to understand. To make it easier, think of the injector as a bakery and the provider as the recipe for creating a service.
Providers: Your Service’s Recipe
A provider is like a recipe for creating an instance of a service. It tells the injector how to create or obtain that service. It’s typically the service class itself. Let’s consider an example where we have a service called UserService
. This service might look like this:
@Injectable({
providedIn: 'root',
})
export class UserService {
getUsers() {
return ['John', 'Jane', 'Bob'];
}
}
Here, UserService
is a provider because it's the recipe for creating an instance of the UserService
. The @Injectable
decorator tells Angular that this class can be used as a provider.
Where Do We Define Providers?
We usually define providers in Angular modules or components using the providers
property. When you define providers, Angular creates an injector with all those providers. When a class needs a service, the injector checks its container of instances to see if it already has one to reuse. If not, the injector makes a new one using the provider.
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
providers: [UserService] // UserService is the provider
})
export class AppComponent { ... }
In our UserService
example above, we actually used a shortcut with providedIn: 'root'
. This automatically provides the UserService
in the root injector, which is like the main bakery for the whole app. This means that UserService
will be available anywhere in our app.
Injectors: The Bakery
An injector is a mechanism that is responsible for creating instances of services and injecting them into classes like components, other services, etc. Think of the injector as a bakery. If you ask it for a cake (UserService
in our example), it will look to see if it has a cake already made. If it does, it gives you that cake. If it doesn't, it uses the recipe (provider) to make a new cake and then gives you the cake.
Here’s an example of how an injector might be asked for a service:
import { Component } from '@angular/core';
import { UserService } from './user.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
users: string[];
constructor(private userService: UserService) {
this.users = userService.getUsers();
console.log(this.users);
}
}
In this example, AppComponent
asks the injector for UserService
. The injector then checks if it has an instance of UserService
to give. If it does, it will provide that instance. If it doesn't, it will use the UserService
provider (recipe) to create a new instance and then give that to AppComponent
.
The injector in Angular is not explicitly defined by developers in the code. Rather, it’s implicitly created and managed by the Angular framework itself. Angular creates a root injector for the application when it bootstraps the application, using the providers defined in the root module (AppModule
by convention).
In Angular, an injector is created for every Angular module and component. So when you define a module or a component, Angular implicitly creates an injector for it.
If you provide a service at the component level, Angular will create a new instance of the injector for that component and its child components. This instance will be different from the one associated with the root or any other module. This allows Angular to have a hierarchical injector system, which means you can override service instances and control the scope of services.
To recap, providers and injectors work together to make Angular’s Dependency Injection system work. Providers are like recipes that tell the injector how to create or find an instance of a service. The injector is like a bakery that takes in these recipes and uses them to create and provide these instances. This whole process allows us to write modular, reusable, and testable code in our Angular applications.
Further Reading