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.

user-profile/user-profile.model.tstypescript
export interface UserProfile {
  id: string;
  displayName: string;
  email: string;
  isVip: boolean;
}
user-profile/vip-highlight.directive.tstypescript
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';
  }
}
user-profile/user-profile-card.component.tstypescript
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;
}
user-profile/user-profile-card.component.htmlhtml
<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>
user-profile/user-profile.module.tstypescript
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.

user-profile/vip-highlight.directive.ts (standalone)typescript
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';
  }
}
user-profile/user-profile-card.component.ts (standalone)typescript
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.

ConcernNgModule ApproachStandalone Approach
Where dependencies are declaredSeparate .module.ts file, shared across every component the module declaresDirectly on each @Component/@Directive/@Pipe's own decorator
Privacy boundary (VipHighlightDirective)Enforced by omitting it from exportsEnforced by simply not importing it into a consuming component's imports array
Consuming the card elsewhereImport UserProfileModule once, get UserProfileCardComponent implicitlyImport UserProfileCardComponent directly wherever it's used — no intermediate module
Boilerplate for a 1-component featureOne module file, minimum ~10 lines, for every feature areaZero extra files — the component is already the unit of reuse
Circular dependency riskHigher — modules can accidentally import each otherLower — 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.

legacy-shell.module.ts (importing a standalone component into an NgModule)typescript
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

  1. Q1. In the NgModule version, why does VipHighlightDirective remain unusable outside UserProfileModule even though it's declared there?

  2. Q2. How is a standalone component added into a legacy NgModule that hasn't been migrated yet?