Introduction
Dependency injection stands as one of Angular's most impactful core features. It has existed since the AngularJS days, and with the introduction of the Ivy renderer, it's worth revisiting how DI works under the hood. What has changed in @Injectable creation and the resolution process? Let's explore this compelling subject in detail.
Pre-conditions
Angular (v9+) offers multiple compilation and runtime combinations depending on how you build your project:
- ViewEngine + JIT
- ViewEngine + AOT
- Ivy + JIT
- Ivy + AOT
The focus here will be on the most modern approach: Ivy + AOT using an application scaffolded with the Angular CLI.
Why does Angular need the Injectable decorator?
The @Injectable decorator serves purely as an annotation mechanism. It carries essential metadata for the compiler, is stripped away at runtime, and is never invoked as a function.
What kinds of problems can be solved using providedIn?
The providedIn option within the Injectable decorator addresses several challenges:
- Enables tree-shakable providers
- Prevents duplicate provider instances
- Ensures providers consistently generate fresh instances
Grasping how @Injectable and providedIn work internally helps you debug providers, manage instance counts, optimize bundle size, and choose the appropriate injector for your needs.
Processing Angular Injectables splits into a build phase and a runtime phase.
The flow is straightforward: during the build phase, the compiler gathers metadata about your Injectables, augments the source code with this information, and that data is then consumed at runtime.
Compilation: in short
Here’s a simplified overview of the compilation pipeline for an Angular application:

Angular application compilation process.
We'll examine the AngularCompilerPlugin first, then the TSProgram stage, and finally the runtime execution.
The cases
In the View Engine era, we relied on Eager and Lazy modules. Ivy, however, unlocks component usage without modules (and without module-level injectors, known as "limp" injection). The scenarios we'll cover include:
- Injectables and
providedInin eager and lazy modules - Injectables and
providedInin components and directives - Injectables and
providedInwith lazy components lacking module context
Build phase
Within the provider context, the AngularCompilerPlugin aims to:
- Identify classes annotated with
@Injectablethat include theprovidedInproperty in your codebase - Gather all providers listed in
@NgModule,@Component, and@Directivedecorators - Attach metadata (detailed later) to each collected class
- Eliminate the
@Injectabledecorator from the classes
Running ng serve kicks off Webpack compilation and instantiates the AngularCompilerPlugin. The plugin then invokes the internal method _make, which instantiates a TSProgram.
compiler.hooks.make.tapPromise('angular-compiler', compilation => this._donePromise = this._make(compilation));
AngularCompilerPlugin starts compilation
Subsequently, TSProgram processes and modifies each file that the AngularCompilerPlugin emits:

Stack trace showing the way from file emitting to file transformation
This process employs the Visitor pattern, a staple in AST-related tooling such as webpack, AngularCompilerPlugin, eslint, and prettier, to iterate over and handle each file.
Angular locates all injectables by examining the "files" and "includes" fields in your tsconfig.json.
Our focus narrows to AST expressions representing classes decorated with Injectable.
Once the Visitor identifies such a class, it processes the decorator data and appends corresponding annotations (referred to as type in Angular terminology) to the class.
In essence, the @Injectable compilation can be summarized as:

Injectable compilation process
This annotation step is skipped for InjectionTokens, which remain unannotated.
The key functions in this flowchart are InjectableDecoratorHandler.compile and compileInjectable.
The compileInjectable routine manages every provider flavor: classes decorated with Injectable and providedIn, as well as standard classes decorated with Injectable that appear in @NgModule.providers or @Component.providers lists. It supports all provider configuration styles, including useClass, useFactory, and useValue (note that @Injectable can also be configured with these options).
The primary objective of these functions is to compute the AST subtree that TypeScript needs to add static properties (annotations) to your injectable class. Those properties are:
ɵprov– holds theInjectableDef, containing the injectable's configurationɵfac– holds the factory function responsible for creating new injectable instances
What is the purpose of these properties?
They exist because:
_ɵfac_— is used within_ɵprov_._ɵprov_— provides runtime details on which Factory (_ɵprov.factory_=_ɵfac_) to use for instantiating a given Type (_ɵprov.token_) and which Injector (_ɵprov.providedIn_) should cache the resulting instance.
Since InjectionTokens bypass the compiler, the ɵprov property gets generated at runtime for them.
Here's an illustration of the results produced by InjectableDecoratorHandler.compile (the AST subtree) based on input metadata for a basic eager injectable named ApplicationService:
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class ApplicationService {
constructor() {
console.log('>>> ApplicationService constructor!');
}
call() {
console.log('>>> ApplicationService call!');
}
}
Simple eager ApplicationService

Representation of AST-subtree for ApplicationsService annotations
Angular then updates your source code with the computed expressions:
// Replace the class declaration with an updated version.
node = ts.updateClassDeclaration(
node,
// Remove the decorator which triggered this compilation, leaving the others alone.
maybeFilterDecorator(node.decorators, this.compilation.decoratorsFor(node)),
node.modifiers, node.name, node.typeParameters, node.heritageClauses || [],
// Map over the class members and remove any Angular decorators from them.
members.map(member => this._stripAngularDecorators(member)));
As a result of IvyVisitor.visitClassDeclaration method we will get annotated sources
The build phase remains consistent for eager and lazy injectables (whether provided in lazy modules or standalone). Angular does identify lazy routes and components and pre-compiles their definitions, but injectables go through the same compilation pipeline. This also applies to @Component.providers and @Directive.providers.
That wraps up the build phase. These annotation properties are utilized at runtime to initialize and identify injectables. When serving your app, you can verify in the browser's source panel that the code has been modified:

Updated sources of ApplicationService in browser
Runtime phase
Injectables are instantiated on demand when consumed by entities like components or directives. In our case, ApplicationService gets created as part of AppComponent's initialization.
Ivy leverages two core functions for injection: ɵɵdirectiveInject and ɵɵinject.
According to the docs, ɵɵdirectiveInject is designated for directives, components, and pipe factories. Any other injection scenario calls ɵɵinject, which skips the NodeInjector tree traversal.
ApplicationService gets resolved from ApplicationComponent since Angular has marked it for injection via ɵɵdirectiveInject:

ApplicationService creation starts here
This is where injectable instantiation kicks off. Let's first trace the full path:

Ivy runtime injectables creation
This routine applies uniformly to both @Injectable and InjectionToken.
At this point, Ivy Injectors step in. For an in-depth look, check out these pieces on Ivy DI and injectors like NodeInjector and R3Injector:
- Angular DI: Getting to know the Ivy NodeInjector by Alexey Zuev
- Asynchronous Modules and Components in Angular Ivy by Artur Androsovych
Briefly, NodeInjector operates at the component level, handling components, directives, and their providers. In contrast, R3Injector functions as a module-level injector, maintaining a records property that caches injectable instances.
Next, we'll examine the runtime core functions for Ivy injectables:
- getOrCreateInjectable
- injectRootLimpMode
- R3injector.get and injectableDefInScope
- searchTokensOnInjector and getNodeInjectable
Initially, in getOrCreateInjectable, we look up the injectable through component-level injectors using bloom filters, which were elegantly explained by Max Koretskyi at NgConnect and Alexey Zuev in his article. If that fails, we attempt to resolve it from the current module-level injector.
Resolving component-level injectables
The searchTokensOnInjector and getNodeInjectable methods search for your injectable on the NodeInjector, specifically within the TView and LView, creating it if it's not already present.
TView holds the injectable’s Type, while LView initially stores the Factory, which is subsequently replaced by the injectable's instance.
/**
* Retrieve or instantiate the injectable from the `LView` at particular `index`.
*
* This function checks to see if the value has already been instantiated and if so returns the
* cached `injectable`. Otherwise if it detects that the value is still a factory it
* instantiates the `injectable` and caches the value.
*/
export function getNodeInjectable(
lView: LView, tView: TView, index: number, tNode: TDirectiveHostNode): any {
let value = lView[index];
const tData = tView.data;
if (isFactory(value)) { <--- Here is first pass check
const factory: NodeInjectorFactory = value;
if (factory.resolving) {
throw new Error(`Circular dep for ${stringifyForError(tData[index])}`);
}
const previousIncludeViewProviders = setIncludeViewProviders(factory.canSeeViewProviders);
factory.resolving = true;
let previousInjectImplementation;
if (factory.injectImpl) {
previousInjectImplementation = setInjectImplementation(factory.injectImpl);
}
enterDI(lView, tNode);
try {
value = lView[index] = factory.factory(undefined, tData, lView, tNode); <--- And here is instance creation
// This code path is hit for both directives and providers.
// For perf reasons, we want to avoid searching for hooks on providers.
// It does no harm to try (the hooks just won't exist), but the extra
// checks are unnecessary and this is a hot path. So we check to see
// if the index of the dependency is in the directive range for this
// tNode. If it's not, we know it's a provider and skip hook registration.
if (tView.firstCreatePass && index >= tNode.directiveStart) {
ngDevMode && assertDirectiveDef(tData[index]);
registerPreOrderHooks(index, tData[index] as DirectiveDef<any>, tView);
}
} finally {
if (factory.injectImpl) setInjectImplementation(previousInjectImplementation);
setIncludeViewProviders(previousIncludeViewProviders);
factory.resolving = false;
leaveDI();
}
}
return value;
}
Consequently, on subsequent requests, your component-level injectable is always retrieved from its LView.
Resolving „limp” injectables
In Ivy, even without a module injector, injection remains possible:
try {
if (moduleInjector) {
return moduleInjector.get(token, notFoundValue, flags & InjectFlags.Optional);
} else {
return injectRootLimpMode(token, notFoundValue, flags & InjectFlags.Optional);
}
}
Limp mode injection by Ivy
This mode has constraints—only injectables configured with providedIn set to root are eligible.
/**
* Injects `root` tokens in limp mode.
*
* If no injector exists, we can still inject tree-shakable providers which have `providedIn` set to
* `"root"`. This is known as the limp mode injection. In such case the value is stored in the
* `InjectableDef`.
*/
export function injectRootLimpMode<T>(
token: Type<T>| InjectionToken<T>, notFoundValue: T | undefined, flags: InjectFlags): T|null {
const injectableDef: ɵɵInjectableDef<T>|null = getInjectableDef(token);
if (injectableDef && injectableDef.providedIn == 'root') {
return injectableDef.value === undefined ? injectableDef.value = injectableDef.factory() :
injectableDef.value;
}
if (flags & InjectFlags.Optional) return null;
if (notFoundValue !== undefined) return notFoundValue;
throw new Error(`Injector: NOT_FOUND [${stringify(token)}]`);
}
injectRootLimpMode in action, instance of our injectable will be created here!
The token and its instance are stored in the InjectableDef — the ɵprov property.
Omitting providedIn set to root triggers this error in limp mode:

Limp services should be provided in root!
How can we instantiate a component (and its injectables) in limp mode using Angular's high-level API? If you've explored Ivy, you've likely encountered this pattern:
import { Component, ɵrenderComponent as renderComponent } from '@angular/core';
loadLimp() {
if (!this.limp) {
this.limp = import(`./limp/limp.component`)
.then(({ LimpComponent }) => {
renderComponent(LimpComponent);
});
}
}
Limp component creation
Resolving module-level injectables
Let's shift to the standard resolution path involving a module-level injector. First, R3injector.get verifies that the input is a valid injectable token and that R3injector.records doesn't already hold it. It then registers the token and instantiates it via the R3Injector.hydrate method:
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); <--- Here token registered
}
// If a record was found, get the instance for it and return it.
if (record != null /* NOT null || undefined */) {
return this.hydrate(token, record); <--- Here token instance created
}
Token registration and instantiation using module injector
The token and its instance are stored in the R3injector.records property.
At the heart of the providedIn property lies the injectableDefInScope function. It verifies that a compiled injectable definition exists and applies to this module-level injector. Angular relies on the R3injector.get method to traverse module-level injectors, so your injectable must be provisioned in at least one. Otherwise, an error is thrown.
private injectableDefInScope(def: ɵɵInjectableDef<any>): boolean {
if (!def.providedIn) {
return false;
} else if (typeof def.providedIn === 'string') {
return def.providedIn === 'any' || (def.providedIn === this.scope);
} else {
return this.injectorDefTypes.has(def.providedIn);
}
}
The guy who handles providedIn
Thus, this function returns true (leading to instance creation) under these conditions:
providedInis set toany(always!)providedInis set torootand you're within the root module injector contextprovidedInis set toSomeModuleandthis.injectorDefTypesincludes that module
That concludes our exploration.
Resources
The example code is available here.
If you're keen on applying Angular internals to tackle business issues, these articles are also worth a look:
- How to avoid Angular injectable instances duplication
- Requests tracking in Angular application with child module injectors without lazy loading
Final Thoughts
Injectables and providers form a substantial part of Angular and Ivy. The way compilation, resolution, and the various operational modes behave can be intricate, yet mastering these details unlocks a deeper command of Angular's dependency injection.
In this analysis, we covered the build-time and runtime handling of injectables by Angular, the different injectable types you can define, and the range of providedIn values at your disposal.
The following visual summarizes the possible providedIn values for quick reference.

We appreciate your time reading this! You can find me on twitter and medium.
Acknowledgements
Many thanks to Max Koretskyi, the mind behind the Indepth platform, for his guidance, thorough review, and the inspiration he provided.
I also extend my sincere gratitude to the AngularInDepth community for their support and constructive feedback:
