Migration NgModule → Standalone
TL;DR — Migrer une codebase de
NgModulevers le mode standalone est l'opération la plus rentable qu'on puisse mener sur une app Angular en 2026 : bundle plus petit (tree-shaking par composant), lazy loading simplifié (loadComponent), DI plus claire (provideXxx()au lieu deforRoot()), code plus lisible, et alignement avec la direction officielle (standalone implicite depuis v19, doc orientée standalone en v20). Angular fournit un schematic officielng generate @angular/core:standalonequi migre en trois étapes : composants/directives/pipes en standalone, puis suppression des NgModules inutiles, puis bootstrap. Le hic est dans les détails : circular deps, libs tierces qui exposent encore des NgModules, tests qui dépendent dedeclarations, et coexistence pendant la migration. Cette fiche cartographie le processus complet, avec patterns pour les cas tordus.
🧠 Mental model — ASCII + analogie
L'idée centrale : un NgModule est un registre intermédiaire dont la seule utilité est de regrouper des composants et de partager des dépendances. Avec standalone, ce registre disparaît, remplacé par les imports directs entre composants. La question est : quelles informations stockait le NgModule, et où vont-elles ?
┌────────────────────────────────────────────┐
│ NgModule (AVANT) │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ declarations │ │ exports │ │
│ │ [A, B, C] │──▶ [A] │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ imports │ │ providers │ │
│ │ [CommonMod, │ │ [SvcX] │ │
│ │ FormsMod] │ │ │ │
│ └──────────────┘ └──────────────┘ │
└────────────────────────────────────────────┘
│
▼ éclate en …
┌────────────────────────────────────────────┐
│ STANDALONE (APRÈS) │
│ │
│ - declarations → composants standalone │
│ - exports → exportés depuis leur │
│ fichier (named export) │
│ - imports → chaque composant les │
│ importe dans son │
│ imports: [...] │
│ - providers → bootstrapApplication's │
│ providers OU │
│ provideXxx() helper │
└────────────────────────────────────────────┘L'analogie : c'est comme enlever un manager intermédiaire dans une équipe. Tant qu'il était là, il filtrait/relayait l'information ; on a besoin de réorganiser la communication directe entre les contributeurs. Au début c'est plus de connexions à dessiner ; au final c'est plus simple et plus rapide.
L'autre image utile est celle de monorepo vs micro-packages. NgModule force un certain découpage en "modules" qui sont rarement la bonne granularité de réutilisation. Standalone permet de réutiliser au niveau composant, ce qui colle bien au mental model des dev TS habitués aux imports ES.
Le vrai modèle mental : deux graphes orthogonaux
Le piège quand on vient de NgModule, c'est de croire que le @NgModule faisait une seule chose. En réalité il mélangeait deux graphes complètement indépendants que standalone sépare proprement :
| Graphe | Question qu'il répond | Avant (NgModule) | Après (standalone) |
|---|---|---|---|
| Graphe de compilation | « Quels selectors/pipes/directives ce template peut-il résoudre ? » | declarations + imports (transitivement via exports) | imports: [...] du composant, non transitif |
| Graphe de DI | « Quel Injector fournit ce token, et avec quel scope ? » | providers (root via forRoot, lazy via lazy module) | EnvironmentInjector hiérarchique : bootstrapApplication.providers → Route.providers → Component.providers |
C'est la clé de voûte de toute la migration. Tant qu'on ne distingue pas ces deux graphes, on raisonne mal sur « où mettre quoi ». Règle staff :
- Tout ce qui touche au template (composants, directives, pipes) →
importsdu composant. Ce graphe est désormais explicite et local : un composant ne voit QUE ce qu'il importe (fini leSharedModulequi ré-exporte 40 trucs et qu'on importe « au cas où »). - Tout ce qui touche à la DI (services, intercepteurs, config) → un
EnvironmentInjector, choisi selon la portée voulue (app entière, sous-arbre de route, ou composant).
La hiérarchie d'injecteurs en standalone
Platform Injector (createPlatform, partagé entre apps)
│
Root EnvironmentInjector ← bootstrapApplication({ providers })
│ = ex-forRoot / providedIn:'root'
│
┌─────────────┴──────────────┐
│ │
Route EnvironmentInjector Route EnvironmentInjector ← Route.providers
(feature 'admin') (feature 'billing') = ex-forFeature / lazy module
│ │ Créé À LA DEMANDE quand la route s'active,
│ │ DÉTRUIT quand on la quitte.
Component Injector ... ← @Component({ providers })
(instance-scoped) 1 instance = 1 injecteur enfantTrois conséquences qu'un senior doit avoir en tête :
Route.providersremplace exactement le scope d'un lazy module. Un service fourni là n'est instancié qu'une fois par activation de la branche de route, et il est détruit (avecngOnDestroysiDestroyRef/OnDestroy) quand l'utilisateur quitte la branche. C'est plus prévisible que le lazy module dont l'injecteur vivait jusqu'à la fin de l'app.providedIn: 'root'n'a pas changé et reste le défaut pour 90 % des services : tree-shakable, singleton, zéro boilerplate. La migration ne touche pas ces services — seulement ceux qui étaientproviders: [...]dans un module.- Le graphe de DI est résolu vers le haut, le graphe de compilation est résolu localement. Un composant lazy peut injecter un token fourni par sa
Route.providersparent, mais ne peut PAS utiliser un composant juste parce que son parent l'importait. Confondre les deux = le bug n°1 post-migration (« ça marchait avant »).
🛠️ Code minimal — avant / après
Avant : NgModule
// shared/shared.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from './button.component';
import { CardComponent } from './card.component';
@NgModule({
declarations: [ButtonComponent, CardComponent],
imports: [CommonModule, FormsModule],
exports: [ButtonComponent, CardComponent, FormsModule],
})
export class SharedModule {}// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { routes } from './app.routes';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, HttpClientModule, RouterModule.forRoot(routes)],
bootstrap: [AppComponent],
})
export class AppModule {}// main.ts (legacy)
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
platformBrowserDynamic().bootstrapModule(AppModule);Après : Standalone
// shared/button.component.ts
@Component({
selector: 'shared-button',
standalone: true, // implicite en v19+
imports: [CommonModule],
template: `<button><ng-content /></button>`,
})
export class ButtonComponent {}
// shared/card.component.ts
@Component({
selector: 'shared-card',
standalone: true,
imports: [CommonModule],
template: `<div class="card"><ng-content /></div>`,
})
export class CardComponent {}
// shared/index.ts : on exporte un tableau pour le pattern barrel
export const SHARED_COMPONENTS = [ButtonComponent, CardComponent] as const;// app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { SHARED_COMPONENTS } from './shared';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, ...SHARED_COMPONENTS],
template: `
<shared-card>
<shared-button>Action</shared-button>
</shared-card>
<router-outlet />
`,
})
export class AppComponent {}// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withFetch()),
],
});Plus de AppModule, plus de SharedModule, plus de platformBrowserDynamic. Le bundle final ne contient que ce qui est réellement importé depuis AppComponent.
La forme idiomatique 2026 : ApplicationConfig extrait
Mettre le tableau providers directement dans main.ts marche, mais l'idiome actuel (généré par ng new depuis v17) est d'extraire la config dans un app.config.ts. Ça garde main.ts minimal, rend la config testable et réutilisable (browser vs SSR vs tests partagent la même base), et c'est ce que les schematics suivants attendent :
// app/app.config.ts
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
// v18+ : remplace provideExperimentalZonelessChangeDetection,
// élimine zone.js du bundle (~13 KB) si toute l'app est signal-driven.
provideZonelessChangeDetection(),
provideRouter(routes, withComponentInputBinding(), withViewTransitions()),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
],
};// main.ts — réduit à 3 lignes
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err));Pourquoi un staff engineer insiste là-dessus :
- SSR / hydration : avec
@angular/ssr, on a unapp.config.server.tsqui faitmergeApplicationConfig(appConfig, serverConfig). Si la config browser est noyée dansmain.ts, on ne peut pas la réutiliser côté serveur — on duplique, on dérive, on a des bugs d'hydratation. L'extraction n'est pas cosmétique. - Provider en
main.ts= mort silencieuse en lazy. Un provider mis dansbootstrapApplication.providersest root-scoped : présent dans le chunk main. Un provider qui n'a de sens que pour une feature lazy DOIT aller dansRoute.providers, sinon il alourdit le bundle initial et casse le bénéfice perf de la migration. Voir la section lazy loading plus bas.
Note version (zoneless). En v18 l'API s'appelait
provideExperimentalZonelessChangeDetection. Elle est stabilisée enprovideZonelessChangeDetection()(v20). Le zoneless n'est pas un prérequis de la migration standalone, mais standalone en est la porte d'entrée :bootstrapApplicationest le seul chemin pour fournir un mode de change detection custom sansBrowserModule. C'est pour ça que « migrer en standalone » et « se préparer au zoneless » sont le même chantier dans la tête d'un tech lead.
🎯 Patterns courants
1. Schematic officiel — les trois passes
Le schematic Angular fait la migration en trois étapes que tu peux lancer indépendamment :
# Étape 1 : convertir composants/directives/pipes en standalone
ng generate @angular/core:standalone --mode=convert-to-standalone
# Étape 2 : supprimer les NgModules désormais inutiles
ng generate @angular/core:standalone --mode=prune-ng-modules
# Étape 3 : remplacer bootstrapModule par bootstrapApplication
ng generate @angular/core:standalone --mode=standalone-bootstrapToujours faire les trois passes dans cet ordre, commit entre chaque, et passer les tests après chacune. Le schematic est conservateur : il préfère laisser un NgModule non migré plutôt que de casser le build.
Ce que le schematic fait réellement (modèle mental staff). Le schematic ne « comprend » pas ton archi : il fait du graphe + transformation AST. Passe 1, pour chaque déclarable, il calcule l'ensemble transitif des selectors que son template référence, le remonte depuis les imports/exports des modules, et le réinjecte en imports direct du composant — d'où des imports parfois plus longs que nécessaire (il importe ce que le module exposait, pas ce que le template utilise vraiment). Passe 2 (prune-ng-modules) supprime un module uniquement s'il n'a plus de declarations, n'est ni bootstrappé ni route-loadé, et n'est plus importé nulle part. Passe 3 réécrit le bootstrap. Conséquences pratiques :
- Lance-le sur un working tree propre (lockfile pinné, zéro changement non commité) : il réécrit des dizaines de fichiers, tu veux un
git difflisible et ungit restore .possible. - Cible des dossiers plutôt que tout d'un coup sur une grosse base :
ng g @angular/core:standalone --path src/app/features/billing. Tu obtiens des PR reviewables. - Le sur-import est attendu : après la passe 1, un coup de lint
unused imports(ou la règle ESLint@angular-eslint/no-unused-imports/eslint-plugin-unused-imports) nettoie lesimportssuperflus. Ne le fais pas à la main pendant la migration. - Il ne touche ni les tests, ni les
forRoot()métier, ni le code d'init custom. Ce qui reste à la main est exactement la liste des Pitfalls plus bas.
2. Shared modules → tableau de composants exporté
// shared/index.ts
export const SHARED_COMPONENTS = [
ButtonComponent,
CardComponent,
TooltipDirective,
TruncatePipe,
] as const;// feature.component.ts
import { SHARED_COMPONENTS } from '../shared';
@Component({
standalone: true,
imports: [...SHARED_COMPONENTS],
// …
})
export class FeatureComponent {}Le tableau as const est tree-shakable : si la feature n'utilise pas TruncatePipe, le bundle final s'en débarrassera quand même (au moins partiellement, selon la config Terser).
3. forRoot() → provideXxx()
// AVANT
@NgModule({
imports: [
RouterModule.forRoot(routes),
HttpClientModule,
StoreModule.forRoot({}),
],
})
export class AppModule {}
// APRÈS
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(),
provideStore({}),
],
});Tous les libs majeurs (Router, HttpClient, NgRx, Angular Material, etc.) exposent désormais leur API en provideXxx(). Si une lib ne le fait pas, c'est qu'elle a un retard à rattraper — souvent un fork ou une mise à jour s'impose.
4. Lazy loading par composant
// AVANT — lazy loaded module
const routes: Routes = [
{ path: 'admin', loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule) },
];
// APRÈS — lazy loaded standalone component
const routes: Routes = [
{ path: 'admin', loadComponent: () => import('./admin/admin.component').then(m => m.AdminComponent) },
];
// APRÈS — lazy loaded route tree (équivalent feature module)
const routes: Routes = [
{ path: 'admin', loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes) },
];// admin/admin.routes.ts
import { Routes } from '@angular/router';
import { provideStoreFeature } from './admin.store';
export const adminRoutes: Routes = [
{
path: '',
providers: [provideStoreFeature()],
loadComponent: () => import('./admin.component').then(m => m.AdminComponent),
children: [
{ path: 'users', loadComponent: () => import('./users/users.component').then(m => m.UsersComponent) },
],
},
];C'est le pattern de référence : un fichier de routes pour la feature, qui peut déclarer ses propres providers (scoped à la route et ses enfants).
5. Coexistence NgModule + Standalone
Pendant la migration, les deux mondes cohabitent. Un composant standalone peut être utilisé dans un NgModule, et inversement :
// Composant standalone utilisé dans un NgModule legacy
@NgModule({
imports: [LegacyForm, NewStandaloneCardComponent], // OK
declarations: [LegacyForm],
})
export class LegacyModule {}// NgModule utilisé dans un composant standalone
@Component({
standalone: true,
imports: [LegacyModule, OtherStandaloneCmp],
// …
})
export class HybridComponent {}C'est ce qui rend la migration progressive possible. On peut très bien avoir 60% de standalone et 40% de NgModule pendant des mois sans douleur.
6. Feature flag pendant la migration
// On peut basculer un sous-arbre via env
if (environment.useStandaloneAdmin) {
routes.push({ path: 'admin', loadComponent: () => import('./admin-v2/admin.component').then(m => m.AdminComponent) });
} else {
routes.push({ path: 'admin', loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule) });
}Approche utile pour les très grosses apps : on migre une feature, on déploie en flag, on monitor, et on bascule pour de bon en supprimant le flag.
7. Migration d'un test legacy
// AVANT — test avec NgModule
TestBed.configureTestingModule({
declarations: [MyComponent, ChildComponent],
imports: [CommonModule, FormsModule, HttpClientTestingModule],
providers: [MyService],
});
// APRÈS — test 100% standalone
TestBed.configureTestingModule({
imports: [
MyComponent, // standalone, déjà importe FormsModule en interne
ChildComponent, // standalone aussi
],
providers: [
provideHttpClientTesting(),
MyService,
],
});Trois différences clés :
declarations→importspour les composants standalone.HttpClientTestingModule→provideHttpClientTesting().- Les
CommonModule/FormsModulene sont plus à mettre : chaque composant standalone les importe lui-même.
8. Provider scoping : forRoot()/forFeature() → provideXxx()/provideXxxFeature()
// AVANT
StoreModule.forRoot(reducers); // au niveau app
StoreModule.forFeature('admin', adminReducer); // au niveau feature lazy
// APRÈS
provideStore(reducers); // dans bootstrapApplication.providers
provideState('admin', adminReducer); // dans Route.providersLe mental model est clair : forRoot → bootstrapApplication.providers ; forFeature → Route.providers. La portée hiérarchique reste identique grâce aux EnvironmentInjectors créés par route.
🤖 Cas réel staff — migrer une feature « chat agent IA » en standalone + signals + zoneless
C'est le scénario le plus parlant pour ce learner : une feature /assistant qui consomme un agent IA (streaming de tokens, trace d'appels d'outils) est le candidat idéal à une migration standalone. Pourquoi ? Parce qu'elle réunit les trois bénéfices au même endroit :
- Lazy par route — l'UI de chat (markdown renderer, syntax highlighter, virtual scroll) pèse lourd et ne doit jamais être dans le chunk
main.loadComponentla sort intégralement du bundle initial. - DI scopée à la route — le client de streaming, l'
AbortControllercourant, le store de conversation : tout vit et meurt avec la route viaRoute.providers. Quand l'utilisateur quitte/assistant, lengOnDestroydu service annule le stream en cours (cf. plus bas). Avec un lazy module, cet injecteur survivait jusqu'à la fin de l'app — un stream pouvait fuiter. - Zoneless — un flux SSE qui pousse 50 tokens/s est le pire ennemi de zone.js (un tick de change detection par chunk réseau). En standalone,
bootstrapApplicationest le seul chemin versprovideZonelessChangeDetection(). On rend alors via signals, et on coalesce les updates aurequestAnimationFrameplutôt qu'à chaque token.
La route, scopée et lazy
// assistant/assistant.routes.ts
import { Routes } from '@angular/router';
import { ASSISTANT_STREAM, fetchStreamClient } from './stream.client';
import { ConversationStore } from './conversation.store';
export const assistantRoutes: Routes = [
{
path: '',
providers: [
// scopés à /assistant : créés à l'entrée, DÉTRUITS à la sortie
ConversationStore,
{ provide: ASSISTANT_STREAM, useFactory: fetchStreamClient },
],
loadComponent: () =>
import('./assistant.component').then((m) => m.AssistantComponent),
},
];// app.routes.ts — le reste de l'app ne paie pas le prix du chat
export const routes: Routes = [
{
path: 'assistant',
loadChildren: () => import('./assistant/assistant.routes').then((m) => m.assistantRoutes),
},
];Le client de stream, DI'd (jamais new dans un champ)
Règle staff identique côté NestJS : on n'instancie pas un client réseau dans un champ de classe. On le fournit par DI pour pouvoir le mocker en test, le scoper, et l'annuler proprement. Ici le client lit un ReadableStream via getReader() + TextDecoder (SSE-over-fetch, qui supporte POST et les headers d'auth, contrairement à EventSource).
// assistant/stream.client.ts
import { InjectionToken, inject, DestroyRef } from '@angular/core';
export interface StreamChunk {
type: 'token' | 'tool_call' | 'tool_result' | 'done' | 'error';
data: string;
}
export interface AssistantStream {
send(prompt: string, signal: AbortSignal): AsyncIterable<StreamChunk>;
}
export const ASSISTANT_STREAM = new InjectionToken<AssistantStream>('ASSISTANT_STREAM');
export function fetchStreamClient(): AssistantStream {
return {
async *send(prompt: string, signal: AbortSignal): AsyncIterable<StreamChunk> {
const res = await fetch('/api/assistant', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ prompt }),
signal, // <- propagé jusqu'au fetch : annulation réseau réelle
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// parse des frames SSE "data: {...}\n\n"
let idx: number;
while ((idx = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, idx).replace(/^data: /, '');
buffer = buffer.slice(idx + 2);
if (frame) yield JSON.parse(frame) as StreamChunk;
}
}
} finally {
reader.releaseLock(); // pas de fuite de lock même sur abort
}
},
};
}Le store : signals, buffer append-only, coalescing rAF
// assistant/conversation.store.ts
import { Injectable, signal, computed, inject, DestroyRef } from '@angular/core';
import { ASSISTANT_STREAM } from './stream.client';
// trace d'outils en union discriminée — le compilateur force l'exhaustivité
type ToolStep =
| { kind: 'pending'; name: string }
| { kind: 'running'; name: string }
| { kind: 'streaming'; name: string; partial: string }
| { kind: 'done'; name: string; result: string }
| { kind: 'error'; name: string; error: string };
interface Message {
role: 'user' | 'assistant';
text: string;
tools: ToolStep[];
}
@Injectable() // PAS providedIn:'root' — scopé via Route.providers
export class ConversationStore {
private readonly stream = inject(ASSISTANT_STREAM);
private readonly destroyRef = inject(DestroyRef);
private readonly _messages = signal<Message[]>([]);
readonly messages = this._messages.asReadonly();
readonly streaming = signal(false);
private controller: AbortController | null = null;
private pendingText = '';
private rafId = 0;
constructor() {
// garantie clé : quitter la route /assistant annule le stream en cours
this.destroyRef.onDestroy(() => this.stop());
}
async ask(prompt: string): Promise<void> {
this.stop(); // un seul stream actif à la fois
this.controller = new AbortController();
this.streaming.set(true);
this._messages.update((m) => [
...m,
{ role: 'user', text: prompt, tools: [] },
{ role: 'assistant', text: '', tools: [] },
]);
try {
for await (const chunk of this.stream.send(prompt, this.controller.signal)) {
if (chunk.type === 'token') this.bufferToken(chunk.data);
else if (chunk.type === 'done') break;
else if (chunk.type === 'error') this.failLast(chunk.data);
}
} catch (e) {
if (!(e instanceof DOMException && e.name === 'AbortError')) {
this.failLast(String(e)); // un abort n'est pas une erreur
}
} finally {
this.flush();
this.streaming.set(false);
}
}
stop(): void {
this.controller?.abort();
this.controller = null;
cancelAnimationFrame(this.rafId);
this.rafId = 0;
}
// coalescing : on n'écrit dans le signal qu'1 fois par frame, pas par token
private bufferToken(t: string): void {
this.pendingText += t;
if (this.rafId) return;
this.rafId = requestAnimationFrame(() => {
this.rafId = 0;
this.flush();
});
}
private flush(): void {
if (!this.pendingText) return;
const delta = this.pendingText;
this.pendingText = '';
this._messages.update((m) => {
const last = m[m.length - 1];
// append-only : on remplace le dernier message, pas tout le tableau muté
return [...m.slice(0, -1), { ...last, text: last.text + delta }];
});
}
private failLast(err: string): void {
this._messages.update((m) => {
const last = m[m.length - 1];
return [...m.slice(0, -1), { ...last, tools: [...last.tools, { kind: 'error', name: 'stream', error: err }] }];
});
}
}// assistant/assistant.component.ts — composant standalone, Stop câblé
import { Component, inject } from '@angular/core';
import { ConversationStore } from './conversation.store';
@Component({
selector: 'app-assistant',
// standalone implicite v19+ ; imports LOCAUX, rien de transitif
imports: [/* MarkdownPipe, ToolTraceComponent, ... */],
template: `
@for (msg of store.messages(); track $index) {
<article [class]="msg.role">
<!-- markdown rendu via pipe + DomSanitizer, jamais [innerHTML] brut -->
<div [innerHTML]="msg.text | markdown"></div>
@for (step of msg.tools; track $index) {
<span class="tool" [attr.data-kind]="step.kind">{{ step.name }}</span>
}
</article>
}
@if (store.streaming()) {
<button (click)="store.stop()">Stop</button>
}
`,
})
export class AssistantComponent {
protected readonly store = inject(ConversationStore);
}Ce que cette feature prouve sur la migration. Le Route.providers + DestroyRef.onDestroy(() => this.stop()) réalise l'annulation côté client (le AbortController.abort() propage jusqu'au fetch, qui ferme la connexion, ce qui déclenche côté serveur la fin du stream LLM). Un lazy module ne donnait pas ce cycle de vie net : son injecteur ne mourait pas à la sortie de route. C'est l'illustration la plus concrète du tableau « deux graphes » : le graphe de compilation reste local (le composant importe son markdown pipe et sa timeline d'outils, rien de plus), le graphe de DI scope le client de stream et le store à la durée de vie de la route — exactement ce qu'on veut pour une ressource réseau coûteuse.
Côté serveur (NestJS, pour boucler la boucle). Le
/api/assistantqui alimente ce stream expose du SSE depuis NestJS avec le client LLM injecté viaforRootAsync(jamaisnew Anthropic()dans un champ),claude-sonnet-4-6pour le throughput /claude-opus-4-8pour le raisonnement lourd, le SDK Anthropic en modestream: true, et un écouteurreq.on('close')qui appellecontroller.abort()côté serveur quand le client se déconnecte — le pendant exact dustop()Angular. Détails dans les fiches NestJS streaming/agentic.
🔄 Versions — Angular 16 / 17 / 18 / 19 / 20
| Version | Apport sur la migration |
|---|---|
| 16 (mai 2023) | Schematic standalone disponible mais conservateur. Coexistence opérationnelle. |
| 17 (nov 2023) | ng new génère du standalone par défaut. La doc officielle promeut activement la migration. Schematic plus robuste. |
| 18 (mai 2024) | Améliorations sur la detection automatique des dépendances. Les libs majeures (Angular Material, NgRx, RxAngular) exposent toutes leurs APIs en provideXxx(). |
| 19 (nov 2024) | standalone: true devient implicite : on peut l'omettre. Pour rester en NgModule il faut explicitement standalone: false. Schematic encore amélioré pour les SharedModule complexes. |
| 20 (mai 2025) | Doc 100% standalone-first. NgModule toujours supporté mais marqué legacy. Les guides "comment démarrer" ne mentionnent plus NgModule. |
Conséquence pratique : si tu es en 2026 sur une app Angular ≤ 16 encore en NgModule, ton plan d'action devrait être :
- Update à la dernière version (au moins 18, idéalement 20).
- Migrer avec le schematic.
- Bénéficier du bundle réduit, du lazy loading par composant, et du futur zoneless.
⚠️ Pitfalls
- Circular deps cachées — Un SharedModule qui importait un FeatureModule qui réimportait SharedModule "marchait" via NgModule (résolution paresseuse). Quand on convertit en imports directs, le cycle devient visible et casse le build. Solution : refactorer pour briser le cycle (typiquement, sortir une interface ou un token).
- Lib tierce qui n'expose que des NgModules — Tant qu'elle reste à jour pour Angular ≥ 14, on peut l'importer dans
imports: [LibModule]d'un composant standalone. Sinon, encapsuler dans un wrapper standalone. declarationspartagé entre modules — Erreur classique : un composant déclaré dans deux NgModules différents. Avec standalone, le problème disparaît (le composant n'est plus déclaré nulle part, juste importé là où on l'utilise). Mais pendant la migration, le schematic peut s'arrêter là.- Tests qui utilisent
TestBed.configureTestingModule({ declarations: [...] })— Ne marche pas pour des composants standalone. Il faut basculer enimports: [...]. Le schematic ne touche pas les tests : à faire à la main avec un find/replace global. forRoot()qui faisait plus qu'un provide — Certaines libs anciennes utilisentforRoot()pour exécuter du code d'init (ex. enregistrer des icônes). Vérifier la doc duprovideXxx()équivalent — parfois il existeprovideXxxIcons()séparé.APP_INITIALIZERnon migré — Continue de marcher en standalone, mais devient verbeux. Considérer de passer àprovideAppInitializer(() => …)(Angular 19+) plus propre.- Composant déclaré ET importé — Pendant la migration, le schematic peut laisser un composant à la fois dans
NgModule.declarationset avecstandalone: true. Erreur de build claire ("standalone et déclaré"). À corriger à la main. CUSTOM_ELEMENTS_SCHEMAau niveau composant — Possible :@Component({ standalone: true, schemas: [CUSTOM_ELEMENTS_SCHEMA], … }). Pas de blocage par rapport à NgModule.- Migration partielle d'une feature — Tant que tu migres un sous-arbre complet d'un coup, tout va bien. Migrer un composant feuille sans toucher ses parents génère du churn. Faire la feuille puis remonter ou la racine puis descendre, selon la situation.
- Oubli du bootstrap — Le schematic en mode
standalone-bootstrapdoit être lancé en dernier, sinon tu auras des incohérences entreAppModule.bootstrap = [AppComponent]etAppComponentdéjà standalone.
🧪 Testing — survivre à la migration
Test d'un composant migré en standalone
// AVANT
TestBed.configureTestingModule({
declarations: [MyComponent],
imports: [CommonModule, FormsModule],
}).compileComponents();
// APRÈS
TestBed.configureTestingModule({
imports: [MyComponent], // MyComponent est standalone, on l'importe directement
}).compileComponents();Mocker un composant enfant standalone
import { Component } from '@angular/core';
@Component({ selector: 'shared-card', standalone: true, template: '<ng-content/>' })
class MockSharedCard {}
TestBed.configureTestingModule({
imports: [ParentComponent],
}).overrideComponent(ParentComponent, {
remove: { imports: [SharedCardComponent] },
add: { imports: [MockSharedCard] },
});overrideComponent avec remove/add est l'API officielle pour mocker un import dans un standalone component pendant un test. C'est plus explicite que les hacks de MockModule.
Tester une feature lazy
import { provideRouter } from '@angular/router';
import { adminRoutes } from './admin.routes';
TestBed.configureTestingModule({
providers: [provideRouter(adminRoutes), provideHttpClientTesting()],
});Les Route.providers sont automatiquement résolus quand le routeur active la route ; pas de TestBed.configureTestingModule({ imports: [AdminModule] }) à écrire.
Smoke test du bootstrap
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
it('boot succeeds', async () => {
const ref = await bootstrapApplication(AppComponent, { providers: [/* mocks */] });
expect(ref).toBeDefined();
ref.destroy();
});À mettre dans un test de "boot" qui tourne dans la CI : si un provider obligatoire manque (NullInjectorError), il est attrapé tôt.
🎬 Cas d'usage concrets
Scénario 1 — Migration d'un SaaS RH legacy Angular 11 → 17
Un éditeur SaaS RH (paie + gestion des temps) maintient une app Angular 11 démarrée en 2019, avec 240 modules, 4 niveaux de SharedModule, et une dette technique conséquente (mix Reactive Forms + Template Forms, certains composants encore en ngOnChanges avec KeyValueDiffer). Le bundle initial fait 3,1 MB.
L'équipe planifie une migration en 4 phases sur 6 mois : (1) montée d'Angular 11 → 14 → 17, (2) migration des composants feuilles vers standalone via le schematic officiel ng generate @angular/core:standalone, (3) remplacement des loadChildren qui pointaient vers des FeatureModule ne contenant qu'un seul composant par des loadComponent, (4) suppression des modules devenus vides et passage à bootstrapApplication.
Le schematic gère 95 % du code automatiquement, mais l'équipe découvre trois pièges : (a) les composants utilisant @ContentChild avec une directive structurelle custom nécessitent une vérification manuelle, (b) certains tests utilisaient TestBed.configureTestingModule({ declarations: [...] }) qui doit devenir imports: [...], (c) les forwardRef autour de composants self-importing posent des conflits que le schematic ne résout pas.
Résultat après 6 mois : bundle initial 1,4 MB (−55 %), build time CI réduit de 22 % grâce au tree-shaking amélioré, et zéro NgModule sauf trois libs internes en cours de package.
Scénario 2 — Cabinet juridique, migration progressive feature-par-feature
Un cabinet d'avocats utilise un portail interne Angular 14 (12 features : dossiers, facturation, time-tracking, GED, calendrier, etc.). Pas de pression pour tout migrer d'un coup, mais l'équipe veut écrire les nouvelles features en standalone, et migrer les existantes au fil des évolutions fonctionnelles.
Stratégie adoptée : la règle de l'arbre. La racine reste sur AppModule. À chaque sprint, on identifie une feature « feuille » (sans dépendance vers une feature non migrée) et on la passe en standalone via le schematic. Les features sont migrées dans l'ordre inverse de leur position dans le graphe : time-tracking, puis GED, puis calendrier, etc., jusqu'à atteindre les features hubs (dossiers, qui est consommée par toutes les autres) en dernier.
Cette approche garantit qu'à tout moment le projet compile et que la cohabitation NgModule ↔ standalone reste valide. Aucun « big bang ». Le tech lead a aussi imposé que tous les services soient déjà providedIn: 'root' avant migration (ce qui est le cas depuis Angular 6 sur ce projet), ce qui simplifie la transition.
Scénario 3 — E-commerce, migration motivée par la performance mobile
Un site e-commerce d'équipement sportif (forte audience mobile) constate via Chrome User Experience Report que son LCP médian sur 4G est de 4,2 s. L'équipe de perf identifie deux causes : un bundle initial de 1,8 MB et un AppModule qui importe 15 features eagerly (au lieu de les lazy-loader).
La migration vers standalone est l'occasion de tout repenser : bootstrapApplication n'importe que l'AppComponent standalone (header + router-outlet), et toutes les routes utilisent loadComponent. Les features lourdes (@defer (on viewport)) chargent à la demande. Les guards et resolvers passent en fonctionnels pour s'inclure dans le chunk de la route plutôt que dans le bundle principal.
Le résultat est mesuré dans la production : LCP médian mobile passe à 1,8 s, INP passe sous 200 ms, et le taux de rebond mobile baisse de 9 %. Le business case justifie le temps de migration (3 mois pour une équipe de 4 dev).
🛠️ Exemple end-to-end
Use case : migration concrète d'une mini-app legacy Angular 11 vers standalone Angular 17. Avant (modules) → après (standalone), avec étape bootstrapApplication, routes fonctionnelles et tests TestBed mis à jour.
// AVANT — app.module.ts (legacy NgModule)
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { RouterModule, Routes } from '@angular/router';
import { CommonModule } from '@angular/common';
import { AppComponent } from './app.component';
import { HeaderComponent } from './header/header.component';
import { CandidateListModule } from './candidates/candidate-list.module';
const routes: Routes = [
{ path: 'candidates', loadChildren: () => import('./candidates/candidate-list.module').then((m) => m.CandidateListModule) },
];
@NgModule({
declarations: [AppComponent, HeaderComponent],
imports: [BrowserModule, CommonModule, HttpClientModule, RouterModule.forRoot(routes)],
bootstrap: [AppComponent],
})
export class AppModule {}// AVANT — candidates/candidate-list.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { CandidateListComponent } from './candidate-list.component';
@NgModule({
declarations: [CandidateListComponent],
imports: [CommonModule, RouterModule.forChild([{ path: '', component: CandidateListComponent }])],
})
export class CandidateListModule {}// APRÈS — app.component.ts (standalone)
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { HeaderComponent } from './header/header.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, HeaderComponent],
template: `
<app-header />
<main><router-outlet /></main>
`,
})
export class AppComponent {}// APRÈS — header/header.component.ts (standalone)
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-header',
standalone: true,
imports: [RouterLink],
template: `
<nav>
<a routerLink="/candidates">Candidats</a>
</nav>
`,
})
export class HeaderComponent {}// APRÈS — candidates/candidate-list.component.ts (standalone)
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { Observable } from 'rxjs';
import { CandidateService, Candidate } from './candidate.service';
@Component({
selector: 'app-candidate-list',
standalone: true,
imports: [AsyncPipe],
template: `
@if (candidates$ | async; as list) {
<ul>
@for (c of list; track c.id) {
<li>{{ c.firstName }} {{ c.lastName }}</li>
}
</ul>
}
`,
})
export class CandidateListComponent {
private readonly api = inject(CandidateService);
protected readonly candidates$: Observable<Candidate[]> = this.api.list();
}// APRÈS — app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'candidates',
loadComponent: () =>
import('./candidates/candidate-list.component').then((m) => m.CandidateListComponent),
},
];// APRÈS — main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
bootstrapApplication(AppComponent, {
providers: [provideRouter(routes), provideHttpClient()],
});// APRÈS — test mis à jour (Jest / Karma)
import { TestBed } from '@angular/core/testing';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { CandidateListComponent } from './candidate-list.component';
it('renders candidates', async () => {
await TestBed.configureTestingModule({
imports: [CandidateListComponent], // avant : declarations: [...]
providers: [provideHttpClientTesting()],
}).compileComponents();
// ... assertions
});Tout le projet a été migré : AppModule supprimé, CandidateListModule supprimé (le composant est lazy-loadé directement), RouterModule.forRoot remplacé par provideRouter, HttpClientModule par provideHttpClient. Le test bascule de declarations à imports.
🔁 Quand utiliser / éviter
| Migrer maintenant | Reporter la migration |
|---|---|
| App ≤ 100 composants, équipe disponible | Critical path avec deadline ≤ 2 semaines (la migration prend du temps de QA) |
| Tu vises zoneless / future proof | Lib tierce critique non compatible Angular ≥ 14 (rare en 2026, mais possible) |
| Bundle pèse trop (lazy loading mal optimisé) | App déjà standalone à 80% (finir avant de prétendre migrer) |
| Onboarding nouveau dev difficile à cause des NgModules | Refactor en cours d'une feature majeure : ne pas mixer deux gros chantiers |
| Schematic est concluant sur un module pilote | Tests cassants à 50% : stabiliser d'abord, migrer ensuite |
🗺️ Plan de migration concret pour une app prod
Pour une app de 50-200 composants, un plan réaliste sur 2-4 sprints :
Sprint 1 — Préparation
- Mettre à jour Angular à la version cible (idéalement 20).
- Audit des libs tierces : vérifier qu'elles supportent standalone.
- Identifier les SharedModules circulaires et planifier leur cassage.
- Activer strictTemplates et strictNullChecks si ce n'est pas déjà fait.
- Pin du
node_modules(lockfile) avant de lancer le schematic.
Sprint 2 — Migration des feuilles
- Lancer
ng generate @angular/core:standalone --mode=convert-to-standalonesur des dossiers ciblés (features feuilles). - Pour chaque PR : tests + smoke test manuel.
- Cible : 60-70% des composants migrés.
Sprint 3 — Migration des shared / shells
- Migrer les SharedModules en exports de tableaux.
- Briser les circular deps détectées.
- Lancer
--mode=prune-ng-modules. - Cible : 100% des composants migrés, NgModules réduits à AppModule + quelques shell.
Sprint 4 — Bootstrap + cleanup
- Lancer
--mode=standalone-bootstrap. - Remplacer
forRoot()parprovideXxx(). - Supprimer AppModule.
- Mettre à jour les tests qui dépendaient de NgModule.
- Mesurer la taille du bundle (gain typique : 5-15%).
Métriques à suivre
- Taille du bundle initial (chunk main).
- LCP / FCP en production.
- Temps de compilation (devrait baisser légèrement).
- Nombre de fichiers
*.module.tsrestants (tendance vers 0).
🏋️ Exercices
Progression : migrer proprement → rendre la migration production-grade → casser puis réparer. Fais-les sur une vraie petite app (un ng new legacy ou un repo jouet avec 2-3 NgModules).
1. Migration de base, sans schematic (à la main)
Objectif : convertir un SharedModule (2 composants, 1 pipe, 1 directive) + son AppModule en standalone, à la main, sans lancer ng generate.
Indice/Solution : pour chaque déclarable → @Component/@Pipe/@Directive avec standalone: true (ou implicite v19+) et déplacer dans imports ce dont son template a besoin. Le SharedModule devient un barrel export const SHARED = [...] as const. AppModule → bootstrapApplication(AppComponent, appConfig) avec provideRouter + provideHttpClient. Vérifie que tu n'as réintroduit aucun import transitif fantôme : chaque composant n'importe QUE ce que son template utilise.
2. Scoper un service à une route lazy
Objectif : prouver, test à l'appui, qu'un service fourni en Route.providers est instancié à l'entrée de la route et détruit à la sortie — contrairement à providedIn: 'root'.
Indice/Solution : crée un BillingFacade avec un compteur statique d'instances et un ngOnDestroy qui logue. Fournis-le dans Route.providers de /billing. Test : provideRouter(billingRoutes), router.navigate(['/billing']) → 1 instance ; router.navigate(['/']) → ngOnDestroy appelé. Refais avec providedIn: 'root' et observe que l'instance survit. C'est la démonstration concrète du tableau « deux graphes ».
3. Production-grade : config browser + SSR partagée
Objectif : extraire un appConfig, ajouter provideHttpClient(withFetch(), withInterceptors([authInterceptor])), un withViewTransitions(), puis dériver un app.config.server.ts via mergeApplicationConfig sans dupliquer un seul provider.
Indice/Solution : appConfig partagé ; serverConfig = { providers: [provideServerRendering()] } ; mergeApplicationConfig(appConfig, serverConfig). Piège attendu : un provider qui touche window/localStorage mis dans appConfig casse le rendu serveur → il doit être conditionné (isPlatformBrowser) ou fourni seulement côté browser. Détecte-le avec un build SSR, pas en runtime.
4. Casse-le : le cycle caché que NgModule masquait
Objectif : reproduire le crash classique. SharedModule importait FeatureModule qui réimportait SharedModule — ça « marchait ». Convertis les deux en imports directs et observe le Circular dependency au build.
Indice/Solution : casse le cycle proprement (pas avec forwardRef, qui ne fait que reporter le problème côté DI et n'aide pas le graphe de compilation). Extrais l'interface/le token partagé dans un 3ᵉ fichier neutre, ou inverse la dépendance (le shared ne doit JAMAIS dépendre d'une feature). Documente pourquoi forwardRef n'est pas la solution ici.
5. Casse-le : provider lazy qui fuit dans le bundle main
Objectif : mettre par erreur un provider lourd (ex. un client analytics de 40 KB) dans bootstrapApplication.providers alors qu'il n'est utilisé que par /admin. Mesure le chunk main avant/après, puis corrige.
Indice/Solution : ng build --stats-json + esbuild/source-map-explorer pour voir le code dans main. Déplace le provider dans Route.providers de /admin et le import() dans un loadComponent/loadChildren → le code part dans le chunk lazy. Re-mesure : le main maigrit. C'est exactement le piège qui annule le gain perf d'une migration mal faite.
6. Mocker un enfant standalone en test
Objectif : tester un ParentComponent standalone en remplaçant son enfant <shared-card> réel par un mock, sans MockModule.
Indice/Solution : TestBed.configureTestingModule({ imports: [ParentComponent] }).overrideComponent(ParentComponent, { remove: { imports: [SharedCardComponent] }, add: { imports: [MockSharedCard] } }). Vérifie que le selector du mock est identique. Bonus : compare la lisibilité avec l'ancien MockModule(SharedModule) de ng-mocks.
7. Casse-le : le stream IA qui fuite après changement de route
Objectif : migrer la feature /assistant (chat agent en streaming) d'un lazy module vers une route standalone, puis prouver que le stream est bien annulé quand l'utilisateur quitte la route — et reproduire le bug inverse.
Indice/Solution : commence en mettant ConversationStore en providedIn: 'root' (le réflexe NgModule). Lance un ask(), navigue vers /, et observe que le fetch continue de couler (network tab + un log par token) : le service singleton survit, l'AbortController n'est jamais abort(). Corrige en déplaçant le store en Route.providers + DestroyRef.onDestroy(() => this.stop()). Re-teste : le abort() ferme la connexion à la sortie de route. Bonus zoneless : enlève le coalescing rAF et fais cracher 50 tokens/s sous zone.js vs provideZonelessChangeDetection() — mesure les ticks de change detection (Angular DevTools profiler). C'est la démonstration vivante des trois bénéfices de la migration au même endroit.
🎤 En entretien
« En standalone, où mettre un provider : providedIn: 'root', bootstrapApplication.providers, Route.providers ou @Component.providers ? » Par défaut providedIn: 'root' (tree-shakable, singleton, zéro boilerplate). bootstrapApplication.providers pour la config d'app non tree-shakable (router, http, intercepteurs). Route.providers pour scoper à une feature lazy (instancié/détruit avec la route — l'équivalent exact d'un lazy module). @Component.providers pour un état par instance de composant. Le critère : la durée de vie et la portée souhaitées, pas l'habitude NgModule.
« Le schematic a tout migré, le build passe, mais le chunk main a grossi. Pourquoi ? » Quasi toujours : des providers de feature poussés dans bootstrapApplication.providers (root-scoped → chunk main) au lieu de Route.providers, et/ou des features encore importées eagerly au lieu de loadComponent/loadChildren. La migration standalone ne réduit le bundle que si on en profite pour scoper le DI et lazy-loader ; sinon on déplace le boilerplate sans gagner de poids.
« Quelle différence entre le graphe de compilation et le graphe de DI après migration, et quel bug ça provoque ? » Compilation : ce que le template peut résoudre — désormais local et non transitif (imports du composant). DI : ce qui résout un token — hiérarchique et remontant (injecteurs root/route/composant). Bug classique « ça marchait avant » : un composant qui s'appuyait sur le ré-export transitif d'un SharedModule ; en standalone il faut l'importer explicitement, alors que pour un service le scope remonte toujours. Confondre les deux fait chercher au mauvais endroit.
« Pourquoi extraire ApplicationConfig dans app.config.ts plutôt que l'inliner dans main.ts ? » Testabilité (on configure un TestBed avec la même base), et surtout SSR : app.config.server.ts fait mergeApplicationConfig(appConfig, serverConfig) pour partager la config browser sans la dupliquer. Inliner dans main.ts force la duplication browser/serveur/tests et crée des divergences d'hydratation. Ce n'est pas cosmétique, c'est une décision d'architecture.
« Tu migres une feature de chat IA en streaming. En quoi standalone change-t-il sa gestion du cycle de vie et des perfs ? » Trois leviers au même endroit. (1) loadComponent/loadChildren sort tout le poids du chat (markdown, highlight, virtual scroll) du chunk main. (2) Route.providers + DestroyRef.onDestroy(() => abortController.abort()) annule le stream à la sortie de route — un lazy module gardait son injecteur vivant jusqu'à la fin de l'app, donc le stream pouvait fuiter. (3) bootstrapApplication est le seul chemin vers provideZonelessChangeDetection() : on rend les tokens via signals avec coalescing requestAnimationFrame au lieu d'un tick zone.js par chunk SSE. Standalone n'est pas juste un refactor cosmétique ici, c'est ce qui rend la feature correcte et rapide.
🔗 Liens
- Angular Docs — Standalone migration guide
- Blog — Standalone is default in v19
- Angular Docs — Lazy loading standalone
- Angular Docs — Functional providers
- Talk : Migrating an enterprise app to standalone (ng-conf)
📌 Récap final
- Pourquoi migrer ? Bundle plus petit (tree-shaking au composant), lazy loading simplifié (
loadComponent), DI plus claire (provideXxx), alignement avec la direction officielle, préparation au zoneless. - Schematic en 3 passes :
convert-to-standalone→prune-ng-modules→standalone-bootstrap. Commit + test entre chaque. - Composants → imports directs ; shared modules → tableau de composants exporté ;
forRoot()→provideXxx()dansbootstrapApplication.providers;forFeature()→provideXxxFeature()dansRoute.providers. - Coexistence : un composant standalone peut être importé dans un NgModule et vice-versa. Migration progressive, par feature ou par sous-arbre.
- Pièges : circular deps cachées par NgModule, tests basés sur
declarations, libs anciennes qui n'exposent que des NgModules,forRoot()qui faisait plus que provide. - Tests : remplacer
declarationsparimports, utiliseroverrideComponent({ remove: { imports }, add: { imports } })pour mocker, ajouter un test de boot. - En 2026, l'objectif réaliste est d'avoir terminé la migration sur la majorité des apps Angular en prod. Les nouvelles bases ne se posent plus la question : standalone par défaut.