Angular is well known for its robust dependency injection system. Using dependency injection has many benefits, including more straightforward testing strategies and dependency management in our applications. With Angular’s dependency injection system, we can create special classes called services that allow us to share logic and data between components and features. In this post, we will look at how we can take advantage of Angular’s advanced hierarchical dependency injection to create services that can are created multiple times or for specific features of our application.
Table of contents
Dependency Injection
Angular’s dependency injection system is hierarchical. A hierarchical dependency injection system allows us to define different boundaries or scopes for our dependencies to run in and follows the component tree structure. By default, services registered to Angular are application wide but we can also create services that are isolated to a subset of components. Our first example will show a basic service that we typically see in an Angular application.
Application-Wide Singleton Services
Typically when using Angular services, we think of services as being an application-wide singleton. Singleton services by default in Angular mean that Angular creates one instance of our service and shares that instance to all the components in our application. Let’s take a look at an example of how this works.
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class MessageService {
messages = [
'10 rockets built',
'new configurations available'
];
addMessage(message: string) { ... }
}
By default, when we create a service with the Angular CLI, we get something similar to the code example above. On our service class, we have the @Injectable
decorator letting Angular know that other components can inject and use this service. In the decorator, the providedIn
property value is root
. By setting the providedIn
property to root
Angular registers the service to the root injector. When a service registers to the root injector, it allows the service to be used application wide.
By registering services application wide, we can easily share the services and any logic contained within them. This can also be useful for sharing state or data across our entire application within multiple components. Singleton services work great for a large majority of tasks in Angular applications. Sometimes though we may want to adjust this default behavior. With Angular, we have a few options.
For example, what if we want to create multiple instances of the same service? Why would we want this? In our next case, we will see how we can create component level service instances.
Component Level Services
In our use case example, we are building out a UI for ordering rockets. We want to be able to compare and contrast the prices of different rockets based on what options we select (and yes, the rocket prices are almost real!). Here is a screenshot of our prototype UI.
Each time we click to add a rocket, we create a new Rocket
order where we can adjust and build our rocket. Each setting changes the price of the rocket and updates it in the UI.
To calculate the cost of the rocket, we have a RocketOrderService
that uses an RxJS Observable to emit an updated value whenever the rocket data has changed. This Observable allows any subscribed component to receive those updates.
Let’s take a look at the RocketOrderService
:
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
export interface Rocket {
color: string;
boosterCondition: number;
boosterCount: number;
total: number;
}
const initialRocket: Rocket = {
color: '#000000',
boosterCondition: 0,
boosterCount: 1,
total: 60000000
};
@Injectable()
export class RocketOrderService {
private readonly _rocketChanges = new BehaviorSubject<Rocket>(initialRocket);
readonly rocket = this._rocketChanges.asObservable();
updateColor(color: string) {
const rocket = { ...this._rocketChanges.value, color };
this.calculateTotal(rocket);
this._rocketChanges.next(rocket);
}
updateBoosterCondition(boosterCondition: number) {
const rocket = { ...this._rocketChanges.value, boosterCondition };
this.calculateTotal(rocket);
this._rocketChanges.next(rocket);
}
updateBoosterCount(boosterCount: number) {
const rocket = { ...this._rocketChanges.value, boosterCount };
this.calculateTotal(rocket);
this._rocketChanges.next(rocket);
}
private calculateTotal(rocket: Rocket) {
rocket.total = 60000000;
if (rocket.color !== '#000000') {
rocket.total = rocket.total + 10000;
}
if (rocket.boosterCondition === 1) {
rocket.total = rocket.total - 10000000;
}
if (rocket.boosterCount === 3) {
rocket.total = rocket.total + 40000000;
}
return rocket.total;
}
}
Our RocketOrderService
is a reactive data service that allows any component to subscribe for updates and changes to our rocket state. Whenever we update one of the rocket values, the total is recalculated and emit the new rocket value via an RxJS Observable.
Free eBook
Directives, simple right? Wrong! On the outside they look simple, but even skilled Angular devs haven’t grasped every concept in this eBook.
- Observables and Async Pipe
- Identity Checking and Performance
- Web Components <ng-template> syntax
- <ng-container> and Observable Composition
- Advanced Rendering Patterns
- Setters and Getters for Styles and Class Bindings
Using Angular’s dependency injection system we can provide an instance of our RocketOrderService
for each instance we have our of our RocketOrderComponent
.
Let’s take a look at the RocketOrderComponent
:
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormBuilder } from '@angular/forms';
import { Observable } from 'rxjs';
import { Rocket, RocketOrderService } from './../rocket-order.service';
@Component({
selector: 'app-rocket-order',
templateUrl: './rocket-order.component.html',
providers: [RocketOrderService]
})
export class RocketOrderComponent {
rocket: Observable<Rocket>
total: number = 10;
form: FormGroup;
constructor(private rocketOrderService: RocketOrderService, private formBuilder: FormBuilder) {
this.rocket = this.rocketOrderService.rocket;
this.form = this.formBuilder.group({
color: ['#000000'],
boosterCondition: [0],
boosterCount: [1]
});
// when the user updates the form, update the rocket data in the service
this.form.valueChanges.subscribe(value => {
this.rocketOrderService.updateBoosterCondition(+value.boosterCondition);
this.rocketOrderService.updateBoosterCount(+value.boosterCount);
this.rocketOrderService.updateColor(value.color);
});
}
}
In the component decorator, we have a new property providers
. The providers
property contains anything that we want to make available to inject for the particular component. By adding the RocketOrderService
to the providers on our component, Angular’s creates a single instance of that service each time it creates an instance of the RocketOrderComponent
.
Not only do we have an instance for each RocketOrder
component, but that instance is also shared with any of the child components of the RocketOrder
component. This behavior is why Angular’s dependency injections system is hierarchical. Where the provider is defined determines the scope that is available for the components. Let’s take a look at the RocketOrderComponent
template.
<form [formGroup]="form" (ngSubmit)="log()" [style.border-color]="(rocket | async)?.color">
<h3>Rocket Order 🚀</h3>
<label for="color">Color 🎨</label>
<input formControlName="color" type="color" id="color"/>
<label for="booster-condition">Booster Condition</label>
<select formControlName="boosterCondition" id="booster-condition">
<option value="0">New</option>
<option value="1">Used</option>
</select>
<label for="booster-count">Number of Boosters ⚡</label>
<select formControlName="boosterCount" id="booster-count">
<option value="1">Standard Single</option>
<option value="3">Tribple Heavy</option>
</select>
<app-rocket-total></app-rocket-total>
</form>
Notice how we don’t pass the rocket data into the app-rocket-total
component via an Input property. Because we registered our RocketOrderService
to the RocketOrderComponent
, the RocketOrderComponent
and all the child components can inject the service instance.
If we look at the app-rocket-total
, we can see this in action:
import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { Rocket, RocketOrderService } from './../rocket-order.service';
@Component({
selector: 'app-rocket-total',
template: `<h3>Total: {{ (rocket | async)?.total | currency }}</h3>`
})
export class RocketTotalComponent {
rocket: Observable<Rocket>;
constructor(private rocketOrderService: RocketOrderService) {
this.rocket = this.rocketOrderService.rocket;
}
}
Using component level services, we can share state and logic between isolated branches of components. Now every time we create a new RocketOrderComponent
it and the RocketTotalComponent
share the same instance of RocketOrderService
.
Note that there is a tradeoff with this pattern of sharing data between components instead of using Inputs and Outputs. It was easier to share data between the components but they are now tightly coupled to the data source (RocketOrderService
) meaning they are more difficult to reuse elsewhere in our application.
Now that we have covered application-wide services and component level services we can cover our final way of isolation for services via NgModule
.
NgModule Feature Services
We now know how we can share services application wide and isolate them to specific components, but there is a third option at our disposal. Using lazily loaded feature modules, we can separate services to only be available in a given feature. This isolation only works if the NgModule
is loaded lazily.
Just like our components using NgModule
we can scope service instances to a subset of our application. In our example app, we have two features, the rocket order form and an about page. Each feature is lazy loaded using NgModules
and the Angular Router.
export const routes: Routes = [
{ path: '', loadChildren: './rockets/rockets.module#RocketsModule' },
{ path: 'about', loadChildren: './about/about.module#AboutModule' },
];
With each feature, we register a MessageService
.
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class MessageService {
value = Math.random();
}
The message service does not have any exciting functionality but helps us understand the behaviour of how Angular creates it.
In each feature module, we register the MessageService
to the module providers:
// About Feature Module
@NgModule({
imports: [
CommonModule,
RouterModule.forChild(routes)
],
declarations: [AboutComponent],
providers: [MessageService] // register the message service
})
export class AboutModule { }
And again:
// Rocket Feature Module
@NgModule({
imports: [
CommonModule,
ReactiveFormsModule,
RouterModule.forChild(routes)
],
declarations: [
RocketComponent,
RocketOrderComponent,
RocketTotalComponent
],
providers: [
MessageService // register message service
]
})
export class RocketsModule { }
Because we register the MessageService
to the feature module Angular will create a single instance of the MessageService
for that feature module to use.
import { Component } from '@angular/core';
import { MessageService } from './../message.service';
@Component({
selector: 'app-about',
template: `
<p>Message Service Instance (About Module): {{ message }}</p>
<p>about works!</p>
`
})
export class AboutComponent {
message: number;
constructor(private messageService: MessageService) {
this.message = this.messageService.value;
}
}
If we view the about page, we can see the random value is different than the value created by the application wide MessageService
.
By leveraging lazily loaded feature modules, we can create services that are isolated and retained within only that given feature. Module level providers are beneficial if we want to make sure a service is available only within a specific feature, or we want that state to persist in only that feature module.
We covered the three main ways to register services in Angular, root application, component level, and lazy loaded feature level modules. By taking advantage of these techniques, we can safely isolate the responsibilities and state of large Angular applications.
If you want to learn more about Angular’s dependency injection system take a look at the documentation found here. Check out the full working demo application below!