The Mechanics of Dependency Injection in Angular
Dependency Injection stands as a foundational pillar in Angular's architecture. This design pattern achieves inversion of control by supplying required instances to a class from the outside, rather than allowing the class to instantiate them itself. The result is a decoupled codebase that significantly simplifies unit testing.
The following exploration examines the inner workings of this system. We will trace the lifecycle of a dependency, from its declaration to its eventual resolution, and investigate the various configuration options developers have at their disposal. Gaining a thorough understanding of DI is essential for architecting robust Angular applications, as it directly influences code maintainability, testability, and overall design quality.
The content presented here is inspired by the Angular Dependency Injection video series from the Decoded Frontend YouTube channel, a valuable resource for those seeking advanced Angular knowledge.
Injecting Dependencies into Classes
Classes marked with decorators such as @Component, @Directive, @Pipe, @Injectable, and @NgModule can receive their required dependencies through the constructor. A typical constructor-based injection pattern looks like this:
@Component({ … })
class UserComponent {
constructor(private userService: UserService) {}
}
Alternatively, the modern inject function offers a different approach:
@Component({ … })
class UserComponent {
private userService = inject(UserService);
}
Introduced in Angular version 14, the inject function provides a concise and legible method for declaring dependencies. Its benefits are numerous:
- It streamlines code by eliminating the need for explicit type annotations, as TypeScript can infer them.
- It simplifies class inheritance; subclasses don't need to forward all dependencies to the parent constructor.
- Logic can be encapsulated within reusable functions. However, this can potentially obscure the function's hidden dependencies.
const getPageParam = (): Observable<string> =>
inject(ActivatedRoute).queryParams.pipe(
map(params => params[‘page’]),
filter(pageParam => pageParam !== null)
)
A critical constraint of the inject function is that it can only be called within an "injection context." Valid contexts include:
- Within a class constructor.
- During the initialization of a class field.
- Inside a
factoryfunction, such as:
- as a
useFactoryproperty within aProviderdefinition, - within the
@Injectabledecorator or a customInjectiontoken's factory.
- Within APIs that operate inside an injection context, like router guards or callbacks passed to
runInInjectionContext.
How the Angular Injector Operates
The core engine behind dependency resolution is the Injector. This abstraction is responsible for both storing and supplying instances of dependencies. When a dependency is needed, the injector first checks its internal cache. If an instance exists, it is retrieved and provided. If not, the injector constructs a new one, passes it to the requesting component, and stores it for future use. Consequently, within a single Injector, every dependency functions as a singleton, ensuring only one instance exists.
Consider a basic service class:
class SomeService {
doSomething() {
console.log('do something');
}
}
And a component that depends on it:
class Component {
constructor(public service: SomeService) {}
}
The injector acts as the central repository and factory for these instances:
class Injector {
private container = new Map();
constructor(private providers: any[] = []) {
this.providers.forEach(service => this.container.set(service, new service()));
}
get(service: any) {
const serviceInstance = this.container.get(service);
if (!serviceInstance) throw new Error('Service not provided');
return serviceInstance;
}
}
At application startup, Angular generates an Injector and populates it with the necessary dependencies required by the components:
const injector = new Injector([SomeService]);
const component = new Component(injector.get(SomeService));
component.service.doSomething();
Hierarchical Structure of Injectors
Dependencies are not defined in a single flat layer. Instead, Angular organizes them within a hierarchy of distinctive injectors, each governing a specific scope:
- Element Injector — This injector is created for each component and directive, holding the dependencies listed in their respective
providersarrays. Its scope includes the component itself and its child view.
@Component({
...
providers: [UserService]
})
export class UserComponent {}
- Environment Injector — A new environment injector is instantiated for each dynamically created component, such as those loaded by the router. This injector, which sits above the element injector in the hierarchy, makes its dependencies available to that component and its descendants.
const routes: Routes = [
{ path: ‘user’, component: UserComponent, providers: [ UserService ] }
]
Environment Root Injector — This is the top-tier environment injector containing dependencies that are globally available. These are typically services decorated with @Injectable and configured with providedIn set to "root" or "platform".
@Injectable({providedIn: 'root'})
export class UserService {
name = 'John'
}
Alternatively, dependencies can be registered in this injector via the providers array of the ApplicationConfig interface:
bootstrapApplication(AppComponent, { providers: [UserService] });
For optimal bundle size, it is advisable to use the @Injectable decorator. Services defined this way are tree-shakeable, meaning they are excluded from the final bundle if they are never used.
- Module Injector — In applications built with
NgModule, this injector is responsible for global dependencies. It contains services decorated with@InjectableandprovidedIn: "root"or"platform", as well as those declared in theprovidersarray of@NgModule. Its configuration also includes dependencies from eagerly loaded modules, and it creates child injectors for lazy-loaded modules.
- Platform Injector — This injector is pre-configured by the Angular framework with platform-specific services like
DomSanitizerand tokens such asPLATFORM_ID. Developers can extend it by adding providers to theextraProvidersarray in theplatformBrowserDynamicfunction. - Null Injector — Positioned at the very top of the hierarchy, its sole purpose is to throw a
"NullInjectorError: No provider for …"error, unless the dependency is marked with the@Optionalmodifier.
When a component requests a dependency, the resolution process follows a precise sequence. Angular first checks the component's own element injector. If the provider is not there, it moves up to the parent component's injector, continuing this climb until a match is found. If the search through the element injector hierarchy is unsuccessful, Angular proceeds to check the environment injector (or module injector for older applications) and then the environment root injector. The next stop is the platform injector. If none of these levels contain the dependency, the null injector is reached, which triggers an error.
This hierarchical ordering ensures that when a dependency is present at multiple levels, the instance registered nearest to the requesting component—at the lowest level—is the one that gets resolved.

Modifiers for Resolution Control
A set of decorator modifiers gives developers granular control over this resolution process:
- @Optional As the name implies, this decorator renders a dependency optional. If it cannot be found, the injector returns
nullinstead of throwing an error. - @Self This restricts the search to the component's own element injector. The dependency must be listed in the component's
providersarray, otherwise a"NodeInjector: NOT_FOUND"error is thrown. - @SkipSelf This is the counterpart to
@Self, instructing the injector to begin its search at the parent component's element injector, bypassing the current one.
@Host This modifier confines the lookup to the host element of the current view. To illustrate, imagine a component MyComponent whose view contains two directives, ParentDirective and ChildDirective. If ChildDirective requires a service, MyService, the compiled view would be structured as follows:
<app-my-component>
<div appParentDirective>
…
<div appChildDirective> … </div>
</div>
</app-my-component>
The host for MyComponent is its own <app-my-component> tag. Therefore, Angular's provider search is limited to:
- the
providersarray ofChildDirective, - the
providersarray ofParentDirective, - the
viewProvidersarray ofMyComponent.
It is important to note that only components can have viewProviders. Dependencies registered there are exposed only to the view of the component itself; they remain inaccessible to content projected via ng-content, even though that content is rendered inside the component.
These modifiers apply when using constructor-based injection. If you use the inject function, you pass flags with corresponding names in the options object instead:
userService = inject(UserService, { optional: true, skipSelf: true });
Understanding Dependency Providers
Now is an opportune moment to delve deeper into the concept of a dependency provider. In essence, it is a set of instructions that dictates to Angular how to create a specific dependency instance.
The most straightforward approach is the TypeProvider, where a class itself serves as the token. Angular instantiates this class using the new operator. This concise syntax is actually a shorthand for a more comprehensive definition described by the Provider interface, which explicitly includes the token for identification and the instantiation recipe.
The Class Provider Strategy
The useClass option within a class provider is used to create and resolve an instance of a specified class. This is particularly useful for substituting the class defined as the token with a subclass, an alternative implementation, or a mock for testing, all without altering the consuming component.
@Injetable()
export class Logger {
log(message: string) {
console.log(message);
}
}
@Injectable()
export class TimeLogger extends Logger {
override log(message: string) {
super.log(`${(new Date()).toLocaleTimeString()}: ${message}}
}
}
@Component({
...,
providers: [ {provide: Logger, useClass: TimeLogger} ]
})
export class MyComponent {
constructor(private readonly logger: Logger) {
logger.log(‘Hello World’); //5:17:35 PM: Hello World
}
}
The example above demonstrates how a component's dependency can be swapped without modifying the component's own code.
Creating Aliases for Tokens
The alias provider allows one token to become an alias for another, as specified in the useExisting field. In this setup, the first token merely points to the class associated with the second one. Angular does not instantiate a new object; it resolves and returns the existing instance.
@Component({
...,
providers: [ TimeLogger, {provide: Logger, useExisting: TimeLogger} ]
})
export class MyComponent {
constructor(private readonly logger: Logger) {
logger.log(‘Hello World’); //5:17:35 PM: Hello World
}
}
This approach guarantees that if a component depends on either the Logger or TimeLogger classes, it will always be given the same, existing instance of TimeLogger. It is crucial to differentiate this from useClass; using useClass would create a new, separate instance of TimeLogger, breaking the singleton semantics.
Dynamic Creation with Factory Providers
When a dependency's instantiation depends on runtime values, the factory provider is the appropriate choice. This provider invokes a function defined in the useFactory field.
@Injectable()
export class SecretMessageService {
constructor(
private readonly logger: Logger,
private readonly isAuthorized: boolean
) {}
private secretMessage = ‘My secret message’;
getSecretMessage(): string | null {
if (!this.isAuthorized) {
this.logger.log(‘Authorize to get secret message!’);
return null;
}
return this.secretMessage;
}
}
@Component({
...,
providers: [
{
provide: SecretMessageService,
useFactory: (logger: Logger, authService: AuthService) =>
new SecretMessageService(logger, authService.isAuthorized),
deps: [Logger, AuthService]
}
]
})
export class MyComponent {
constructor(private readonly secretMessageService: SecretMessageService) {
const secretMessage = this.secretMessageService.getSecretMessage()
}
}
This provider can include a deps field, an array of tokens that are passed as arguments to the factory function in the order they are listed. For functions with many dependencies, it can be more elegant to pass the entire Injector and retrieve dependencies directly within the factory logic, as shown:
{
provide: SecretMessageService,
useFactory: (injector: Injector) => {
const logger = injector.get(Logger);
const authService = injector.get(AuthService);
return new SecretMessageService(logger, authService.isAuthorized)
},
deps: [Injector]
}
A more compelling use case involves dynamically selecting a dependency based on a condition evaluated at runtime. For instance, a service that interfaces with an external API might need to choose its implementation to manage request volume and avoid incurring extra costs:
{
provide: ThirdPartyService,
useFactory: (appConfig: AppConfig, http: HttpClient) =>
appConfig.testEnv ? new ThridPartyMockService() : new ThridPartyService(http),
deps: [APP_CONFIG, HttpClient]
}
Providing Static Values
The useValue provider establishes a token for a static, unchanging value. This method is commonly employed for setting configuration constants or for providing mock data during testing.
@Component({
...,
providers: [ {provide: APP_CONFIG, useValue: {testEnv: !enviroment.production}} ]
})
export class MyComponent {
readonly showTestEnvBanner = this.appConfig.testEnv;
constructor(@Inject(APP_CONFIG) private readonly appConfig: AppConfig) {}
}
Understanding the Purpose of Injection Tokens
When working with value providers, an injection token is essential — but the reasoning behind this requirement deserves a closer look. Every dependency registered within an injector must be associated with a distinct identifier, known as a token, which Angular uses to determine what to instantiate or return. In the case of classes and services, the token is simply the class reference itself. However, complications arise when the dependency isn't a class at all, such as when it's a plain object or a primitive value. An interface cannot serve as a token in this scenario, since interfaces don't exist at runtime in JavaScript — they're stripped out during the transpilation process. On the surface, using a string as a token might seem like a viable alternative:
{ provide: ‘APP_CONFIG’, useValue: {testEnv: !enviroment.production} }
Yet this approach introduces several pitfalls. Typos are surprisingly easy to make, and there's always the risk of inadvertently reusing the same string for multiple unrelated dependencies. This is precisely where the InjectionToken class proves its value:
interface AppConfig {
testEnv: boolean;
}
export const APP_CONFIG = new InjectionToken<AppConfig>(‘app config’);
The argument passed to the constructor isn't meant to serve as an identifier — it's merely a human-readable description. The actual identifier generated by InjectionToken is guaranteed to be unique, eliminating any chance of collision. In the example above, you can observe the @Inject() decorator at work, accepting a direct reference to the relevant token as its argument.
For scenarios where the token should globally represent a value and remain tree-shakeable, an additional options object can be supplied:
export const APP_CONFIG = new InjectionToken<AppConfig>(
‘app config’,
{ providedIn: ‘root’, factory: () => ({ testEnv: !enviroment.production }) }
);
Another configurable aspect of providers is the multi flag. When enabled, this option allows multiple dependencies to be registered under a single token, with Angular returning them as an array. This behavior overrides the default mechanism, which would otherwise replace earlier registrations with the latest one. To demonstrate, let's establish a token and attach two separate values to it. The outcome looks like this:
export const LOCALE = new InjectionToken<string>(‘locale’);
@Component({
...,
providers: [
{ provide: LOCALE, useValue: ‘en’ },
{ provide: LOCALE, useValue: ‘pl’ }
]
})
export class WithoutMultiComponent {
constructor() {
console.log(inject(LOCALE)); // [‘pl’]
}
}
@Component({
…,
providers: [
{ provide: LOCALE, useValue: ‘en’, multi: true },
{ provide: LOCALE, useValue: ‘pl’, multi: true }
]
})
export class WithMultiComponent {
constructor() {
console.log(inject(LOCALE)); // [‘en’, ‘pl’]
}
}
Interceptors represent one of the most frequent applications of this pattern. Following the Single Responsibility Principle, each interceptor carries out a distinct task, and the multi-provider mechanism ensures that all interceptors execute correctly despite sharing the same token.
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
]
The Role of Forward Ref
The forwardRef utility exists to create indirect references that aren't resolved immediately. Because the order in which classes are declared can have significant consequences, this function becomes especially valuable in situations involving circular references or when a component attempts to refer to itself within its own configuration:
@Compnent({
...,
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: forwardRef(() => CustomInputComponent)
}
]
)}
export class CustomInputComponent { ... }
Further Advantages of Dependency Injection
Beyond promoting code modularity and offering greater adaptability, establishing loose dependencies also simplifies the testing process. By substituting real dependencies with mock implementations, developers can isolate the specific functionality under test and observe its behavior within a controlled setting. While testing frameworks typically automate much of this process, manual mocking of dependencies remains a viable strategy when dealing with intricate services:
class MyServiceMock {
getData() {
return of(...)
}
}
describe(MyComponent, () => {
beforeEach(() => {
TestBed.configureTestingModule({
provide: [{ provide: MyService, useClass: MyServiceMock }]
})
}
}
Another design pattern that leverages Dependency Injection is the port-adapter architecture. This pattern separates the definition of an abstraction's shape in one module from its concrete implementation in another. Such separation enables logic decoupling and diminishes inter-module dependencies, as implementations can be swapped out dynamically. An abstract class that both defines an interface and doubles as a token fits this scenario perfectly:
abstract class NotificationPort {
abstract notify(message: string): void;
}
@Injectable()
class SnackbarNotificationAdapter extends NotificationPort {
private readonly snackbarService = inject(SnackbarService);
notify(message: string): void {
this.snackbarService.open(message);
}
}
@Injectable()
class ToastNotificationAdapter extends NotificationPort {
private readonly toastNotificationService = inject(ToastNotificationService);
notify(message: string): void {
this.toastNotificationService.push(message, Theme.INFO)
}
}
{ provide: NotificationPort, useClass: SnackbarNotificationAdapter }
Wrap-Up
Dependency Injection transcends being merely a programming technique; it embodies a philosophy for application design that emphasizes modularity, flexibility, and testability. This article has explored the essential facets of DI within Angular. Adopting Dependency Injection yields numerous rewards, among them heightened code clarity, streamlined dependency oversight, and enhanced flexibility when evolving applications. The exploration of DI in your own projects, along with continued study of recommended practices, is highly encouraged. Embrace Dependency Injection as a core component of your Angular development methodology, reaping its rewards in both the immediate and distant future.
