Tree-shakable dependencies simplify reasoning and reduce bundle size.
For a long time, Angular modules (NgModules) were the standard mechanism for registering application-wide dependencies—constants, configuration, functions, and class-based services. With Angular 6, however, we gained the ability to build tree-shakable dependencies and, in some cases, do away with Angular modules altogether.
The problem: Angular module providers become hard dependencies
Whenever dependencies are registered through the providers array of the NgModule decorator, the module file references those dependency files through its imports. As a result, every service registered in an Angular module ends up in the final bundle, regardless of whether any component, directive, pipe, or other dependency actually consumes it. These are hard dependencies because the build tooling cannot remove them through tree-shaking.
The solution is to reverse the relationship: instead of the Angular module pointing to the dependencies, the dependency files point to the Angular module. That way, importing the Angular module alone does not pull in a service—the service is only referenced when something like a component actually needs it.
Providing singleton services
Many class-based services are application-wide singletons, often just called singleton services, since usage at the platform injector level is uncommon.
The pre-Angular 6 approach
Between Angular 2 and 5, singleton services had to be placed in the providers array of an NgModule. The convention was to put them in a dedicated CoreModule and to make sure that only eagerly loaded modules imported it.
// pre-six-singleton.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
@Injectable()
export class PreSixSingletonService {
constructor(private http: HttpClient) {}
}
// pre-six.module.ts
import { NgModule } from '@angular/core';
import { PreSixSingletonService } from './pre-six-singleton.service';
@NgModule({
providers: [PreSixSingletonService],
})
export class PreSixModule {}
// core.module.ts
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { PreSixModule } from './pre-six.module.ts';
@NgModule({
imports: [HttpClientModule, PreSixModule],
})
export class CoreModule {}
Pre-Angular 6 singleton service.
A service instance becomes shared only at the root injector level. If a lazy-loaded feature module also imports the module that provides the singleton, a separate instance is created for that lazy context.
Providing services in mixed Angular modules
When an Angular module contains both declarables and service providers—a mixed module—the forRoot pattern signals that distinction. Importing a provider module into a lazy-loaded context produces a fresh service instance for that module's injector, even when a root-level instance exists.
// pre-six-mixed.module.ts
import { ModuleWithProviders, NgModule } from '@angular/core';
import { MyComponent } from './my.component';
import { PreSixSingletonService } from './pre-six-singleton.service';
@NgModule({
declarations: [MyComponent],
exports: [MyComponent],
})
export class PreSixMixedModule {
static forRoot(): ModuleWithProviders {
return {
ngModule: PreSixMixedModule,
providers: [PreSixSingletonService],
};
}
}
The forRoot pattern for singleton services.
The static forRoot method is meant for the CoreModule which is tied to the root module injector.
Tree-shakable singleton services
Angular 6 introduced the providedIn property on the Injectable decorator factory. This gives us a cleaner way to declare singleton services.
// modern-singleton.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class ModernSingletonService {
constructor(private http: HttpClient) {}
}
Modern singleton service.
The service instance is created the first time a component that injects it is instantiated.
Best practice dictates that every class-based service is decorated with Injectable. This decorator tells Angular to resolve constructor dependencies for the service.
Before Angular 6, the decorator was technically optional if the service had no dependencies. Still, it was recommended to include it so that later additions of dependencies wouldn't be forgotten.
With providedIn available, the case for always decorating singleton services with Injectable gets even stronger.
An exception exists: services that are always meant to be produced by a factory provider using the useFactory option should not be decorated for constructor injection.
Using providedIn: 'root' registers the service with the root module injector—the injector created for the bootstrapped Angular module, which is typically the AppModule. In practice, this injector serves all eagerly loaded Angular modules as well.
An alternate route is to point providedIn at a specific Angular module. This resembles the older forRoot pattern for mixed modules, with a couple of nuances.
// modern-singleton.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { ModernMixedModule } from './modern-mixed.module';
@Injectable({
providedIn: ModernMixedModule,
})
export class ModernSingletonService {
constructor(private http: HttpClient) {}
}
// modern-mixed.module.ts
import { NgModule } from '@angular/core';
import { MyComponent } from './my.component';
@NgModule({
declarations: [MyComponent],
exports: [MyComponent],
})
export class ModernMixedModule {}
Modern forRoot alternative for singleton services.
Two key differences separate this from using 'root':
- The service cannot be injected unless the referenced Angular module has been imported.
- Lazy-loaded Angular modules and the
AppModuleeach get their own instance due to separate module injectors.
Preventing duplicate instances across injectors
In a typical Angular application that uses one root module, a service could still be instantiated more than once if a module injector gets involved. To keep a single shared instance, we can rely on a factory provider that first checks for an existing instance and only creates one when none is found.
// modern-singleton.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable, Optional, SkipSelf } from '@angular/core';
import { ModernMixedModule } from './modern-mixed.module';
@Injectable({
deps: [[new Optional(), new SkipSelf(), ModernSingletonService], HttpClient],
providedIn: ModernMixedModule,
useFactory: (instance: ModernSingletonService | null, http: HttpClient) => instance || new ModernSingletonService(http),
})
export class ModernSingletonService {
constructor(private http: HttpClient) {}
}
// modern-mixed.module.ts
import { NgModule } from '@angular/core';
import { MyComponent } from './my.component';
@NgModule({
declarations: [MyComponent],
exports: [MyComponent],
})
export class ModernMixedModule {}
Singleton service with protection against multiple injectors.
Angular Material follows this exact approach for singleton services such as MatIconRegistry, as shown in its source code.
The providing module must be part of the root module injector for this guard to work. If two lazy-loaded modules import the same module separately, each will still end up with its own instance.
Prefer the root injector
For most cases, setting providedIn to 'root' is the simplest and most reliable way to define an app-wide singleton.
Beyond being easier to use and reason about, the providedIn option on the Injectable decorator also makes the service tree-shakable, as covered earlier in this series.
Suppose we need to show a deprecation banner for users on Internet Explorer 11. We can create an InjectionToken<boolean> for this purpose.
This token lets us inject a boolean flag anywhere—into services, components, and other pieces of the app. The detection logic runs once per module injector, meaning once for the root and once for each lazy-loaded module.
In Angular 4 and 5, providing a value for an injection token required an Angular module.
// is-internet-explorer.token.ts
import { InjectionToken } from '@angular/core';
export const isInternetExplorer11Token: InjectionToken<boolean> = new InjectionToken('Internet Explorer 11 flag');
// internet-explorer.module.ts
import { NgModule } from '@angular/core';
import { isInternetExplorer11Token } from './is-internet-explorer-11.token';
@NgModule({
providers: [
{
provide: isInternetExplorer11Token,
useFactory: (): boolean => /Trident\/7\.0.+rv:11\.0/.test(navigator.userAgent),
},
],
})
export class InternetExplorerModule {}
Angular 4–5 injection token configured with a factory provider.
Back in Angular 2, an OpaqueToken served a similar role, but without the type parameter that InjectionToken offers.
Starting with Angular 6, a factory can be passed directly to the InjectionToken constructor, so a dedicated Angular module is no longer necessary.
// is-internet-explorer-11.token.ts
import { InjectionToken } from '@angular/core';
export const isInternetExplorer11Token: InjectionToken<boolean> = new InjectionToken('Internet Explorer 11 flag', {
factory: (): boolean => /Trident\/7\.0.+rv:11\.0/.test(navigator.userAgent),
providedIn: 'root',
});
Contemporary injection token with a value factory.
Even though providedIn defaults to 'root' when using a factory provider, keeping it explicit makes the intent clear and keeps the declaration style consistent with services that are created via the Injectable decorator.
Value factories with dependencies
We decide to isolate the user agent string into a dedicated dependency injection token. This lets us reference it in multiple places while reading from the browser only once per module injector.
Back in Angular versions 4 and 5, declaring factory dependencies required the deps option (which stands for dependencies).
// user-agent.token.ts
import { InjectionToken } from '@angular/core';
export const userAgentToken: InjectionToken<string> = new InjectionToken('User agent string');
// is-internet-explorer.token.ts
import { InjectionToken } from '@angular/core';
export const isInternetExplorer11Token: InjectionToken<boolean> = new InjectionToken('Internet Explorer 11 flag');
// internet-explorer.module.ts
import { Inject, NgModule } from '@angular/core';
import { isInternetExplorer11Token } from './is-internet-explorer.token';
import { userAgentToken } from './user-agent.token';
@NgModule({
providers: [
{ provide: userAgentToken, useFactory: () => navigator.userAgent },
{
deps: [[new Inject(userAgentToken)]],
provide: isInternetExplorer11Token,
useFactory: (userAgent: string): boolean => /Trident\/7\.0.+rv:11\.0/.test(userAgent),
},
],
})
export class InternetExplorerModule {}
Angular 4–5 dependency injection token with value factory provider that declares dependencies.
Regrettably, the dependency injection token constructor does not support declaring factory provider dependencies directly. The workaround is to rely on the inject function exported from @angular/core.
// user-agent.token.ts
import { InjectionToken } from '@angular/core';
export const userAgentToken: InjectionToken<string> = new InjectionToken('User agent string', {
factory: (): string => navigator.userAgent,
providedIn: 'root',
});
// is-internet-explorer-11.token.ts
import { inject, InjectionToken } from '@angular/core';
import { userAgentToken } from './user-agent.token';
export const isInternetExplorer11Token: InjectionToken<boolean> = new InjectionToken('Internet Explorer 11 flag', {
factory: (): boolean => /Trident\/7\.0.+rv:11\.0/.test(inject(userAgentToken)),
providedIn: 'root',
});
Modern dependency injection token with value factory that has dependencies.
The inject function pulls dependencies from the module injector where it is registered — in this scenario, that's the root module injector. This approach works for factories within tree-shakable providers. Similarly, tree-shakable class-based services can call it inside their constructors or property initialisers.
To handle optional dependencies through inject, a second argument of InjectFlags.Optional can be provided. The InjectFlags enum lives in @angular/core and accommodates additional injector options through bit flags.
Upcoming Angular releases are expected to broaden the capabilities of inject to cover scenarios like node-based injectors.
Providing platform-specific APIs
To take advantage of platform-specific APIs while keeping code testable, dependency injection tokens serve as useful carriers for these APIs.
Consider the Location type as an illustration — not Angular's version, but the browser's. In a browser, it surfaces as the global location variable and also via document.location. TypeScript defines its type as Location. Injecting this by type into a service could easily mask the fact that Location is actually an interface.
Since interfaces exist only at compile time in TypeScript, Angular has no way to use them as dependency injection tokens. Angular performs dependency resolution at runtime, so it requires artefacts that persist in the runtime environment — much like how a Map or WeakMap needs a concrete key.
The solution is to generate a dependency injection token and use that to bring Location into a service.
// location.token.ts
import { InjectionToken } from '@angular/core';
export const locationToken: InjectionToken<Location> = new InjectionToken('Location API');
// browser.module.ts
import { NgModule } from '@angular/core';
import { locationToken } from './location.token';
@NgModule({
providers: [{ provide: locationToken, useFactory: (): Location => document.location }],
})
export class BrowserModule {}
Angular 4–5 dependency injection token with factory provider.
Just as with primitive values, a token paired with a factory lets us eliminate the Angular module dependency.
// location.token.ts
import { InjectionToken } from '@angular/core';
export const locationToken: InjectionToken<Location> = new InjectionToken('Location API', {
factory: (): Location => document.location,
providedIn: 'root',
});
Modern dependency injection token with API factory.
Inside the API factory, we tap into the global document variable. This serves as the dependency needed to resolve the Location API within the factory. We could spin up yet another token for this, but it turns out Angular already exposes one for this exact platform-specific API — the DOCUMENT token from the @angular/common package.
In Angular 4 and 5, the factory provider would list this dependency through the deps array.
// location.token.ts
import { InjectionToken } from '@angular/core';
export const locationToken: InjectionToken<Location> = new InjectionToken('Location API');
// browser.module.ts
import { DOCUMENT } from '@angular/common';
import { Inject, NgModule } from '@angular/core';
import { locationToken } from './location.token';
@NgModule({
providers: [
{
deps: [[new Inject(DOCUMENT)]],
provide: locationToken,
useFactory: (document: Document): Location => document.location,
},
],
})
export class BrowserModule {}
Angular 4–5 dependency injection token with API factory provider that declares dependencies.
The same trick works here: passing the factory to the token constructor removes the need for the Angular module. The key difference is replacing the declared dependency with a direct call to inject.
// location.token.ts
import { DOCUMENT } from '@angular/common';
import { inject, InjectionToken } from '@angular/core';
export const locationToken: InjectionToken<Location> = new InjectionToken('Location API', {
factory: (): Location => inject(DOCUMENT).location,
providedIn: 'root',
});
Modern dependency injection token with API factory that has dependencies.
We’ve now established a reusable accessor for a platform-specific API. This becomes especially handy when testing declarables and services that depend on it.
Testing tree-shakable dependencies
When writing tests for tree-shakable dependencies, keep in mind that the factories supplied to Injectable and InjectionToken serve as the default providers.
To swap out a tree-shakable dependency, the tool to reach for is TestBed.overrideProvider, like so: TestBed.overrideProvider(userAgentToken, { useValue: 'TestBrowser' }).
Providers defined inside Angular modules show up in tests only when those modules are part of the testing module's imports, as in TestBed.configureTestingModule({ imports: [InternetExplorerModule] }).
Do tree-shakable dependencies matter?
For compact applications the value of tree-shakable dependencies is modest — it's usually pretty clear which services are actually being used.
Where they shine is in a scenario with a shared service library consumed by multiple applications. In that setup, each application bundle can drop any services it doesn't touch. This advantage holds for both monorepo workspaces and multirepo projects that lean on shared libraries.
For Angular libraries themselves, tree-shakable dependencies are equally critical. Picture importing every Angular Material module into an app while only using a handful of components and the class-based services tied to them. Since Angular Material ships tree-shakable services, the final application bundle contains only the services you actually use.
Summary
We’ve covered the modern approach to configuring injectors with tree-shakable providers. When contrasted with the provider patterns from before Angular 6, tree-shakable dependencies are generally cleaner to reason about and less likely to introduce mistakes.
Unreferenced tree-shakable services originating from shared or Angular libraries get stripped out during compilation, which keeps bundle sizes down.
Related articles
Tree-shakable dependencies are one piece of the puzzle for making Angular modules optional. To see what lies ahead with Angular Ivy, check out “Angular revisited: tree-shakable components and optional NgModules”.
For a deep dive into how Angular dependency injection behaves in automated tests, look at “Testing and faking Angular dependencies”.
We’ll also build a browser faker to assist with developing the banner component in “Faking dependencies in Angular applications”.
Peer reviewers
A big shout-out goes to the Angular professionals who contributed invaluable feedback to this piece 🙏
It’s people like these, met through the Angular community, that make the experience worthwhile.
