Understanding module encapsulation
Angular's module system brings encapsulation that resembles what you get with ES modules. In practice, this means that declarable types—components, directives, and pipes—are only usable within the components declared in the same module. For instance, if you attempt to use a-comp from the A module inside the App component of the App module:
@Component({
selector: 'my-app',
template: `
<h1>Hello {{name}}</h1>
<a-comp></a-comp>
`
})
export class AppComponent { }
The result is an error message:
Template parse errors: 'a-comp' is not a known element
This happens because a-comp hasn't been declared in the App module. To make this component available, you need to import the module that declares it. That import looks like this:
@NgModule({
imports: [..., AModule]
})
export class AppModule { }
Encapsulation becomes relevant here. For this arrangement to work, the A module must explicitly make a-comp public by including it in the exports array:
@NgModule({
...
declarations: [AComponent],
exports: [AComponent]
})
export class AModule { }
This principle applies equally to other declarable elements, namely directives and pipes:
@NgModule({
...
declarations: [
PublicPipe,
PrivatePipe,
PublicDirective,
PrivateDirective
],
exports: [PublicPipe, PublicDirective]
})
export class AModule {}
Keep in mind that components placed in the entryComponents are not subject to encapsulation. If you're working with dynamic views and instantiating components at runtime as detailed in the Here is what you need to know about dynamic components in Angular guide, you can reference components from the A module even if they aren't in the exports array. Of course, you'll still need to import the A module itself.
A common mistake among newcomers is assuming that providers also enjoy encapsulation. That's incorrect. A provider declared in any non-lazy loaded module is accessible from anywhere in the application. The reasons for this will become clear in the next section.
How modules fit together
A frequent source of confusion is the belief that imported modules form some kind of parent-child relationship. It seems logical to think that a module importing other modules would sit above them in a hierarchy. But that's not how Angular operates. Every module gets merged together during the compilation phase. Consequently, there's no hierarchical link between an imported module and the one doing the importing.
Just as with components, the Angular compiler generates a factory for the root module—the one you pass to the bootstrapModule method inside main.ts:
platformBrowserDynamic().bootstrapModule(AppModule);
This factory relies on the createNgModuleFactory function, which expects:
- a reference to the module class
- the bootstrap components
- a component factory resolver handling entry components
- a definition factory containing merged module providers
Those last two points clarify why providers and entry components don't benefit from encapsulation. After compilation, you're not left with multiple modules; instead, you have a single merged entity. During compilation, the compiler cannot predict where or how you'll use providers and dynamic components, so guarding them with encapsulation isn't feasible. However, when parsing a component template, this context is available, allowing private declarables like components, directives, and pipes to be restricted.
Let's look at a concrete example of what a generated module factory looks like. Assume you have A and B modules, each contributing one provider and one entry component:
@NgModule({
providers: [{provide: 'a', useValue: 'a'}],
declarations: [AComponent],
entryComponents: [AComponent]
})
export class AModule {}
@NgModule({
providers: [{provide: 'b', useValue: 'b'}],
declarations: [BComponent],
entryComponents: [BComponent]
})
export class BModule {}
Meanwhile, the App root module adds its own provider, includes the root app component, and imports both A and B modules:
@NgModule({
imports: [AModule, BModule],
declarations: [AppComponent],
providers: [{provide: 'root', useValue: 'root'}],
bootstrap: [AppComponent]
})
export class AppModule {}
When the compiler produces a module factory for the App root module, it merges all providers from every module and creates a single factory for this one combined module. The resulting factory looks like this:
createNgModuleFactory(
// reference to the AppModule class
AppModule,
// reference to the AppComponent that is used
// to bootstrap the application
[AppComponent],
// module definition with merged providers
moduleDef([
...
// reference to component factory resolver
// with the merged entry components
moduleProvideDef(512, jit_ComponentFactoryResolver_5, ..., [
ComponentFactory_<BComponent>,
ComponentFactory_<AComponent>,
ComponentFactory_<AppComponent>
])
// references to the merged module classes
// and their providers
moduleProvideDef(512, AModule, AModule, []),
moduleProvideDef(512, BModule, BModule, []),
moduleProvideDef(512, AppModule, AppModule, []),
moduleProvideDef(256, 'a', 'a', []),
moduleProvideDef(256, 'b', 'b', []),
moduleProvideDef(256, 'root', 'root', [])
]);
Notice how providers and entry components from all modules are combined and handed to the moduleDef function. Regardless of how many modules you import, you end up with one factory that holds merged providers. That factory is responsible for creating a module instance along with its own injector. Because there's only one merged module, Angular sets up a single root injector fed by these providers.
What happens if two modules define a provider with the same token?
The first rule states that a provider from the module doing the importing always takes precedence. Using our setup, let's add an a provider to the root module:
@NgModule({
...
providers: [{provide: 'a', useValue: 'root'}],
})
export class AppModule {}
Now, let's inspect the resulting factory:
moduleDef([
...
moduleProvideDef(256, 'a', 'root', []),
moduleProvideDef(256, 'b', 'b', []),
]);
You can observe that the merged factory includes {provide: 'a', useValue: 'root'} from the App module, which overrides the identical token from the A module.
The second rule says that a provider from the most recently imported module supersedes providers from earlier ones, except for the module doing the importing (which follows from the first rule). Let's modify our example so that the B module defines the a provider:
@NgModule({
...
providers: [{provide: 'a', useValue: 'b'}],
})
export class BModule {}
Now the App module imports A and B in that order:
@NgModule({
imports: [AModule, BModule],
...
})
export class AppModule {}
Both modules have the same provider token. Let's examine the factory:
moduleDef([
...
moduleProvideDef(256, 'a', 'b', []),
moduleProvideDef(256, 'root', 'root', []),
]);
That confirms the rule. The provider uses the b value from the B module. Now, if we swap the import order:
@NgModule({
imports: [BModule, AModule],
...
})
export class AppModule {}
And check the factory once more:
moduleDef([
...
moduleProvideDef(256, 'a', 'a', []),
moduleProvideDef(256, 'root', 'root', []),
]);
Everything aligns with expectations. With the swapped order, the a provider from A takes priority over the same token from B.
Handling lazy loaded modules
Lazy loaded modules bring their own set of questions. The official docs explain:
Angular creates a lazy-loaded module with its own injector, a child of the root injector… So a lazy-loaded module that imports that shared module makes its own copy of the service.
So, Angular assigns its own injector to each lazy loaded module. This occurs because Angular generates a separate factory for every such module. This means providers in these modules aren't folded into the main module injector. If a lazy loaded module defines a provider with the same token as one in the root, Angular will instantiate a fresh service instance, even if one already exists in the root injector.
Thus, lazy loaded modules do establish a hierarchy, but it's a hierarchy of injectors, not modules. Imported modules still merge into a single factory during compilation, just like non-lazy ones.
The relevant source from RouterConfigLoader shows how lazy modules are loaded and how the injector hierarchy is built:
export class RouterConfigLoader {
load(parentInjector, route) {
...
const modFactory = this.loadModuleFactory(route.loadChildren);
const module = modFactory.create(parentInjector);
}
private loadModuleFactory(loadChildren) {
...
return this.loader.load(loadChildren)
}
}
On this particular line:
const module = modFactory.create(parentInjector);
you can see that a new instance of the loaded module is created, with the parent injector passed in as an argument.
The purpose of forRoot and forChild
Here's what the official documentation suggests:
Add a
CoreModule.forRootmethod that configures the coreUserService… CallforRootonly in the root application module,AppModule
That advice makes sense, but without grasping the underlying reason, you might end up doing something like this:
@NgModule({
imports: [
SomeLibCarouselModule.forRoot(),
SomeLibCheckboxModule.forRoot(),
SomeLibCloseModule.forRoot(),
SomeLibCollapseModule.forRoot(),
SomeLibDatetimeModule.forRoot(),
...
]
})
export class SomeLibRootModule {...}
In this scenario, each imported module (CarouselModule, CheckboxModule, etc.) has no providers at all. There's no good reason to use forRoot here. Let's explore why forRoot exists in the first place.
When importing a module, you typically reference the module class directly:
@NgModule({ providers: [AService] })
export class A {}
@NgModule({ imports: [A] })
export class B {}
This way, every provider defined on module A gets added to the root injector and is available throughout the app. You're already aware of why this is—all module providers get merged, as demonstrated in the first section.
Angular also allows registering a module with providers via an object that implements the ModuleWithProviders interface:
interface ModuleWithProviders {
ngModule: Type<any>
providers?: Provider[]
}
Here's how to apply that approach in our example:
@NgModule({})
class A {}
const moduleWithProviders = {
ngModule: A,
providers: [AService]
};
@NgModule({
imports: [moduleWithProviders]
})
export class B {}
Rather than importing the moduleWithProviders object directly, it's cleaner to define a static method on the module class that returns that object. Let's call this method forRoot and update our example:
@NgModule({})
class A {
static forRoot() {
return {ngModule: A, providers: [AService]};
}
}
@NgModule({
imports: [A.forRoot()]
})
export class B {}
This was just for illustration. In this simple case, there's no need for a forRoot method or a module-with-providers object, since both the module and the object offer the same set of providers. But it becomes valuable when you need to split providers and offer different sets depending on where the module is imported.
For instance, imagine wanting a global A service for non-lazy loaded modules and a B service for lazy loaded ones. At that point, the approach makes sense. You'd use forRoot to supply providers for eager modules and forChild for lazy modules:
@NgModule({})
class A {
static forRoot() {
return {ngModule: A, providers: [AService]};
}
static forChild() {
return {ngModule: A, providers: [BService]};
}
}
@NgModule({
imports: [A.forRoot()]
})
export class NonLazyLoadedModule {}
@NgModule({
imports: [A.forChild()]
})
export class LazyLoadedModule {}
Since non-lazy loaded modules get merged, the providers from forRoot become available application-wide. However, lazy loaded modules have their own injectors, so providers from forChild are only accessible within that specific lazy-loaded module.
Keep in mind that the method names you pick for returning
ModuleWithProviderscan be anything you like. The namesforChildandforRootI've used are just customary labels endorsed by the Angular team and employed in theRouterModule.
Returning to our earlier setup:
@NgModule({
imports: [
SomeLibCarouselModule.forRoot(),
SomeLibCheckboxModule.forRoot(),
...
It's pointless to implement a forRoot method for modules that only serve to define app-wide providers and don't have a separate subset for lazy-loaded scenarios. And it's even more perplexing to apply these methods when a module has no providers whatsoever.
Reserve the forRoot/forChild pattern for shared modules with providers that could be imported into either eager or lazy modules.
There's one more aspect to consider regarding forRoot and forChild—they're just regular methods, so you can pass options or extra providers when calling them. The RouterModule is a great example. Its forRoot method accepts both additional providers and configuration:
export class RouterModule {
static forRoot(routes: Routes, config?: ExtraOptions)
The routes you provide get registered under the ROUTES token:
static forRoot(routes: Routes, config?: ExtraOptions) {
return {
ngModule: RouterModule,
providers: [
{provide: ROUTES, multi: true, useValue: routes}
And the second parameter's options are used to configure other providers:
static forRoot(routes: Routes, config?: ExtraOptions) {
return {
ngModule: RouterModule,
providers: [
{
provide: PreloadingStrategy,
useExisting: config.preloadingStrategy ?
config.preloadingStrategy :
NoPreloading
}
As you can see, RouterModule uses forRoot and forChild to separate provider sets and adjust them based on the given options.
How modules are cached
Developers occasionally raise a concern on Stack Overflow about importing a module into both lazy-loaded and eager-loaded modules, worrying that this will lead to duplicated module code at runtime. It’s a reasonable fear, but there’s no cause for alarm: every module loader available today caches the modules it loads.
When SystemJS loads a module, it places it in its cache. If that same module is requested again later, the loader retrieves it from cache instead of making a fresh network call. This behavior applies uniformly across all modules. For instance, when building Angular components, you import the Component decorator from the angular/core module:
import { Component } from '@angular/core';
That package is referenced throughout the application, but SystemJS doesn’t fetch angular/core on each occasion. It loads the module once and then serves it from cache.
The same principle holds when using Webpack, whether through angular-cli or a custom configuration. The module code is included in the bundle just once and assigned an ID. Every other module depends on symbols from this module via that ID.
That wraps it up. Thank you for reading! You may also be interested in my NgConf talk on modules. It covers why lazy-loaded modules behave identically to eager-loaded ones and how the RouterModule functions internally.
