Angular Dependency Injection: A Beginner’s Guide

Angular Dependency Injection: A Beginner’s Guide

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:

  1. Testing difficulty: In this approach, when you want to test the House class, you are forced to also test with Brick and Cement objects. You can't replace these dependencies with mock objects for testing, because the House class is directly instantiated with new Brick and Cement instances.

  2. Less flexibility: The House class is rigidly configured to use Brick and Cement. What if later you want to construct a House with Stone and Clay instead? You'd need to modify the House class itself, which isn't ideal, especially if the House class is used widely in your application.

  3. 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 and Cement 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.

  1. Testing made easier: With DI, testing becomes easier because you can inject mock versions of your dependencies during testing. For instance, if Brick and Cement services make HTTP requests to a server, and you want to isolate House from these server requests during testing, you can provide mock versions of Brick and Cement that do not make actual HTTP requests.

  2. Flexibility: DI provides flexibility to your classes. If the House class needs to use Stone and Clay instead of Brick and Cement, you can simply change the services you inject without needing to change the House 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.

  3. Code reusability: DI promotes code reusability. Once Brick and Cement 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

https://angular.io/guide/dependency-injection