Angular Modules and NgModule — A Complete Walkthrough

This guide explores Angular’s modularity system, centered around the NgModule decorator, and examines how it underpins key capabilities such as ahead-of-time (AOT) compilation and lazy loading. We’ll address a range of topics:

  • Defining an Angular Module
  • Comparing Angular Modules with ES6 Modules
  • Establishing a Root Module
  • Streamlining module definitions with the spread operator
  • Visibility rules within Angular Modules
  • Dependency Injection concerns and frequent mistakes
  • Dynamic bootstrap with Just-In-Time (JIT) compilation
  • How the Angular AOT compiler operates
  • Static bootstrap procedures
  • Building Feature Modules
  • Integrating Modules with the Router
  • Implementing Lazy Loading through the Router
  • Shared Modules and their interaction with Lazy Loading
  • Concluding overview

Understanding Angular Modules

An Angular Module, declared with the NgModule decorator, serves as a cohesive block that groups related components, directives, pipes, and services. It provides a compilation context for templates and a boundary for dependency injection, helping structure applications into understandable units.

Angular Modules versus ES6 Modules

It’s crucial to distinguish Angular Modules from ES6 modules. ES6 modules handle code organization and loading at the file level using import and export statements. In contrast, an Angular Module is a higher-level construct that uses the NgModule decorator to assemble specific application pieces, influencing how the framework compiles templates and resolves dependencies.

Setting Up the Root Module

Every Angular application starts with a root module, conventionally named AppModule. This module is responsible for bootstrapping the application by specifying the root component that Angular renders. It acts as the entry point that ties the entire application together.

Enhancing Module Readability with the Spread Operator

To keep module definitions concise and readable, you can use the spread operator (...) to combine lists of declarations, imports, or providers. This pattern reduces redundancy and makes it easier to manage large arrays of dependencies, especially when reusing common sets across multiple modules.

Visibility and Encapsulation in Angular Modules

Angular Modules introduce a concept of visibility. Only items explicitly listed in the exports array are available for use in other modules. This encapsulation ensures that internal implementation details of a module remain private unless intentionally exposed, promoting a clean API for each feature area.

Dependency Injection within Angular Modules

Dependency injection in Angular is closely tied to modules and their providers. A common pitfall involves providing a service in multiple modules, which can lead to multiple instances instead of a singleton. Understanding the injector hierarchy—where the root injector is created from the root module—is essential to avoid unexpected behavior.

Dynamic Bootstrapping and Just-In-Time Compilation

During development, Angular typically uses Just-In-Time (JIT) compilation. At runtime, the application is bootstrapped dynamically, and the compiler translates templates in the browser. This approach speeds up the development cycle but increases the application's initial load time, as the compiler must be shipped to the client.

The Ahead-Of-Time Compiler in Action

For production, Angular offers Ahead-Of-Time (AOT) compilation. The AOT compiler converts templates into highly efficient JavaScript code during the build process. This eliminates the need for the compiler in the browser, reduces bundle size, and catches template errors early. It also enhances security by preventing certain client-side injection attacks.

Static Bootstrapping with AOT

With AOT compilation, the bootstrap process is static. The generated code directly instantiates the root module and component without needing a runtime compiler. This results in faster application startup and improved overall performance.

Developing Feature Modules

As applications grow, it becomes beneficial to divide them into feature modules. A feature module encapsulates a specific functionality, such as user management or product listings. By importing and exporting the necessary parts, feature modules can be developed in isolation and integrated into the main application.

Integrating Modules with the Router

The Angular Router works seamlessly with modules. When defining routes, you can reference a component directly or load an entire module. This integration allows for efficient organization of navigation logic and facilitates the implementation of lazy loading without complex configurations.

Lazy Loading via the Router

One of the most significant advantages of using NgModules is the ability to lazy load feature modules. By configuring routes to point to a module rather than a component, Angular only fetches and initializes that module’s code when the user navigates to its routed path. This strategy dramatically reduces the initial bundle size and improves load times for large applications.

Shared Modules and Their Role in Lazy Loading

To maximize the benefits of lazy loading, careful design is required for shared modules. If a shared module is imported into a lazy-loaded module, each lazy chunk gets its own copy of that shared module’s providers. This can lead to duplicated service instances. A common approach is to only re-export common components and directives (no providers) from shared modules, ensuring that they are collated into the parent bundle and not duplicated.

Summary

Angular’s NgModule system is pivotal for structuring scalable applications. It enables effective code organization, explicit dependency management, and profile-based optimization through AOT compilation and lazy loading. By mastering when and how to create root, feature, and shared modules—and understanding the interplay with the Router—you can build applications that are both maintainable and performant.

Understanding Angular Modules

An Angular module represents a deployable subset of your overall application. It is a mechanism for partitioning an application into smaller, independently loadable pieces, and for constructing reusable libraries of components that can be easily integrated into other projects.

Consider the description found in the official documentation:

Angular modules consolidate components, directives, and pipes into cohesive blocks of functionality... Modules can also add services

The core purpose here is to bundle related Angular primitives—components, directives, and pipes—into a single, coherent unit that is designed to work together.

Practical Module Examples

The reactive forms module serves as a prime illustration. Its directives are deeply interconnected, and it also includes injectable services, such as the FormBuilder, which is tightly coupled to those directives for configuring a form model.

Another example is the Angular router module. It contains a set of directives and services that are intrinsically linked, forming a consistent and functional unit.

Application-level modules are also common. For instance, an app with two distinct and unrelated sets of screens would likely benefit from being organized into two separate modules.

Anatomy of an Angular Module

Here is a typical Angular module definition, in this case, the root application module used in our examples:

Several key elements are at play:

  • the @NgModule decorator is what formally defines the module
  • the declarations array lists the components, directives, and pipes that belong to this module
  • the imports array allows us to bring in other modules
  • the providers array lists the services associated with the module, though as we will see, this should be used with caution

This declarative structure is valuable for organizing the application's architecture and documenting which pieces of functionality are interrelated.

But modules are more than just organizational tools. What does Angular actually do with all this information?

The Purpose Behind Angular Modules

An Angular module establishes a context for template compilation. When Angular parses an HTML template, it needs a list of components, directives, and pipes to compare against each HTML tag and attribute. It must determine which component applies to a tag, or which directive to an attribute. The question is: how does Angular know which elements to look for?

Angular modules provide this answer. They define the exact set of components, directives, and pipes to be considered during template parsing, all in one place.

In short, Angular modules serve these purposes:

  • they are fundamental to template parsing, whether using Just In Time or Ahead Of Time compilation
  • they act as documentation, helping to group related functionality
  • they can delineate public API from internal implementation details, as we will examine

Angular Modules vs. ES6 Modules

An Angular Module is distinct from an ES6 module. An ES6 module is a formalization of the classic JavaScript pattern for encapsulating private details within a closure and exposing only a chosen public API.

An Angular Module, on the other hand, is fundamentally a template compilation context. It also helps define a public API for a set of functionality and contributes to the application's dependency injection configuration.

Angular Modules are crucial for building fast and mobile-friendly applications. Let's now explore the different types of modules and their appropriate use cases.

The Application Root Module

Every application has exactly one root module, and each component, directive, and pipe can only belong to a single module.

Here is an example of an application root module:

Several characteristics identify a root module:

  • it is conventionally named AppModule

  • for web applications, it imports BrowserModule, which provides browser-specific renderers and installs core directives like ngIf and ngFor.

  • it uses the bootstrap property, which lists the components to serve as bootstrap entry points. This array typically contains just one element: the root component.

As an application grows, this module's arrays can become large and unwieldy. Let's look at how to keep this readable.

Improving Module Readability

A straightforward way to manage large modules is to move the lists of components, directives, and pipes into external files as constants. For example:

These constants can then be imported and integrated into the module definition using the array spread ... operator:

This approach keeps the module file itself concise and readable over time.

Module Visibility & Public API

In Angular, declaring a component in a module's declarations array does not automatically make it visible to other modules. This is because a component might be an internal implementation detail. To make it publicly available, it must also be exported.

For instance, consider a module with a single Home component. If we try to use <home></home> in our root application template, it will not render, even if the module is imported. The component is not in the module's public API.

To fix this, we simply export it:

Once exported, the Home component will be rendered correctly wherever its tag is used.

It is also possible to export a component without declaring it, but this only applies to components not used internally within the module.

Importing Non-Module Components

If you attempt to use a component directly that is not part of a module, Angular will throw an error:

Unhandled Promise rejection: Component Home is not part of any NgModule or the module has not been imported into your module.

This enforces that only components declared and exported as part of a module's public API can be used in templates.

Modules and Dependency Injection

When it comes to services and the providers property, a common assumption is that importing a module with providers will only make those services available to that module's own components and directives. Let's test this with a simple service added to a module:

If we add a service like LessonsService to HomeModule, it is indeed available in the Home component. However, importing a module does not create a new, separate dependency injection context.

The service is actually added to the root module's DI context. This means a single instance of LessonsService is available to the entire application. It can be injected into:

  • the root component
  • any component of HomeModule
  • any component of any other module

The Angular DI container is hierarchical, which means a separate context *could* be created if needed, but it is not the default behavior.

Why Not Create a Separate DI Context?

The lack of a separate DI context by default is a deliberate design choice. Modules directly imported are intended to enrich the functionality of the importing module, and the services they provide are generally meant to be application-wide singletons.

The goal is not to create a small, isolated sub-application within the main one. Instead, the default behavior supports the common use case of importing application-wide singletons. This design helps prevent:

  • errors where an injectable is not available after importing a module
  • subtle bugs caused by accidental multiple instances of an injectable

The Lazy-Loading Perspective

In the old AngularJs framework, a major problem was the non-hierarchical DI container—everything existed in a single, global bucket. This meant that lazy-loading parts of the app could lead to accidental overwrites of services with the same name. The application's behavior would then depend on the navigation order, leading to serious, hard-to-reproduce bugs.

This was a primary reason AngularJs did not support lazy-loading at the framework level, requiring libraries like ocLazyLoad.

We will see how modules solve this in Angular, but first, let's look at using the root module to bootstrap the application.

Dynamic Bootstrapping with JIT

Angular modules define the compilation context, but how is it used to start the app? One approach, which is no longer a default but is instructive, is to send the Angular compiler to the browser. The app is then compiled dynamically on the client at runtime. This is known as Just In Time (JIT) Compilation, and was used in development mode.

This method compiles all templates and bootstraps the application. The downside is a significantly larger application bundle, which is acceptable on a development machine but not for production.

Let's examine the modern production alternative.

The Ahead Of Time Compiler

A more powerful option is to use the module information for Ahead Of Time (AOT) compilation. This is now done transparently by the Angular CLI in both development and production.

The CLI uses the ngc template compiler to convert the component class, template, and styles into plain JavaScript that is universally understood by browsers. The output is view-transformation code that looks something like this:

The specifics of this generated code will change between releases. The point is not to debug it; it is generated and used automatically behind the scenes.

However, examining this code reveals how AOT works. It is somewhat surprising, but the intent is clear:

  • the constructor is extended with core injectables, such as renderers
  • the renderer is used to manually create the DOM elements and text content
  • this is essentially the internal view of an Angular renderer, surprisingly close to hand-written code

The Angular CLI handles everything—from compiling templates to producing bundles and generating the bootstrapping code. But what about lazy-loading? How does it relate to modules? We will explore feature modules first, as they are essential to understanding lazy-loading.

Feature Modules

The HomeModule we've been building is a form of feature module, designed to extend the application with new features, such as screens and services. Looking at a version of this module, there is actually an issue:

If we try to use a core Angular directive like ngStyle in the Home template, we will get an error:

Unhandled Promise rejection: Template parse errors:
Can't bind to 'ngStyle' since it isn't a known property of 'h3'.

Even though ngStyle is available in the root application module via BrowserModule (which includes CommonModule, where ngStyle is defined), it is not available in the Home component. This is because HomeModule did not import CommonModule itself.

A directive visible in the application module is not automatically visible within other modules.

Each module must declare its own set of visible dependencies. The solution is to import CommonModule into this module. This results in a typical feature module: it imports CommonModule and provides related components and services.

Feature Modules and Lazy-Loading

As an application grows, grouping functionality into feature modules may not be enough. To split it into separately loadable chunks, we can use lazy-loading. However, this introduces a problem related to module injectables. Let's see why.

Modules and the Router

To understand the lazy-loading issue, let's add the router to the application:

We've defined a /home URL that displays the Home component in the <router-outlet> tag. Furthermore:

  • we imported RouterModule for its routing directives like routerLink.
  • but it doesn't include router services like RouteSnapshot; the RouterModule definition shows no providers.
  • services are made available through the forRoot call. Let's understand its purpose for our own feature modules.

Lazy-Loading the Home Module

Let's refactor to make HomeModule lazy-loaded. This means its code is only fetched from the server when the home link is clicked, not on initial load.

First, remove all references to Home or HomeModule from the App component and main routing configuration:

Notice that App no longer imports HomeModule. Instead, the routing config uses loadChildren. This tells Angular that if the /home URL is hit, it should fetch the home.module.js file via an HTTP request.

The Structure of a Lazy-Loadable Module

Here's what HomeModule looks like after refactoring, with only a few changes:

Several things are happening here:

  • it defines its own routing configuration, which is added to the main config with respect to the /home path
  • it now uses the default export. This is crucial; without it, the router cannot know which export to use from the file because it only knows the module file's name
  • its routing config is added via a forChild call, the purpose of which we will now examine

What Changes with Lazy-Loading?

A lazy-loaded module functions like a regular feature module, but with one key difference: it receives a separate dependency injection context.

This ensures that services created within the lazy-loaded module are only accessible to components, directives, and pipes within that same module. For example, the LessonsService is in the Home DI context and is not visible to the rest of the application.

It is available for injection in the Home component, but injecting it into the App component will fail, resulting in an error:

Error: Can't resolve all parameters for App

The Rationale for a Separate DI Context

The reason for this is to prevent accidental overwriting of services between different lazy-loaded modules that may share names. It also avoids an application behaving differently based on which modules are loaded and in what order, a source of difficult-to-debug issues.

By creating a separate DI context for each lazy-loaded module, these problems are completely avoided. Now, let's discuss shared modules and how they relate to lazy-loading and the forRoot/forChild methods.

Shared Modules

As an application evolves, the need for a module to share common services arises. Now, take, for example, an AuthenticationService. You'd want to use it in the main module and also within feature modules for operations like financial transfers.

A shared module containing this service might be created like this:

This module can be imported anywhere needed. However, importing it into a lazy-loaded module like `HomeModule` creates a problem. While we expect it to work, a shared module with `providers` will cause a duplicate instance of `AuthenticationService`:

  • one instance is created at startup and injected into App
  • a second instance is created when the HomeModule is lazy-loaded

This defeats the purpose of a singleton application service and introduces a subtle, hard-to-trace bug. The solution lies in the forRoot and forChild methods.

Shared Modules and Lazy Loading

For a shared module that might be used by both root and lazy-loaded modules, the following rule is important:

a shared module cannot define services using the providers property for use by the lazy-loaded module, unless intended for its internal use only

We need a different mechanism. The goal is to:

  • create a single service instance (a true singleton) once at the root module
  • make that instance available to all child DI contexts, such as the lazy-loaded module
  • prevent the creation of a second, duplicate instance

By convention, we define a forRoot static method on the shared module:

In this pattern, the service is removed from the `providers` array. This prevents a duplicate instance if the module is imported into a lazy-loaded module.

Instead, the `forRoot` method returns the module along with its necessary providers:

When Angular sees this, it allows the module to be created with its own context, but it adds the declared services to the root DI context, rather than the lazy-loaded module context.

Note that forRoot is just a conventional name. We could name this method anything we like; the pattern is what matters.

When SharedModule is imported into a lazy-loaded module like HomeModule without using forRoot, Angular only processes the module itself, completely ignoring the providers. As a result, no duplicate AuthenticationService is created.

Let's now consolidate everything we've learned about Angular Modules and NgModule.

Summary

Angular Modules are logical units for grouping components, directives, pipes, and services. They allow us to partition application functionality, encapsulate internal details, and define a clear public API.

Modules are indispensable for enabling both ahead-of-time compilation and lazy-loading.

While very useful, be mindful of these common pitfalls:

  • avoid declaring a component, directive, or pipe in more than one module
  • non-lazy-loaded modules do not have a separate DI context; their injectables are available to the wider application
  • lazy-loaded modules get their own DI context to prevent accidental service overrides and hard-to-debug issues
  • for a shared module used by a lazy-loaded module, omit providers to prevent duplicate service instances, unless they are meant for internal module use only

For a more advanced dive into Angular Core features, the Angular Core Deep Dive course covers NgModule in greater depth.

If you are beginning with Angular, the Angular for Beginners Course is a recommended starting point:

Angular Modules and NgModule - Complete Guide — figure 1

Further Reading on Angular

If you found this guide useful, you might also want to check out some other popular articles from our blog: