Dependency Injection (DI) is one of the most important mechanisms in Angular. This pattern allows for inversion of control by passing instances of requested dependencies to the class instead of creating them inside the class. This approach creates loose coupling and makes testing easier.
In this article I would like us to take a closer look at how it works. We will find out how dependencies are defined, created and resolved and how developers can customize this process. I invite you to explore the ins and outs of Dependency Injection in Angular and discover why DI is a key concept in designing applications in Angular, the benefits of using it, and how to apply it effectively in practice.
This article is inspired by Angular Dependency Injection series on YouTube channel Decoded Frontend. If you’re looking for advanced Angular tutorials this is a great place to visit.
How to inject dependencies in Angular?
Angular allows the injection of necessary dependencies, such as classes, functions, or primitives to classes decorated with @Component, @Directive, @Pipe, @Injectable and @NgModule by defining them as a constructor parameter:
@Component({ … })
class UserComponent {
constructor(private userService: UserService) {}
}
or using inject function:
@Component({ … })
class UserComponent {
private userService = inject(UserService);
}
The inject function in its current form was introduced in version 14. Aside from bringing a convenient and readable way of declaring dependencies, it also offers the following advantages:
- It allows the omission of explicit typing — let TypeScript do it for you.
- Class extension is made easier without the necessity of passing every argument to the base class constructor.
- Additionally, it lets the programmer move the logic to reusable functions — the downside here, however, is hiding dependencies inside the function.
const getPageParam = (): Observable<string> =>
inject(ActivatedRoute).queryParams.pipe(
map(params => params[‘page’]),
filter(pageParam => pageParam !== null)
)
It’s worth remembering that the inject function can only be used inside an injection context. This means
- Within a constructor,
- As a definition of a class field,
- Inside the factory function,
- as useFactory in the Provider interface,
- @Injectable decorator or a factory in the Injection token definition,
- An API within the injection context, such as a router guard or a runInInjectionContext function callback.
How does the Angular Injector work?
An abstraction called Injector is responsible for resolving dependencies. It can store an instance of a required dependency. If it already exists, it’s passed onto the consumer. Otherwise, a new instance is created and passed as a constructor parameter and stored in memory. Every dependency inside an Injector is a singleton — which means there’s always only one instance.
To better demonstrate this process, let’s create a simple example. Let's assume that we have a class representing some service:
class SomeService {
doSomething() {
console.log('do something');
}
}
and a class representing a component which uses the service:
class Component {
constructor(public service: SomeService) {}
}
The injector is responsible for storing and returning an instance of the dependency:
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;
}
}
During the bootstrapping, Angular creates the Injector and registers dependencies which will be passed to components:
const injector = new Injector([SomeService]);
const component = new Component(injector.get(SomeService));
component.service.doSomething();
Hierarchical Injectors in Angular
Dependencies can be defined on several levels and organized in hierarchies. This is done using different types of injectors:
- Element Injector — registers dependencies defined in providers inside the @Component or @Directive decorators. These dependencies are available for the component and its children.
@Component({
...
providers: [UserService]
})
export class UserComponent {}
- Environment Injector — child hierarchies of the environment injector are created whenever dynamically loaded components are created, such as with a router. In that case, the injector is available for components and its children. It is higher in the hierarchy than the element injector in the same component.
const routes: Routes = [
{ path: ‘user’, component: UserComponent, providers: [ UserService ] }
]
Environment Root Injector — contains globally available dependencies decorated with @Injectable and having providedIn set to “root” or “platform”.
@Injectable({providedIn: 'root'})
export class UserService {
name = 'John'
}
or defined in providers of the ApplicationConfig interface:
bootstrapApplication(AppComponent, { providers: [UserService] });
To achieve better optimization, it’s recommended to use the @Injectable decorator. Such a definition makes dependencies tree-shakeable — they are removed from bundled files if they haven't been used.
- Module Injector — in module-based applications, this injector stores global dependencies decorated with @Injectable and having providedIn set to “root” or “platform”. Additionally, it keeps track of dependencies defined in the providers array within @NgModule. During compilation, Angular also recursively registers dependencies from eagerly loaded modules. Child hierarchies of Module Injector are created by lazy loaded modules.
- Platform Injector — configured by Angular, this injector registers platform-specific dependencies such as DomSanitizer or the PLATFORM_ID token. Additional dependencies can be defined by passing them to the extraProviders array in the platformBrowserDynamic function parameter.
- Null Injector — the highest injector in the hierarchy. Its job is to throw the error “NullInjectorError: No provider for …” unless the @Optional modifier was used.
If a component requires a dependency, Angular first looks for it in the element injector of the component. If it isn’t defined in the providers array, then the framework looks at the parent component. This process repeats for as long as Angular finds a dependency in an ancestor. If the dependency isn’t found, the next phase is searching in the environment injector (or the module injector in the case of module-based applications), and then the environment root injector. Finally, the platform injector is checked. If Angular reaches the null injector an error is thrown.
In this hierarchical order, if a dependency exists in more than injector, the instance defined on the lowest level, the one closest to the component is resolved.
Angular Resolution Modifiers
The process we just described is affected by the following modifiers:
- @Optional makes a dependency, well, optional. If one hasn’t been resolved, instead of throwing an error Angular returns null.
- @Self makes Angular look for the dependency only in the element injector of the component, meaning it has to be defined in the providers array of the component. In any other case the “NodeInjector: NOT_FOUND” error is thrown.
- @SkipSelf is the opposite of the @Self modifier. Angular starts looking for dependency from the Element Injector of the parent component.
@Host restricts the dependency lookup to the host of the element. Let's use a simple example to demonstrate how the @Host modifier works.. Let’s assume we have a MyComponent component which uses two directives in its view: ParentDirective and ChildDirective. ChildDirective requires injecting MyService service. A built view of this fragment would look something like this:
<app-my-component>
<div appParentDirective>
…
<div appChildDirective> … </div>
</div>
</app-my-component>
The host of MyComponent is restricted by its <app-my-component> tag. In practice, it means that Angular only checks the following in its search for the provider:
- the providers array of ChildDirective,
- the providers array of ParentDirective,
- the viewProviders array of MyComponent.
Only components can define viewProviders. Dependencies provided there are available in the host of the component — they are not available for content projected via ng-content despite it being a logical descendant of the component.
Described decorators can be used for dependencies defined as constructor parameters. When we use the inject function, flags with names corresponding to decorators should be set in the options object:
userService = inject(UserService, { optional: true, skipSelf: true });
What Is a Dependency Provider?
At this point it is worth describing more broadly what a dependency provider is. In a nutshell, it's a recipe which tells Angular how to create an instance of dependency.
The default and the most straightforward way is to define a TypeProvider, using class as a token. An instance of that class is created using the new operator. This is, in fact, syntactic sugar which is expanded into a full definition described by the Provider interface. Such a declaration consists of a token to identify the dependency and a definition of how an instance should be created.
Class Provider
The class provider contains the option useClass — its job is to create and resolve a new instance of the defined class. It replaces the class defined as a token by its extension, classes with different implementation, or its mock for testing purposes.
@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
}
}
This example shows how to change implementation of dependency without making changes in the component itself.
Alias Provider
The alias provider maps one token to another, as defined in the useExisting field. The first token is an alias for the class associated with the second one. Angular doesn’t create a new instance but instead resolves an existing one.
@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 type of definition ensures that if the component depends on the Logger or TimeLogger classes, the existing instance of TimeLogger is always resolved. It’s worth noting the difference between useExisting and useClass. If we were to use useClass a new, independent instance of TimeLogger would be created.
Factory Provider
Using the factory provider, let’s create a dependency based on runtime values by calling a function defined in useFactory.
@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 contains an additional field, deps, which is an array of tokens passed as arguments of the factory function. The order in which they are defined is important. For functions with more arguments, it may be more convenient and flexible to replace them with a single Injector, which allows Angular to retrieve the needed dependencies inside the function. It would look something like this:
{
provide: SecretMessageService,
useFactory: (injector: Injector) => {
const logger = injector.get(Logger);
const authService = injector.get(AuthService);
return new SecretMessageService(logger, authService.isAuthorized)
},
deps: [Injector]
}
Another interesting use case is when we don’t know in advance what dependency we want to use, and it is determined by some runtime condition. To use a simple example, we could have a service that connects to an external API, and we want to limit the number of requests sent so as not to generate additional costs:
{
provide: ThirdPartyService,
useFactory: (appConfig: AppConfig, http: HttpClient) =>
appConfig.testEnv ? new ThridPartyMockService() : new ThridPartyService(http),
deps: [APP_CONFIG, HttpClient]
}
Value Provider
The value provider allows us to associate a static value defined within the useValue with a token. This technique is usually used for resolving configuration constants or mocking data in tests.
@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) {}
}
Why Do We Need an Injection Token?
In the case of a value provider, an injection token is necessary.But why do we need it? Each dependency in an injector has to be described by a unique identifier — a token — so that Angular knows what should be resolved. For classes, as well as services, a token is a reference to the class itself. But what if the dependency is not a class, but an object, or even a primitive type? We cannot use an interface as a token, because such a construct does not exist in JavaScript — it will be removed during transpilation. Theoretically, we can use a string as a token:
{ provide: ‘APP_CONFIG’, useValue: {testEnv: !enviroment.production} }
However, this solution has a number of drawbacks. It’s very easy to imagine making a typo or accidentally using the same value for different dependencies. This is where the InjectionToken comes to the rescue:
interface AppConfig {
testEnv: boolean;
}
export const APP_CONFIG = new InjectionToken<AppConfig>(‘app config’);
The value given as an argument to the constructor is not an identifier, but a description — the identifier created by InjectionToken is always unique.
As you can see from the example above, we use the @Inject() decorator by passing a reference to the token in question as an argument.
If we want the token to globally represent a value and be tree-shakeable, we can additionally use the option object:
export const APP_CONFIG = new InjectionToken<AppConfig>(
‘app config’,
{ providedIn: ‘root’, factory: () => ({ testEnv: !enviroment.production }) }
);
Another parameter we can configure in the provider is multi. Setting its value to true allows us to bind multiple dependencies to a single token and return them as an array. This prevents the default behavior of overwriting dependencies. To illustrate this, let's create a token to which we will then assign two values. Here is the result we get:
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’]
}
}
One of the most common use cases for this are interceptors. According to the Single Responsibility Principle, each interceptor is responsible for a different action, and a multi-provider allows each interceptor to act even though they use the same token.
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
]
Forward Ref
The forwardRef function is used to create indirect references that are not resolved immediately. Since the order in which classes are defined matters, it is particularly useful when references are looped or when a component tries to use a reference to itself in its configuration:
@Compnent({
...,
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: forwardRef(() => CustomInputComponent)
}
]
)}
export class CustomInputComponent { ... }
Additional Benefits of Dependency Injection
In addition to code modularity and greater flexibility, creating loose dependencies makes testing easier. Replacing dependencies with their mock counterparts allows us to isolate the functionalities being tested and check their behavior in a controlled environment. Admittedly, testing frameworks take care of this for us, but in the case of complex services we can mock dependencies manually:
class MyServiceMock {
getData() {
return of(...)
}
}
describe(MyComponent, () => {
beforeEach(() => {
TestBed.configureTestingModule({
provide: [{ provide: MyService, useClass: MyServiceMock }]
})
}
}
A design pattern that we can use using Dependency Injection is port-adapter. It assumes that one module defines the shape of an abstraction and another provides its implementation. This allows us to decouple logic and loosen dependencies between modules, since the implementation can be swapped on-the-fly. This is where an abstract class that creates an interface and can also be used as a token works great:
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 }
Conclusions
Dependency Injection is not only a programming technique, but also an application design philosophy that promotes solutions that are modular, flexible and easy to test. In this article, we discussed key aspects of DI in the context of Angular. Using Dependency Injection brings a number of benefits, including increased code readability, easier dependency management and flexibility in modifying applications. We encourage you to experiment with its use in your own projects and to deepen your knowledge of the best practices. Let Dependency Injection become an integral part of your approach to developing applications in Angular, bringing benefits both in the short and long term.