Nearly every Angular codebase written before 2023 uses @NgModule, and a huge share of production enterprise Angular still does today — migrating a mature app is a deliberate, incremental decision, not a mandatory upgrade. Understanding NgModules is therefore not optional archaeology; it is how you read, maintain, and incrementally modernize the Angular code that already exists in the wild.
The NgModule Approach
An @NgModule is a class decorated with metadata that groups a set of components, directives, and pipes into a single compilation unit, and declares which external NgModules that unit depends on. Below is a self-contained UserProfileModule feature — a card component that displays a user and an internal-only directive that highlights VIP users — built the classic way.
export interface UserProfile {
id: string;
displayName: string;
email: string;
isVip: boolean;
}import { Directive, ElementRef, Input, OnChanges, inject } from '@angular/core';
@Directive({
selector: '[appVipHighlight]',
})
export class VipHighlightDirective implements OnChanges {
@Input({ required: true }) appVipHighlight!: boolean;
private readonly host = inject(ElementRef<HTMLElement>);
ngOnChanges(): void {
this.host.nativeElement.style.borderLeft = this.appVipHighlight
? '4px solid #d4af37'
: 'none';
}
}import { Component, Input } from '@angular/core';
import type { UserProfile } from './user-profile.model';
@Component({
selector: 'app-user-profile-card',
templateUrl: './user-profile-card.component.html',
styleUrl: './user-profile-card.component.css',
})
export class UserProfileCardComponent {
@Input({ required: true }) profile!: UserProfile;
}<article class="profile-card" [appVipHighlight]="profile.isVip">
<h3>{{ profile.displayName }}</h3>
<p>{{ profile.email }}</p>
<span *ngIf="profile.isVip" class="vip-badge">VIP</span>
</article>import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UserProfileCardComponent } from './user-profile-card.component';
import { VipHighlightDirective } from './vip-highlight.directive';
@NgModule({
// declarations: components/directives/pipes that BELONG to this module —
// each class may be declared in exactly one NgModule across the whole app.
declarations: [UserProfileCardComponent, VipHighlightDirective],
// imports: other NgModules whose EXPORTED declarables this module's
// templates need. CommonModule supplies *ngIf, *ngFor, and the async pipe.
imports: [CommonModule],
// exports: the subset of this module's declarations that OTHER modules
// are allowed to use once they import UserProfileModule. VipHighlightDirective
// is intentionally omitted — it stays private to this feature.
exports: [UserProfileCardComponent],
// providers: services scoped to this module's injector (rare in modern
// Angular — providedIn: 'root' on the @Injectable is almost always preferred).
providers: [],
})
export class UserProfileModule {}What Each NgModule Metadata Property Actually Controls
- declarations — the ownership list; a class can appear in exactly one module's declarations across the entire application, or the compiler throws a duplicate-declaration error
- imports — pulls in another module's exported declarables into this module's template compilation scope; it does NOT re-export them automatically
- exports — the public API surface of this module; anything declared-but-not-exported (like VipHighlightDirective here) is a private implementation detail invisible to consuming modules
- providers — historically used for module-scoped services; in modern Angular this is largely superseded by
@Injectable({ providedIn: 'root' }), covered in Module 4
The Standalone Shift: Rewriting the Same Feature
Standalone components (standalone: true, the default since Angular 17 when using ng generate) eliminate the module file entirely. Each component declares its own dependencies directly in its @Component decorator's imports array — collapsing the ES-module-level dependency graph and the Angular-level template compilation scope into a single list, as introduced conceptually in Module 1.
import { Directive, ElementRef, Input, OnChanges, inject } from '@angular/core';
@Directive({
selector: '[appVipHighlight]',
standalone: true,
})
export class VipHighlightDirective implements OnChanges {
@Input({ required: true }) appVipHighlight!: boolean;
private readonly host = inject(ElementRef<HTMLElement>);
ngOnChanges(): void {
this.host.nativeElement.style.borderLeft = this.appVipHighlight
? '4px solid #d4af37'
: 'none';
}
}import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { UserProfile } from './user-profile.model';
import { VipHighlightDirective } from './vip-highlight.directive';
@Component({
selector: 'app-user-profile-card',
standalone: true,
// imports here replaces BOTH the old module's `imports` (for CommonModule)
// and its `declarations` (for VipHighlightDirective) in a single array.
imports: [CommonModule, VipHighlightDirective],
templateUrl: './user-profile-card.component.html',
styleUrl: './user-profile-card.component.css',
})
export class UserProfileCardComponent {
@Input({ required: true }) profile!: UserProfile;
}The template (user-profile-card.component.html) does not change at all — binding syntax is identical between the two architectures. That is a deliberate design constraint from the Angular team: the standalone migration is a file-organization change, not a template-syntax or binding-semantics change.
| Concern | NgModule Approach | Standalone Approach |
|---|---|---|
| Where dependencies are declared | Separate .module.ts file, shared across every component the module declares | Directly on each @Component/@Directive/@Pipe's own decorator |
| Privacy boundary (VipHighlightDirective) | Enforced by omitting it from exports | Enforced by simply not importing it into a consuming component's imports array |
| Consuming the card elsewhere | Import UserProfileModule once, get UserProfileCardComponent implicitly | Import UserProfileCardComponent directly wherever it's used — no intermediate module |
| Boilerplate for a 1-component feature | One module file, minimum ~10 lines, for every feature area | Zero extra files — the component is already the unit of reuse |
| Circular dependency risk | Higher — modules can accidentally import each other | Lower — components depend directly on components, a flatter graph |
Interop: You Can Mix Both in One Application
Standalone components can be declared inside an NgModule's imports array (not declarations — standalone components are never declared), and an NgModule-based component can be used inside a standalone component's imports array as long as it belongs to an exported NgModule. This bidirectional interop is what makes incremental migration of a large legacy app realistic instead of an all-or-nothing rewrite.
import { NgModule } from '@angular/core';
import { UserProfileCardComponent } from './user-profile/user-profile-card.component';
@NgModule({
declarations: [/* legacy components still declared here */],
// A standalone component goes in `imports`, exactly like an NgModule would.
imports: [UserProfileCardComponent],
exports: [UserProfileCardComponent],
})
export class LegacyShellModule {}Module 2 Checkpoint
Q1. In the NgModule version, why does VipHighlightDirective remain unusable outside UserProfileModule even though it's declared there?
Q2. How is a standalone component added into a legacy NgModule that hasn't been migrated yet?