Différences majeures avec Spring
La plupart des notions Nest ont un équivalent Spring, et la table de correspondances suffit pour s'y retrouver. Trois différences, en revanche, changent la façon d'écrire le code et expliquent l'essentiel des bugs d'un développeur Java qui arrive sur Node : le modèle d'exécution (une event loop au lieu d'un thread par requête), un conteneur d'injection privé des types à l'exécution, et un écosystème sans JVM où les décorateurs sont de simples fonctions.
Event loop vs thread-per-request
Le modèle Spring MVC
Spring MVC sur Tomcat attribue un thread par requête, pris dans un pool (200 threads par défaut avec Spring Boot). Un appel JDBC bloque son thread pendant que la base répond, mais les autres requêtes continuent sur les autres threads. Tant que le pool n'est pas épuisé, le coût du blocage est de la mémoire et des changements de contexte, pas de la latence pour les voisins.
Le modèle Node
Node exécute votre JavaScript sur un seul thread, qui fait tourner l'event loop. Les I/O (réseau, base de données, fichiers) sont non bloquantes : la requête est envoyée, le thread passe à autre chose, et le callback (ou la suite de la fonction async, après await) est mis en file quand le résultat arrive. Un seul thread sert ainsi des milliers de requêtes concurrentes, à condition qu'aucune ne le garde pour elle.
La contrepartie : une boucle CPU lourde ou un appel synchrone bloque toutes les requêtes, pas seulement la sienne. Le fonctionnement détaillé de la boucle (phases, microtasks et macrotasks) est expliqué au J1.
Mesure sur TaskFlow
L'exercice du J1 est conservé dans TaskFlow :
// The J1 exercise. Call /demo/block, then /demo/ping in another terminal:
// ping waits ~5 s, because the only JavaScript thread is busy looping.
// With /demo/sleep, ping answers at once: awaiting frees the thread.
@Public()
@SkipThrottle()
@Controller({ path: 'demo', version: VERSION_NEUTRAL })
export class EventLoopDemoController {
@Get('ping')
ping() {
return { pong: new Date().toISOString() };
}
@Get('block')
block() {
const end = Date.now() + 5_000;
while (Date.now() < end) {
// Synchronous CPU work: nothing else can run meanwhile.
}
return { blockedMs: 5_000 };
}
@Get('sleep')
async sleep() {
await sleep(5_000); // The thread serves other requests during the wait.
return { sleptMs: 5_000 };
}
}Lancez l'application, appelez /demo/block puis, dans un autre terminal, /demo/ping :
curl http://localhost:3000/demo/block &
curl -w '%{time_total}s\n' http://localhost:3000/demo/pingMesuré sur TaskFlow : pendant /demo/block, /demo/ping a attendu 4,7 s ; pendant /demo/sleep, il a répondu en environ 1 ms. La boucle while garde le thread ; await sleep(5_000) le rend à l'event loop, qui sert /demo/ping pendant l'attente. Le module de démonstration n'est chargé qu'en dehors de la production (ConditionalModule dans app.module.ts).
Conséquences pratiques
- Tout I/O passe par
await. Les API synchrones de Node (readFileSync,execSync…) bloquent le thread : réservez-les au démarrage ou aux scripts. - Pas de
Thread.sleepni de calcul lourd dans un handler. Attendre s'écritawait setTimeout(ms)(node:timers/promises) ; un calcul long part dans un worker ou une file (ci-dessous). - Une promesse non attendue est un bug classique. Sans
await, la fonction continue sans attendre le résultat : letry/catchqui l'entoure ne voit pas l'erreur, et la réponse HTTP peut partir avant la fin de l'écriture (piège). La règle de lintno-floating-promises, active dans TaskFlow, le signale. - La concurrence se pense en
Promise.all, pas enExecutorService. Lancer deux I/O en parallèle ne demande ni pool ni thread : il suffit de ne pas les attendre l'une après l'autre.
// Spring : deux appels en parallèle sur un pool de threads
var user = CompletableFuture.supplyAsync(() -> users.findById(id), executor);
var tasks = CompletableFuture.supplyAsync(() -> taskRepo.findByAssignee(id), executor);
CompletableFuture.allOf(user, tasks).join();// Exemple hors TaskFlow : les deux requêtes partent ensemble, un seul thread
const [user, tasks] = await Promise.all([
this.users.findById(id),
this.tasks.findBy({ assigneeId: id }),
]);Promise.all échoue dès la première promesse rejetée ; Promise.allSettled attend tous les résultats. Dans une transaction TypeORM, toutes les requêtes passent par la même connexion : Promise.all n'y apporte aucun parallélisme.
Calcul CPU : worker_threads ou file
async ne déplace pas le travail sur un autre thread, contrairement à @Async. Pour un calcul long (génération de PDF, traitement d'image, gros parsing) :
worker_threadsexécute du JavaScript sur un autre thread du même processus, avec sa propre event loop ; on communique par messages (documentation Node).- Une file de jobs (BullMQ au J9) sort le travail de la requête : le handler dépose un job et répond, un worker le traite plus tard avec retries. Un worker BullMQ qui tourne dans le même processus partage cependant son event loop : pour du calcul lourd, faites-le tourner dans un processus à part.
Le plus proche en Java : WebFlux
Le modèle Java le plus proche est WebFlux sur Netty : quelques threads d'event loop, des I/O non bloquantes, et la même règle absolue de ne jamais bloquer. La différence tient à la syntaxe : là où WebFlux enchaîne des Mono et des Flux, Node écrit du code séquentiel avec async/await. RxJS, que Nest utilise dans les intercepteurs, joue le rôle de Reactor.
Les threads virtuels de Java 21 répondent au même problème par l'autre bout : ils gardent le style bloquant d'un thread par requête, et la JVM détache le thread virtuel de son thread porteur pendant une I/O. Une boucle CPU occupe un thread porteur, mais il y en a autant que de cœurs ; dans Node, il n'y a qu'un thread JavaScript, et aucun mécanisme ne le préempte.
Conteneur d'injection
Les types disparaissent à l'exécution
Java garde à l'exécution les classes et les interfaces (et, dans les signatures, les paramètres génériques, que Spring sait exploiter) : le conteneur sait qu'un constructeur attend un ProjectsStore. TypeScript efface tous les types à la compilation : interfaces, alias type, génériques, annotations de type. Il ne reste que les classes, qui sont des valeurs JavaScript.
Pour que Nest retrouve les dépendances d'un constructeur, tsconfig.json active emitDecoratorMetadata : sur chaque classe décorée, le compilateur enregistre la liste des types des paramètres (design:paramtypes), que Nest lit au démarrage via reflect-metadata. Voici ce que produit nest build pour TasksService (extrait du JavaScript compilé dans dist/) :
// Sortie de compilation de src/tasks/tasks.service.ts (extrait)
__param(0, (0, typeorm_1.InjectRepository)(task_entity_1.Task)),
__param(3, (0, common_1.Inject)(common_module_1.CLOCK)),
__metadata("design:paramtypes", [typeorm_2.Repository,
projects_service_1.ProjectsService,
event_emitter_1.EventEmitter2, Function])Ce que Nest peut en tirer :
| Paramètre dans le source | Métadonnée émise | Résolution |
|---|---|---|
Repository<Task> | Repository (le générique a disparu) | Impossible par le type : @InjectRepository(Task) donne le token |
ProjectsService | ProjectsService | Par le type, comme en Spring |
EventEmitter2 | EventEmitter2 | Par le type |
Clock (alias de () => Date) | Function | Impossible par le type : @Inject(CLOCK) donne le token |
Une interface s'injecte par token
Une interface n'existe pas à l'exécution : pour un paramètre store: ProjectsStore, la métadonnée émise est Object, ce qui ne désigne rien. Il faut un token (une chaîne ou, mieux, un Symbol) associé à un provider useClass, useValue ou useFactory, et @Inject(TOKEN) sur le paramètre :
// Interfaces disappear at runtime, so the container cannot resolve one by
// type. A token stands in for it: @Inject(PROJECTS_STORE) store: ProjectsStore.
export const PROJECTS_STORE = Symbol('PROJECTS_STORE');
export interface ProjectsStore {
create(data: CreateProjectDto, ownerId: string): Promise<Project>;
/** memberId undefined: every project (admin view). */
findPage(query: ProjectQueryDto, memberId?: string): Promise<Page<Project>>;
findById(id: string): Promise<Project | null>;
update(id: string, changes: UpdateProjectDto): Promise<Project | null>;
softDelete(id: string): Promise<boolean>;
/** Archives the project and, with TypeORM, all its tasks in one transaction. */
archive(id: string): Promise<Project | null>;
isMember(projectId: string, userId: string): Promise<boolean>;
addMember(projectId: string, userId: string): Promise<void>;
}C'est le rôle que joue @Qualifier côté Spring, mais ici il est obligatoire dès que le type n'est pas une classe. Le détail des providers est au J3.
Le piège de design:type
Le compilateur n'émet que ce que vous écrivez. Pour une propriété dont le type est inféré, il ne connaît pas le type à émettre et écrit design:type = Object. Vérifié sur la validation d'environnement de TaskFlow (TypeScript 6.0) :
// Illustration : deux écritures de la même propriété
@IsInt() PORT = 3000; // type inféré : design:type = Object
@IsInt() PORT: number = 3000; // type écrit : design:type = NumberAvec la première, plainToInstance(..., { enableImplicitConversion: true }) laisse "8080" en chaîne et @IsInt() échoue. Avec la seconde, la conversion se fait. La même cause explique l'erreur « Nest can't resolve dependencies of … » quand un paramètre de constructeur est typé par une interface ou par un import import type, et le type qu'il faut écrire dans @Column pour une propriété string | null, émise comme Object. Détails et correction : piège des métadonnées, J7.
Déclarer et exporter : pas de component scan
Spring Boot découvre les beans en scannant les packages. Nest ne scanne rien : un provider existe parce qu'il est listé dans providers d'un module, et il n'est visible d'un autre module que s'il est dans exports et que l'autre module importe le premier.
// No component scan: a provider is only visible to modules that import this
// one, and only because it is listed in `exports`.
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}AuthModule importe UsersModule pour obtenir UsersService. Oublier l'export ou l'import donne une erreur au démarrage, pas à la compilation. @Global() rend un module visible partout (TaskFlow l'utilise pour CommonModule et son token CLOCK) ; réservez-le aux utilitaires transverses, sinon les dépendances entre modules ne se lisent plus. Voir J3, modules.
Dépendances circulaires : forwardRef, ou mieux, un autre découpage
Si ProjectsModule importe TasksModule et réciproquement, Nest ne peut pas construire le graphe. forwardRef(() => TasksModule) des deux côtés débloque la situation, mais le cycle reste dans le design. Dans TaskFlow, la dépendance ne va que dans un sens : TasksModule importe ProjectsModule pour appeler assertAccess(), et rien ne remonte. Les notifications, qui réagissent aux tâches, ne sont pas injectées dans TasksService : le service émet un événement, que NotificationsListener écoute, sans aucun import entre les deux modules.
Côté fichiers, les imports circulaires existent aussi : Task importe Project et Project importe Task. C'est pourquoi les relations TypeORM prennent une fonction (@ManyToOne(() => Project, …)) : la classe n'est lue qu'au moment où la fonction est appelée, quand les deux fichiers sont chargés. Voir le piège des dépendances circulaires.
Écosystème
npm et les fichiers de verrouillage
package.json joue le rôle du pom.xml : dépendances, dépendances de développement et scripts. package-lock.json (ou pnpm-lock.yaml avec pnpm) fige l'arbre complet des versions installées, dépendances transitives comprises. Versionnez-le toujours et installez avec npm ci, qui reproduit exactement le lockfile : c'est ce que font le Dockerfile et la CI de TaskFlow. TaskFlow fixe en plus des versions exactes dans package.json (pas de ^).
Les dépendances s'installent dans le node_modules de chaque projet, pas dans un dépôt partagé comme ~/.m2. Certaines dépendances sont des « peer dependencies » optionnelles que vous devez installer vous-même : BullMQ 6 et ioredis en sont un exemple vérifié (piège).
Pas de JVM
Il n'y a pas de runtime commun qui exécute un bytecode portable : le TypeScript est compilé en JavaScript (nest build produit dist/) et Node exécute ce JavaScript. La vérification de types n'existe qu'à la compilation (npm run typecheck) ; à l'exécution, plus rien ne contrôle les types, d'où la validation explicite des entrées (J2).
nest new (CLI 12) demande le système de modules : ESM avec Vitest par défaut, CommonJS avec Jest en alternative. TaskFlow, comme le plan, est en CommonJS (module: nodenext dans tsconfig.json) avec Jest. La différence entre modules ES et CommonJS est expliquée au J1.
Les décorateurs sont des fonctions
Une annotation Java est une donnée inerte que le framework lit par réflexion quand il en a besoin. Un décorateur TypeScript est une fonction exécutée au chargement de la classe, c'est-à-dire quand le fichier est importé. Les décorateurs Nest enregistrent des métadonnées sur la classe ou la méthode ; Nest les relit au démarrage pour construire routes, injections et guards. Écrire un décorateur revient donc à écrire une fonction :
// Metadata read by the global JwtAuthGuard: this route needs no token.
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);Nest s'appuie sur les décorateurs « expérimentaux » de TypeScript (experimentalDecorators et emitDecoratorMetadata dans tsconfig.json), pas sur les décorateurs standard ECMAScript.
Lint : oxlint dans le starter Nest 12
Le projet généré par la CLI 12 utilise oxlint au lieu d'ESLint. Sa configuration active typescript/no-floating-promises, la même règle que @typescript-eslint/no-floating-promises citée par le plan : un appel save(); sans await est signalé comme erreur (vérifié).
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"rules": {
"typescript/no-explicit-any": "off",
"typescript/no-floating-promises": "error"
},
"env": {
"node": true
}
}TaskFlow lance oxlint --type-aware src/ test/ (npm run lint). Avec l'option CommonJS + Jest, les scripts de test générés passent par node --experimental-vm-modules.