Note: This article was originally published on the Angular In Depth blog and is part of a three-part series.
Understanding Dependencies: The Fundamentals
Let's begin with a scenario where Dependency Injection is absent.
@Component({
//...
})
export class AppComponent {
service = new RootService();
}
Here, AppComponent directly creates an instance of RootService with the new keyword. This hardwires the dependency, creating strong coupling. Although functional, this pattern lacks flexibility and makes the code harder to test and scale over time.
Now, let's apply Dependency Injection to the same problem. This should look familiar to every Angular developer.
@Component({
//...
})
export class AppComponent {
service = inject(RootService);
// constructor(private service: RootService) {}
}
Heads up: both constructor-based injection and the
injectfunction are supported, and they share the same core logic.
Notice that AppComponent no longer handles the instantiation of RootService. That responsibility has moved to an external entity. This helper can fetch an already-created instance or manufacture a new one on demand. A simplified version of that process looks like this:
export const inject = (searchClass: Class) => {
const dependance = find(searchClass)
if(dependance) {
return dependance;
} else {
return new searchClass();
}
}
Now, AppComponent remains unaware of how RootService is built. This separation trims the coupling between a class and its required services, which in turn boosts maintainability, testability, and code reuse.
In Angular, this external utility is the Injector. Internally, it holds a dictionary-like structure. Each entry in that map has a shape similar to this:
record:{
//...
[index]:{
key: class RootService,
value: {
factory: ƒ RootService_Factory(t),
value: {}
}
//...
}
The Injector keeps track of every injectable class — that is, anything decorated with @Injectable, @Component, @Pipe, or @Directive.
Going back to the sample above: when AppComponent asks for RootService, the Injector looks through its records to find the matching token. If a record exists and its value is not undefined, the Injector hands it back — that signals that the service had already been created. If the value is missing, the Injector produces a fresh instance by calling the stored factory.
The record is just a plain object, and its value field is mutable. For instance, with this piece of code:
@Component({
//...
providers: [{ provide: RootService, useClass: OtherService }]
})
export class AppComponent {
service = inject(RootService);
}
...the Injector updates the value property in the RootService entry as follows:
record:{
//...
[index]:{
key: class RootService,
value: {
factory: ƒ OtherService_Factory(t),
value: {}
}
//...
}
So when AppComponent asks for RootService again, the Injector returns an instance of OtherService instead.
Keep in mind: this is a simplified representation of Angular's DI behavior, but it captures the core principle.
The next part explores the advanced mechanics and dives into the real implementation details of Angular's DI framework.
Diving Into Angular's Injector System
Angular distinguishes between two primary injector categories:
EnvironmentInjector: This group encompasses every global injectable class registered via the router, through modules, or by using the
providedIn: 'root'configuration.NodeInjector: This group covers all local injectable classes attached to each component or template.
A crucial point to grasp is that each segment of a view containing injectable classes, known as an LView, gets its own NodeInjector. Inside this NodeInjector, you can find all services declared in the component's providers array or those introduced by directives operating within that LView.
LView !== Component
Building the EnvironmentInjector Hierarchy
When your application starts, the bootstrapApplication function is triggered from your main.ts. This function accepts two arguments:
- The root component
- An array of providers
bootstrapApplication(AppComponent, {
providers: [GlobalService],
})
Behind the scenes, this function assembles three EnvironmentInjectors linked in sequence:
- NullInjector: This serves as the terminal point. Its only task is to raise an error: "NullInjectorError: No provider for …!!!"
- PlatformInjector: This holds a set of tokens that tell Angular which platform hosts the application—be it browser, server, web worker, and similar environments.
Example: Here is where the InjectionToken called DOCUMENT gets defined. In a browser environment, this token resolves to window.document, but on a server, Angular constructs a DOM using Domino. A best practice is to always inject the DOCUMENT token rather than referencing window.document directly, guaranteeing compatibility if you need to render the app from a server side.
import { DOCUMENT } from '@angular/common';
@Component()
export class FooComponent {
document = inject(DOCUMENT) // ✅
document = window.document // ❌
}
- RootInjector: Among the three, this is the most familiar. It is the storage space for all your global services (injectables marked as root).
Notes: Going back to the earlier example, the GlobalService instance resides within this injector.
All these three injectors are connected in a chain.
Constructing the NodeInjector Hierarchy
This section explores scenarios you'll encounter in everyday projects. The initial focus is on clarifying how the NodeInjector tree forms. (The NodeInjectorTree closely parallels the ComponentTree but isn't exactly the same.)
We will then observe how Angular decides which dependencies to retrieve or instantiate.
Note: Modules are left out of this discussion because most applications are moving toward standalone components. Additionally, starting with v17, all new Angular applications default to standalone mode.
Forming the Tree
Let's look at the shape of a NodeInjectorTree. We start with a straightforward case: a single Parent containing one Child.
@Component({
template: `<child />`,
imports: [ChildComponent],
})
export class ParentComponent {}
@Component({})
export class ChildComponent {}
The resulting tree looks like this:
Because ParentComponent and ChildComponent carry the @Component decorator, they are treated as injectable. As a result, each component slots into its own NodeInjector. A key detail is that ChildComponent has the ability to inject ParentComponent, but injecting itself is not possible since that would cause a circular reference.
Next, we introduce a second child to the parent:
@Component({
template: `
<child />
<child />
`,
imports: [ChildComponent],
})
export class ParentComponent {}
@Component({})
export class ChildComponent {}
The overall structure of both trees stays consistent.
Now we wrap one child inside a div that carries a directive.
@Directive({
selector: '[foo]',
standalone: true,
})
export class FooDirective {}
@Component({
selector: 'app-root',
standalone: true,
imports: [ChildComponent, FooDirective],
template: `
<div foo>
<child />
</div>
<child />
`,
})
export class ParentComponent {}
At this point, the InjectorTree starts to differ from the ComponentTree. Notice a fresh Injector has emerged. Since FooDirective is decorated with @Directive, it counts as injectable, meaning the first ChildComponent has the option to inject it.
This example illustrates that a NodeInjector is tied not to a Component but to an LView (Logical View).
With these three illustrations, you now have a complete picture of how the InjectorTree is assembled.
(Note: Routing and ActivatedRoute will be covered in a future article.)
Let's now shift focus to the different ways you can provide an injectable service and how Angular locates the instance you're requesting.
Providing via Component
The component decorator exposes a property called providers, which lets you register an Injectable class, as shown here:
@Component({
template: `...`,
providers: [MyComponentService],
})
export class MyComponent {}
The service registered inside the decorator is placed into the records of the NodeInjector belonging to MyComponent. Bear in mind that registering your service does not instantiate it right away. A service only gets created when it is actually injected.
We'll now inspect which instance gets returned using two concrete examples:
First Example:
@Component({
template: `
<child />
<child />
`,
imports: [ChildComponent],
})
export class ParentComponent {}
@Component({
providers: [MyService]
})
export class ChildComponent {
myService = inject(MyService);
}
This configuration produces the following NodeInjectorTree:
As seen, MyService appears within both ChildInjectors. When Angular builds the first ChildComponent, it requests MyService from the DI system. The lookup begins by inspecting the record of ChildInjector, which has this structure:
record:{
//...
[index]:{
key: class MyService,
value: {
factory: ƒ MyService_Factory(t),
value: undefined
}
//...
}
Angular scans every dictionary entry in the Injector to see whether the key MyService exists. Since it does appear within this NodeInjector, the next step is verifying if it was already instantiated; the value being undefined indicates it wasn't. In that case, Angular creates a fresh instance of MyService and returns it.
Had the key been missing from the record, the DI system would proceed to the next Injector and keep moving until it locates the key or reaches the NullInjector, at which point an error is thrown and the application halts.
The exact same procedure repeats for the second instance of ChildComponent. Angular examines its own NodeInjector, discovers the key in the record, and because MyService has not been instantiated, it creates a new object.
Second Example:
Next, we move the provision of MyService to ParentComponent rather than keeping it in ChildComponent.
@Component({
providers: [MyService]
template: `
<child />
<child />
`,
imports: [ChildComponent],
})
export class ParentComponent {}
@Component({})
export class ChildComponent {
myService = inject(MyService);
}
Now, MyService is stored in the record of ParentInjector.
When Angular constructs the first ChildComponent, it fails to locate the MyService key in the ChildInjector record. Angular then ascends to the next Injector, which is ParentInjector. Its record looks like this:
record:{
//...
[index]:{
key: class MyService,
value: {
factory: ƒ MyService_Factory(t),
value: undefined
}
//...
}
Because MyService hasn't been instantiated, a new instance is generated and returned.
The situation changes when the second ChildComponent is created. Angular navigates through the NodeInjectorTree until it reaches ParentInjector. This time, however, the ParentInjector appears as:
record:{
//...
[index]:{
key: class MyService,
value: {
factory: ƒ MyService_Factory(t),
value: MyService {
prop1: 'xxx'
// ...
}
}
//...
}
The value for MyService is no longer undefined. The DI system passes this existing instance to the second ChildComponent. Consequently, both ChildComponents share a single instance of MyService, which differs from what happened in the prior example.
Note: If ParentComponent were also injecting MyService, the same shared instance would apply to all three components.
Understanding ProvidedIn: 'root'
The providedIn: 'root' option ranks among the most frequently used injectable configurations in Angular applications, yet many developers don't fully grasp what those two words entail. This section aims to clarify that.
Let's set up a minimal app featuring a parent and a child:
@Component({
template: `<child />`,
imports: [ChildComponent],
})
export class ParentComponent {}
@Component({})
export class ChildComponent {
service = inject(RootService);
}
@Injectable({ providedIn: 'root' })
export class RootService {}
When we look at the NodeInjectorTree, we notice that RootService is absent from every record. The reason is that Angular doesn't add it to any Injector until a component actually requests it.
Note: With lazy-loaded routes, RootService might be tree-shaken and placed outside the primary bundle. That topic goes beyond this article's scope, but you can explore it further at the link provided.
Understanding the Inner Workings of Angular's DI System
thomas for Playful Programming Angular ・ Dec 19 '22
When Angular instantiates ChildComponent, it looks for RootService by walking up the hierarchy from the ChildInjector. The search continues until it reaches the EnvironmentInjectorTree, specifically the RootInjector.
Note: The actual implementation contains more nuance, but a simplified explanation suffices for this context.
Once the DI engine arrives at the RootInjector, it looks up the RootService key just like any other NodeInjector would. But the key isn't found there. The distinction from NodeInjectors is that the RootInjector checks whether the service's scope aligns with its own before moving further up to the next EnvironmentInjector.
Below is an excerpt from the get method of the RootInjector: (The complete function is available here)
let record: Record<T>|undefined|null = this.records.get(token);
if (record === undefined) {
// No record, but maybe the token is scoped to this injector. Look for an injectable
// def with a scope matching this injector.
const def = couldBeInjectableType(token) && getInjectableDef(token);
if (def && this.injectableDefInScope(def)) {
// Found an injectable def and it's scoped to this injector. Pretend as if it was here
// all along.
record = makeRecord(injectableDefOrInjectorDefFactory(token), NOT_YET);
} else {
record = null;
}
this.records.set(token, record);
}
The process begins by attempting to fetch the record for the requested token. If no record exists, it checks whether the service is equipped with an InjectableDef (the providedIn property). If it is, and the scope of that definition matches the scope of the current EnvironmentInjector (root, in this case), a fresh record is generated, stored in the injector, and a new instance is handed back.
On subsequent requests for RootService from any component, the record is already present, so the same instance is served each time.
Note: Although less common, you can place your service inside the
PlatformInjectorby usingprovidedIn: 'platform'.
Warning: Marking an injectable service with providedIn: 'root' typically means the service behaves as a singleton. Yet, if the same service is also listed in the providers array of a component, it gets added to that component's NodeInjector record. Consider the following scenario:
@Component({})
export class ChildComponent {
service = inject(RootService);
}
@Component({
providers: [RootService]
})
export class FooComponent {
service = inject(RootService);
}
@Component({
template: `
<child />
<foo />
`,
imports: [ChildComponent, FooComponent],
})
export class ParentComponent {}
// injectable service
@Injectable({ providedIn: 'root' })
export class RootService {}
In this setup, RootService is declared with providedIn: 'root' and injected into both FooComponent and ChildComponent. However, RootService is also supplied via FooComponent's NodeInjector. The resulting structure looks like this:
As a result, ChildComponent receives the service instance from the RootInjector, while FooComponent gets its own from its local injector. This can easily confuse someone inspecting the code, as it appears both components share a global singleton, when in fact they don't.
To sum up, providedIn: 'root' just tells Angular to create a record in the RootInjector — and only if the service reaches that point during the search through the injector tree.
I trust that the Angular Dependency Injection System now feels more transparent. With this knowledge, you should be able to determine whether a service instance will be shared or unique across your application.
Look out for upcoming articles covering:
- Dependency Injection within Routed Components
- Injection Flags: Host, Self, SkipSelf, and Optional
- Provider alternatives: useClass, useValue, useFactory, useExisting
If there are other topics you're curious about, feel free to drop a comment.
Want to sharpen your Angular skills? Check out Angular Challenges for a collection of exercises on Angular and its ecosystem.
Connect with me on Twitter or Github — feel free to reach out with any questions.








