Dependency Injection (DI) is a useful and popular tool that makes development easier. It simplifies the process of injecting necessary dependencies into components, improves testing, and supports the Dependency Inversion Principle. However, many developers don’t fully take advantage of DI because they might not understand how it operates behind the scenes. In this article, we’ll explore the inner workings of DI, the hierarchy of injectors, and how it changed with the introduction of standalone components.
Injector 101
It’s crucial to understand how injectors provide services to fully grasp how DI works in Angular. Here's a simplified algorithm:
- Angular uses the current injector.
- Injector.get(token) is called with a service's token.
- If the injector finds the token, it either creates and returns a new instance of the service or provides an existing instance.
Let’s take a look at an example of an our custom-written injector implementation:
type InjectionToken = string; // unique identifier
abstract class Injector {
abstract get(token: InjectionToken): any; // function for getting a service
}
type Record = {
factory: () => any; // factory for creating a service
value: any; // existed service
}
export class ModuleInjector extends Injector {
private records: Map<InjectionToken, Record>; // responsible for storing services
constructor(providers: Array<[InjectionToken, Record]>) {
this.records = new Map(providers);
}
get(token: InjectionToken): any {
if (!this.records.has(token)) { // if token is not found then throw an error
throw new Error(`Could not find the ${token}`);
}
const record = this.records.get(token);
if (!record.value) { // if an instance is not created then just create it
record.value = record.factory();
}
return record.value;
}
}
Angular gathers all services from the providers in modules, components, and classes marked with the @Injectable({providedIn: …}) decorator. The injector then creates a Map, which allows us to retrieve instances of these services. Under the hood, Angular's process looks something like this:
// gathering all providers and creating an injector
const injector = new Injector([
['SomeService', () => new SomeService()],
['AnotherService', () => new AnotherService()]
...
]);
// when a component creates, then Injector.get is called
injector.get('SomeService') // => SomeService instance
However, Angular doesn't rely on one injector — it uses an entire hierarchy of injectors. This tree of injectors is created during the application's bootstrap process. If the current injector doesn’t contain the required token, Angular attempts to find it in the parent injector. Let's implement this logic in our custom-written injector:
export class ModuleInjector extends Injector {
private records: Map<InjectionToken, Record>;
private parent: Injector;
constructor(
providers: Array<[InjectionToken, Record]>,
parentInjector: Injector
) {
this.records = new Map(providers);
this.parent = parentInjector; // save the parent injector
}
get(token: InjectionToken): any {
if (!this.records.has(token)) {
// attempt find a token in the parent injector
return this.parent.get(token);
}
...
}
}
When Angular creates an injector, it saves a reference to the parent injector. If the current injector doesn’t contain the token, Angular searches for it in the parent injector. But what happens if the token isn't found in any of the injectors? In that case, Angular uses a special NullInjector.
export class NullInjector implements Injector {
get(token: ProviderToken): any {
const error = new Error(`NullInjectorError: No provider for ${stringify(token)}!`);
error.name = 'NullInjectorError';
throw error;
}
}
Angular provides NullInjector as the root of the injector tree. If a token isn’t found in the injector chain, the NullInjector throws the familiar error: NullInjectorError: No provider for MyService!.
Now that we've reached this concept, let’s explore the different types of injectors in Angular and how they are connected. It’s important to note that we’ll first look at the hierarchy of injectors before Angular 14, and then examine how it changed with the introduction of standalone components.
Hierarchy of injectors
Default Injectors
Three injectors are created during the application’s bootstrap process:
- NullInjector: Throws an error if the token is not found.
- Platform Injector: Responsible for services that can be shared across multiple apps within the same Angular project.
- Root Injector: Stores Angular’s default services as well as services provided using the @Injectable({ providedIn: 'root' }) decorator or defined in the providers property of the AppModule metadata.
When we try to get a service, Angular first searches the root injector, followed by the platform injector. If the service is still not found, the NullInjector throws an error. It may look like this in a diagram:
There’s a tricky aspect to consider: if a module with providers is imported into the AppModule, a separate injector won’t be created. Instead, its services will be added to the root injector.
@NgModule({
providers: [MyService]
})
export class MyModule {}
@NgModule({
imports: [MyModule],
providers: [Service]
})
export class AppModule {}
// as a result the root injector contains two services:
// [Service, MyService]
This wasn’t an obvious point for me when I was learning Angular — I had assumed that every module would create its own injector.
Lazy-loaded module injector
In addition to the three default injectors, Angular also creates a separate injector for each lazy-loaded module.
@NgModule({
providers: [MyService]
})
export class LazyModule {
}
export const ROUTES: Route[] = [
{
path: 'lazy',
loadChildren: () => import('./lazy-module').then(mod => mod.LazyModule)
},
]
When you provide a service in the providers property of a lazy-loaded module, the module's injector will create an instance of the service for components that belong to that module. The rest of the application won’t be aware of this service.
If the service is injected into a component within a lazy-loaded module, the search will begin with the injector of that module. If the service isn't found, the search continues through the standard chain: Root Injector → Platform Injector → NullInjector.
Node Injector
We've covered module injectors, but Angular also uses another type of injector: the node (element) injector.
- The root component always creates its own node injector.
- Node injectors are created for each HTML tag that serves as a component selector or acts as a host for directives.
To add a service to a component's node injector, we need to provide it in the providers or viewProviders property of the component’s metadata.
@Component({
selector: 'app-child',
template: '',
providers: [Service], // provide Service to NodeInjector
// or
// viewProviders: [Service]
})
export class ChildComponent {
constructor(
private service: Service // injected from NodeInjector
) {
}
}
The main difference between a node injector and a module injector is that the node injector is created and destroyed along with the component. As a result, services that belong to the node injector are created and destroyed the same way.
Node injectors have their own hierarchy, similar to module injectors. The search for a service starts with the current node injector and continues up through parent components until the service is found or the root component is reached. Let’s consider this component tree:
<app-root>
<app-parent>
<app-child></app-child>
<app-child></app-child>
</app-parent>
</app-root>
When a service is injected into the app-child component, the search chain will follow this order: app-child → app-parent → app-root.
Full picture of injector tree
@Component({...})
export class Component {
// how does it work?
constructor(private readonly service: Service) {
}
}
When a service is injected into a component, Angular first looks for it in the current node injector. If it’s not found, the search continues through parent node injectors, followed by the lazy-loaded module injectors, and finally through the standard chain: Root Injector → Platform Injector → NullInjector. The last one throws an error if none of the injectors contain the service’s token.
I’ve prepared an example on StackBlitz that provides more details on this topic. I recommend installing the Angular DevTools extension so you can observe the injector tree yourself directly in the devtools.
The example of injector tree in Angular DevTools
If you ever feel lost, you can refer to the cheatsheet by Chris Kohler. Don’t hesitate to check it out for any questions related to DI.
Standalone revolution
Environment Injector
With the introduction of standalone components in Angular 14, the hierarchy of injectors has changed. Angular now uses the EnvironmentInjector instead of module injectors. In a fully standalone app, there are no modules, so the Angular team chose more consistent naming. However, the search chain for default injectors remains the same: Root Environment Injector → Platform Environment Injector → NullInjector.
If you want to provide a service to the root injector, you have two options: use the @Injectable({ providedIn: 'root' }) decorator or use ApplicationConfig, which replaces the AppModule providers.
export const appConfig: ApplicationConfig = {
providers: [SomeService],
};
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err)
);
Route Environment Injector
In Angular 14, a new loadComponent function was introduced, which handles lazy-loading of components, similar to how loadChildren works for modules. You might assume that loadComponent could be used to create a separate injector, but this assumption is incorrect. In reality, loadComponent does not create a new injector.
export const ROUTES: Route[] = [
{
path: 'lazy',
// it does not create a new injector :(
loadComponent: () => import('./lazy.component').then(c => c.LazyComponent)
},
]
If you need to create an injector for a specific route, similar to a lazy-loaded module injector, you can use the providers property in the route configuration. This functionality creates a separate injector for that route and its child routes, regardless of whether the route is lazy-loaded or not.
export const ROUTES: Route[] = [
{
path: 'route-with-providers',
component: ChildComponent,
// it does create a new injector for the route
providers: [SomeService],
children: [...]
},
]
The full process for searching a service looks like this:
Check an example on StackBlitz for that case.
Backward compatibility with NgModule
The Angular team recognizes that migrating to standalone components takes time, so they’ve added backward compatibility with the NgModule approach. You can import either a component or a module inside a standalone component.
@Component({
standalone: true,
selector: 'app-child',
imports: [
SomeComponent, // standalone component
SomeModule // "old" NgModule
],
template: ``,
})
export class ChildComponent {
}
This feature makes it easier to migrate to the new component type, but it can also lead to unexpected behavior. Issues happen when a module with services is imported into a standalone component. Since Angular treats standalone components as independent building blocks of the application, it would be odd if these services were added to the root injector. Components must encapsulate this logic.
To address this issue, Angular creates a separate injector-wrapper for standalone components if they import any modules, regardless of whether the modules have providers or not. This injector-wrapper collects all the services from the imported modules and stores them within itself.
The injector-wrapper is created in three cases:
- During the application’s bootstrap process: If the root component has modules or uses a component with modules inside.
- For dynamically created components: If the component has modules or uses a component with modules inside.
- For routed components: If the component has modules or uses a component with modules inside.
Let’s explore these cases one by one.
- During Bootstrap
If we import a component with modules into our AppComponent, an injector-wrapper will be created.
@Component({
standalone: true,
selector: 'app-root',
imports: [ChildComponent], // the component with modules inside
template: `
<app-child /><app-child />
`,
})
export class AppComponent {
}
The hierarchy of the environment injectors will look like this:
- Dynamically created component
An injector-wrapper will be created when a dynamically created component includes modules or uses a component with modules.
@Component({
...
})
export class AppComponent {
click(): void {
// dynamically creation of component with modules
const compRef = this.viewContainerRef.createComponent(ChildComponent);
compRef.changeDetectorRef.detectChanges();
}
}
- Routed component
If a route navigates to a component that includes modules or uses a component with modules, an injector-wrapper will also be created.
export const ROUTES: Route[] = [
{
path: 'child',
// the component with modules inside
component: ChildComponent,
},
]
If you have any questions about the examples, please follow the link to StackBlitz. I’ve prepared these cases, and you can study them yourself using the Angular Devtools.
Instead of conclusion
Thank you for your attention. Instead of concluding, I’d like to hear your thoughts. Some people think all services should go in the root injector, so they wouldn’t have to worry about the injector hierarchy in Angular. However, I believe we should consider it differently. What do you think?
Do not hesitate to contact me if you have any questions!