Dependency Injection (DI) is a widely adopted and effective mechanism for managing dependencies in software. It simplifies how components obtain the services they need, enhances testability, and encourages adherence to the Dependency Inversion Principle. Despite its benefits, a deep understanding of its internal workings often eludes many developers, preventing them from leveraging its full potential. This article delves into the mechanics of Angular's DI, examines the hierarchy of injectors, and explains how this architecture evolved with the arrival of standalone components.

Understanding the Injector

To appreciate how DI functions in Angular, one must first understand how injectors deliver services. The core process can be broken down as follows:
  1. Angular identifies the injector relevant to the current context.
  2. It invokes Injector.get(token), where the token corresponds to the requested service.
  3. If the injector has the token registered, it either returns an already existing instance or constructs and returns a new one.
Consider a basic implementation of an injector that we might write ourselves:

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 service definitions from the providers arrays in modules, components, and classes decorated with @Injectable({providedIn: …}). These definitions are used to populate a Map that enables service lookup. The underlying process in Angular resembles this simplified version:
// 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 operate with just one injector; it relies on a hierarchical structure. During application bootstrap, a tree of injectors is established. When the current injector cannot resolve a token, Angular traverses up to the parent injector. We can replicate this behavior in our custom 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);
      }
      ...
  }
}
Each new injector stores a reference to its parent. If the token isn't found in the current injector, the search moves up the tree. But what happens when the token isn't found anywhere? This is where a special injector, the NullInjector, comes into play.
export class NullInjector implements Injector {
  get(token: ProviderToken): any {
    const error = new Error(`NullInjectorError: No provider for ${stringify(token)}!`);
    error.name = 'NullInjectorError';
    throw error;
  }
}
The NullInjector serves as the root of the entire injector hierarchy. If a search through the chain of injectors fails to locate the token, the NullInjector is invoked, which throws the well-known error: NullInjectorError: No provider for MyService!. Now that this concept is clear, let's examine the different types of injectors Angular uses and how they are interconnected. We'll first review the injector hierarchy as it existed before Angular 14, and then explore the modifications introduced with standalone components.

The Hierarchy of Injectors

Core Injectors

During the bootstrap process, Angular creates three primary injectors:
  • NullInjector: Its sole purpose is to raise an error if a token is not found.
  • Platform Injector: It manages services that should be shared among multiple applications within the same Angular project.
  • Root Injector: It hosts Angular's default services, as well as those provided via @Injectable({ providedIn: 'root' }) or declared in the providers property of the AppModule metadata.
When requesting a service, Angular first checks the Root Injector. If it's not found there, the search proceeds to the Platform Injector. If the service remains elusive, the NullInjector will throw an error. This flow can be visualized as follows:

Cracking Angular DI: The Hidden Layers of Injectors — figure 1

There's a subtle point to keep in mind: if a module with providers is imported into the AppModule, it does not get its own injector. Instead, its services are merged into 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 was something I personally found counterintuitive when learning Angular—my initial assumption was that each module would have its own dedicated injector.

Lazy-Loaded Module Injectors

Beyond the three core injectors, Angular 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 a service is provided in the providers array of a lazy-loaded module, that module's injector is responsible for instantiating it for its own components. This service remains scoped to that module and is invisible to the rest of the application. If a component within a lazy-loaded module needs a service, the lookup begins with the module's own injector. If the service isn't found, the search continues up the standard chain: Root Injector → Platform Injector → NullInjector.

Cracking Angular DI: The Hidden Layers of Injectors — figure 2

Node Injectors

In addition to module-level injectors, Angular utilizes a different kind: the node (element) injector.
  • The root component always creates a node injector for itself.
  • A node injector is created for every HTML element that corresponds to a component selector or serves as a host for directives.
To add a service to a component's node injector, you must 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 key distinction between a node injector and a module injector is their lifecycle. A node injector is created when its associated component is created and is destroyed when the component is destroyed. Consequently, the services registered with it have the same creation and destruction lifecycle. Node injectors also form a hierarchy, much like module injectors. The service lookup starts from the current node injector and ascends through the parent components until it either finds the service or reaches the root component. Consider the following component tree:
<app-root>
    <app-parent>
        <app-child></app-child>
        <app-child></app-child>
    </app-parent>
</app-root>
If a service is injected into the app-child component, the resolution order will be: app-child → app-parent → app-root.

Cracking Angular DI: The Hidden Layers of Injectors — figure 3

Complete Injector Tree
@Component({...})
export class Component {
    // how does it work?
    constructor(private readonly service: Service) {
    }
}
When a component requires a service, Angular's resolution process first queries its own node injector. If the service isn't there, the search propagates through the parent node injectors, then to the lazy-loaded module injectors, and finally up the default chain: Root Injector → Platform Injector → NullInjector. This final injector throws an error if the token remains unresolved.

Cracking Angular DI: The Hidden Layers of Injectors — figure 4

I've created an example on StackBlitz that illustrates these concepts in detail. For hands-on exploration, I recommend installing the Angular DevTools extension, which lets you inspect the injector tree directly.

Cracking Angular DI: The Hidden Layers of Injectors — figure 5

This is what the injector tree looks like in Angular DevTools. If you need a quick reference, you can consult the cheatsheet created by Chris Kohler. It's a valuable resource for any DI-related questions.

The Standalone Revolution

Environment Injectors

The release of standalone components in Angular 14 brought about a significant shift in the injector hierarchy. Angular now employs the EnvironmentInjector in favor of module injectors. In a purely standalone application, there are no modules, so the Angular team adopted this more consistent naming convention. Despite the new name, the resolution order for the core injectors remains unchanged: Root Environment Injector → Platform Environment Injector → NullInjector. To provide a service at the root level, you have two primary methods: using the @Injectable({ providedIn: 'root' }) decorator, or using the ApplicationConfig provider, which now replaces the AppModule providers array.
export const appConfig: ApplicationConfig = {
  providers: [SomeService],
};

bootstrapApplication(AppComponent, appConfig).catch((err) =>
  console.error(err)
);

Route Environment Injectors

Angular 14 also introduced the loadComponent function, which facilitates the lazy-loading of components, analogous to how loadChildren works for modules. It's a common guess that loadComponent might also set up a dedicated injector, but that's not the case. In fact, 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 scope services to a particular route (similar to a lazy-loaded module's injector), you can use the providers property within the route configuration. This setup will generate a separate injector for that specific route and all its child routes, regardless of whether the route is lazy-loaded.
export const ROUTES: Route[] = [
    {
        path: 'route-with-providers',
        component: ChildComponent,
        // it does create a new injector for the route
        providers: [SomeService],
        children: [...]
    },
]
Here is the full service resolution process:

Cracking Angular DI: The Hidden Layers of Injectors — figure 6

You can see a working example of this on StackBlitz.

Backward Compatibility with NgModule

Recognizing that migrating to standalone components is an incremental process, the Angular team added backward compatibility with the older NgModule approach. A standalone component can import either another standalone component or a traditional module.
@Component({
  standalone: true,
  selector: 'app-child',
  imports: [
    SomeComponent, // standalone component
    SomeModule // "old" NgModule
  ], 
  template: ``,
})
export class ChildComponent {
}
While this feature eases the transition, it can also introduce unexpected behavior, especially when a module with services is imported into a standalone component. Because standalone components are meant to be independent units, it would be architecturally inconsistent for their imported services to leak into the global root injector. Therefore, a standalone component must encapsulate this logic itself. To address this, Angular creates a special injector-wrapper for standalone components that import any modules, even if those modules don't have providers. This wrapper is responsible for collecting and managing the services declared by the imported modules. An injector-wrapper is generated under three specific circumstances:
  1. During bootstrap: This occurs if the root component imports modules or uses another component that does.
  2. For dynamically created components: This happens when a dynamic component imports modules or uses a component that does.
  3. For routed components: This is triggered when a routed component imports modules or uses a component that does.
Let's examine each of these scenarios.
  1. During Bootstrap
If we import a component that includes modules into our AppComponent, an injector-wrapper will be instantiated.
@Component({
  standalone: true,
  selector: 'app-root',
  imports: [ChildComponent], // the component with modules inside
  template: `
    <app-child /><app-child />
  `,
})
export class AppComponent {
}
The hierarchy of environment injectors will then have this structure:

Cracking Angular DI: The Hidden Layers of Injectors — figure 7

  1. Dynamically Created Components
An injector-wrapper is created when a dynamically created component imports modules, or when it uses another component that does.
@Component({
  ...
})
export class AppComponent {
  click(): void {
    // dynamically creation of component with modules
    const compRef = this.viewContainerRef.createComponent(ChildComponent);
    compRef.changeDetectorRef.detectChanges();
  }
}
  1. Routed Components
If a route targets a component that imports modules, or that itself uses a component with modules, an injector-wrapper is created for it as well.
export const ROUTES: Route[] = [
    {
        path: 'child',
        // the component with modules inside
        component: ChildComponent,
    },
]
For a more hands-on understanding of these examples, please see the prepared cases on StackBlitz. You can also investigate them on your own using Angular Devtools.

Final Thoughts

Thank you for reading. Rather than a formal conclusion, I'd like to open a discussion. There's a school of thought that suggests placing every service in the root injector to avoid worrying about Angular's injector hierarchy altogether. However, I believe a more nuanced approach is beneficial. What's your perspective on this? Feel free to reach out if you have any questions!