Skip to content

J1 · Fondations Node et Nest ​

Jour 1 · lundi 9 novembre · Livrable : squelette, Docker Compose, premier endpoint

Avant d'écrire du Nest, il faut changer de modèle d'exécution : un seul thread JavaScript sert toutes les requêtes, et tout ce qui attend (base de données, réseau, fichiers) passe par des promesses. Ce chapitre pose ces bases, génère le projet TaskFlow, démarre PostgreSQL avec Docker Compose et expose GET /health. À la fin, vous saurez expliquer pourquoi une boucle synchrone de 5 s bloque toute l'API, et comment l'éviter.

Objectifs ​

  • Comprendre l'event loop de Node : phases, microtasks, macrotasks, et ce qui bloque le thread.
  • Écrire du code asynchrone avec Promise et async/await sans perdre d'erreurs.
  • Distinguer modules ES et CommonJS, et savoir lequel votre projet utilise.
  • Générer un projet avec la Nest CLI et lire sa structure : main.ts, AppModule, AppController, AppService.
  • Démarrer PostgreSQL et Adminer avec Docker Compose, exposer GET /health, initialiser le dépôt git.

Spring → NestJS ​

SpringNestJS / NodeÀ retenir
SpringApplication.run(App.class, args)NestFactory.create(AppModule) puis app.listen(port)Deux étapes : construire le conteneur, puis ouvrir le port
Classe @SpringBootApplicationAppModule (@Module)Racine du graphe de modules, sans component scan
Pool de threads Tomcat (un thread par requête)Un thread JavaScript et l'event loopUn calcul synchrone bloque toutes les requêtes
CompletableFuture, MonoPromise, async/awaitawait suspend la fonction, pas le thread
Thread.sleep(5000)await sleep(5000) (node:timers/promises)L'attente libère le thread pour les autres requêtes
ExecutorService.invokeAll()Promise.all()La concurrence se pense en promesses, pas en threads
@AsyncRien d'équivalent : async ne change pas de threadUne fonction async tourne sur le même thread jusqu'à son premier await
Spring Initializrnest newGénère le projet, installe les dépendances, initialise git
Maven / Gradle, pom.xmlnpm, package.json + package-lock.jsonnpm ci installe exactement ce que fixe le lockfile
mvn spring-boot:run + devtoolsnpm run start:dev (nest start --watch)Recompile et redémarre à chaque modification
/actuator/healthGET /health (Terminus en J11)Hors versioning : pas de préfixe /v1

Comprendre ​

Event loop, microtasks et macrotasks ​

Node exécute votre code JavaScript sur un seul thread. Les entrées-sorties (sockets, requêtes SQL, fichiers) sont confiées au système d'exploitation ou à libuv, la bibliothèque C de Node. Quand une opération se termine, son callback est mis en file. L'event loop est la boucle qui prend ces callbacks un par un et les exécute sur ce thread unique.

Chaque tour de boucle traverse des phases, chacune avec sa propre file :

PhaseCe qui s'y exécute
timerscallbacks de setTimeout et setInterval arrivés à échéance
pending callbackscertains callbacks d'erreurs système reportés au tour suivant
idle, prepareusage interne de Node
pollcallbacks d'I/O (socket lisible, réponse PostgreSQL reçue) ; la boucle y attend quand il n'y a rien d'autre à faire
checkcallbacks de setImmediate
close callbacksfermetures, par exemple socket.on('close')

Ces callbacks sont des macrotasks. Après chacun d'eux, Node vide entièrement les files de microtasks : d'abord process.nextTick, puis les continuations de promesses (.then, la suite d'un await). Une microtask passe donc toujours avant le prochain timer ou la prochaine I/O.

ts
// Exemple hors TaskFlow : un fichier CommonJS lancé avec node
setTimeout(() => console.log('4. macrotask (timers)'), 0);
Promise.resolve().then(() => console.log('3. microtask (promise)'));
process.nextTick(() => console.log('2. process.nextTick'));
console.log('1. synchrone');

La sortie suit les numéros. Dans un module ES, 3 s'affiche avant 2 : l'évaluation d'un module ES a lieu elle-même dans une microtask, et la file des promesses est vidée avant celle de nextTick. Ne bâtissez pas de logique sur cet ordre fin. Retenez la règle : code synchrone, puis microtasks, puis macrotasks.

La conséquence directe : tant que du code synchrone tourne, rien d'autre ne tourne. Ni les autres requêtes HTTP, ni les timers.

ts
// Exemple hors TaskFlow
const start = Date.now();
setTimeout(() => console.log(`timer après ${Date.now() - start} ms`), 10);
const end = Date.now() + 200;
while (Date.now() < end) {} // 200 ms de calcul synchrone

Le timer demandé à 10 ms s'exécute après environ 200 ms : setTimeout donne un délai minimal, pas une échéance garantie.

Côté Spring MVC, chaque requête a son thread Tomcat : un appel bloquant n'immobilise que lui. Ici, une boucle CPU, une API *Sync (fs.readFileSync, crypto.pbkdf2Sync) ou un JSON.parse sur un corps énorme dans un handler bloque toutes les requêtes en cours. Le modèle Java le plus proche est WebFlux sur Netty : peu de threads d'event loop, qu'il ne faut jamais bloquer. Pour un vrai calcul lourd, déportez-le dans des worker_threads ou dans une queue (J9).

Pas tout à fait un seul thread

libuv garde un petit pool de threads interne (4 par défaut) pour les opérations sans API asynchrone native côté système : accès fichiers, dns.lookup, une partie de crypto, zlib. Votre code JavaScript, lui, tourne toujours sur un seul thread.

Pour la vue d'ensemble des différences de modèle d'exécution, voir Différences majeures. L'exercice du jour les rend visibles.

Promise et async/await ​

Une Promise représente un résultat futur : en attente, puis résolue (une valeur) ou rejetée (une erreur). Une fonction async renvoie toujours une Promise. await suspend la fonction jusqu'au règlement de la promesse et rend la main à l'event loop, qui sert d'autres requêtes en attendant ; la suite de la fonction reprend plus tard, sous forme de microtask. Une promesse rejetée devient une exception à l'endroit du await : try/catch fonctionne comme en Java.

Deux différences avec Java méritent d'être retenues tôt :

  • async ne crée pas de thread. Une fonction async s'exécute de façon synchrone jusqu'à son premier await. La marquer async ne la déporte nulle part, contrairement à @Async. Une boucle CPU dans une fonction async bloque autant qu'ailleurs.
  • La concurrence s'écrit avec Promise.all, pas avec un ExecutorService. Les requêtes partent ensemble et le thread reste libre pendant l'attente.
ts
// Exemple hors TaskFlow
// Séquentiel : la seconde requête ne part qu'après la première.
const user = await usersRepo.findOneBy({ id });
const tasks = await tasksRepo.findBy({ assigneeId: id });

// Concurrent : les deux requêtes partent ensemble, durée totale = la plus lente.
const [owner, assigned] = await Promise.all([
  usersRepo.findOneBy({ id }),
  tasksRepo.findBy({ assigneeId: id }),
]);

Promise.all échoue dès la première promesse rejetée ; Promise.allSettled attend tout et renvoie le statut de chacune.

Le bug classique est l'await oublié :

ts
// Exemple hors TaskFlow
async create(dto: CreateProjectDto) {
  this.repository.save(dto); // await oublié
  return { ok: true };       // la réponse part avant l'INSERT
}

La réponse part avant l'écriture, et si l'INSERT échoue, l'erreur n'arrive à personne : ni au try/catch de l'appelant, ni au filtre d'exceptions de Nest. Depuis Node 15, une promesse rejetée que rien n'attend arrête même le processus par défaut. Le projet généré par la CLI 12 le détecte au lint : il utilise oxlint, dont la configuration active typescript/no-floating-promises (la même règle que @typescript-eslint/no-floating-promises cité par le plan). Un appel save(); sans await y est signalé en erreur. TaskFlow garde cette configuration :

json
{
  "$schema": "./node_modules/oxlint/configuration_schema.json",
  "rules": {
    "typescript/no-explicit-any": "off",
    "typescript/no-floating-promises": "error"
  },
  "env": {
    "node": true
  }
}

Quand une promesse ne doit volontairement pas être attendue, écrivez-le avec void : c'est ce que fait void bootstrap(); dans main.ts. Voir aussi Pièges fréquents.

Modules ES et CommonJS ​

Node connaît deux systèmes de modules :

CommonJS (CJS)Modules ES (ESM)
Syntaxe exécutéerequire(), module.exportsimport, export
Activé pardéfaut (.js sans "type": "module", ou .cjs)"type": "module" dans package.json, ou .mjs
Chargementsynchroneasynchrone, await permis au niveau du module
Imports relatifsextension facultative (./app.module)extension obligatoire (./app.module.js)
__dirname, __filenamedisponiblesabsents (import.meta.dirname à la place)

En TypeScript, vous écrivez toujours import / export. C'est le compilateur qui produit l'un ou l'autre format. Avec "module": "nodenext" (le réglage de TaskFlow), il décide fichier par fichier d'après le package.json le plus proche : sans "type": "module", il émet des require().

TaskFlow est en CommonJS : son package.json n'a pas de champ type, et dist/main.js utilise require. Cela se voit dans le code : postgres-options.ts et data-source.ts utilisent __dirname, et le CLI TypeORM est lancé par typeorm-ts-node-commonjs (J5).

Les schematics de la CLI 12 installés avec TaskFlow (@nestjs/schematics 12.0.7) demandent le système de modules à la création du projet : ESM avec Vitest (choix par défaut) ou CommonJS avec Jest. Le plan et TaskFlow utilisent Jest (J10) : choisissez CommonJS. La variante ESM se reconnaît aux imports relatifs suffixés en .js et à await bootstrap(); au niveau du module.

Les erreurs typiques d'un mélange des deux :

  • SyntaxError: Cannot use import statement outside a module : du code ESM chargé comme CommonJS.
  • ReferenceError: require is not defined in ES module scope (ou __dirname is not defined) : du code CommonJS chargé comme ESM.
  • Un paquet publié uniquement en ESM, importé depuis du CommonJS : les versions récentes de Node, dont Node 24, acceptent require() d'un module ES sans await au niveau du module ; les plus anciennes échouaient avec ERR_REQUIRE_ESM.

Il n'y a pas d'équivalent Java direct : le classpath ne connaît qu'un format. Pensez à vérifier le champ type du package.json avant de copier un exemple trouvé en ligne.

Structure d'un projet Nest et démarrage ​

nest new en variante CommonJS génère ceci :

text
taskflow/
├── src/
│   ├── app.controller.spec.ts   test unitaire du contrôleur
│   ├── app.controller.ts
│   ├── app.module.ts
│   ├── app.service.ts
│   └── main.ts                  point d'entrée
├── test/
│   ├── app.e2e-spec.ts          test e2e (supertest)
│   └── jest-e2e.json
├── .gitattributes
├── .gitignore                   écrit par la CLI avec git init
├── .oxlintrc.json
├── .prettierrc
├── README.md
├── jest.config.ts
├── nest-cli.json
├── package.json
├── tsconfig.json
└── tsconfig.build.json

Les trois classes générées montrent le découpage de Nest. Elles ont disparu de TaskFlow, remplacées par un module par domaine :

ts
// Généré par nest new (absent de TaskFlow)
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
  • AppController est un @RestController : @Get() sans chemin répond à GET /.
  • AppService est un @Service : @Injectable() le rend gérable par le conteneur.
  • constructor(private readonly appService: AppService) déclare et assigne le champ en une ligne (une « parameter property » TypeScript, l'équivalent d'un @RequiredArgsConstructor Lombok). Nest retrouve le type AppService grâce aux métadonnées émises par le compilateur, détaillées en J3.
  • AppModule déclare explicitement ses contrôleurs et providers. Il n'y a pas de component scan : une classe absente de tout module n'existe pas pour Nest (J3).

Le point d'entrée remplace SpringApplication.run. Celui de TaskFlow :

ts
// SpringApplication.run(TaskflowApplication.class, args)
async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bufferLogs: true });
  setupApp(app);
  setupSwagger(app);
  await app.listen(app.get(ConfigService).getOrThrow<number>('PORT'));
}
// `void` marks the promise as deliberately not awaited (no-floating-promises).
void bootstrap();
  1. NestFactory.create(AppModule) part du module racine, parcourt ses imports, résout les dépendances et instancie providers et contrôleurs, au-dessus de l'adaptateur HTTP (Express 5). Comme SpringApplication.run, il construit le conteneur ; contrairement à lui, il n'ouvre pas de port.
  2. setupApp(app) et setupSwagger(app) appliquent les réglages HTTP qui ne sont pas des providers : logger, helmet, CORS, versioning (J2), documentation (J11). bufferLogs: true retient les logs de démarrage jusqu'à ce que le logger pino soit branché (J7).
  3. app.listen(port) initialise l'application si ce n'est pas fait (enregistrement des routes, hooks onModuleInit et onApplicationBootstrap, voir J7), puis ouvre le port. Le port vient de la configuration validée ; la version générée lit process.env.PORT ?? 3000.
  4. void bootstrap(); lance la promesse sans l'attendre, explicitement. En CommonJS, pas d'await au niveau du module : d'où la fonction.

Pour un script sans serveur HTTP (un batch, un worker), NestFactory.createApplicationContext(AppModule) construit le conteneur sans adaptateur HTTP, comme une application Spring Boot non web.

Au-delà du squelette, TaskFlow range le code par domaine (projects/, tasks/, users/…) avec des dossiers transverses (common/, config/, database/) : voir l'arborescence et les conventions de nommage.

Nest CLI ​

La CLI génère le projet et les briques, en les déclarant dans le bon module :

bash
npm i -g @nestjs/cli
nest new taskflow                   # projet complet : fichiers, npm install, git init
nest g module projects              # alias : nest g mo projects
nest g controller projects          # alias : nest g co projects
nest g service projects             # alias : nest g s projects
nest g resource projects            # module, contrôleur, service, DTO et entité d'un coup
nest g service projects --dry-run   # affiche les fichiers sans rien écrire
  • nest g module ajoute le nouveau module aux imports du module parent (AppModule par défaut) ; nest g controller et nest g service ajoutent la classe aux controllers / providers du module le plus proche. --skip-import désactive cette déclaration automatique.
  • --flat crée les fichiers sans sous-dossier, --no-spec sans fichier de test.
  • nest g resource pose des questions (REST ou GraphQL, points d'entrée CRUD) : utilisé en J2.
  • nest start --watch (script start:dev) recompile et redémarre à chaque sauvegarde ; nest build compile vers dist/ en appliquant les plugins déclarés dans nest-cli.json.

Les fichiers générés sont un point de départ : vous les modifiez librement ensuite, la CLI ne les régénère pas.

Toutes les commandes au même endroit

L'aide-mémoire Nest CLI liste les schematics et leurs alias (gu pour guard, itc pour interceptor…), indique lesquels sont déclarés automatiquement dans un module, et donne les commandes qui produisent le squelette de TaskFlow. Tout y est vérifié sur le CLI 12.

Cas TaskFlow ​

Générer le projet ​

bash
npm i -g @nestjs/cli
nest new taskflow --package-manager npm --no-observe
cd taskflow
npm run start:dev        # puis http://localhost:3000 → "Hello World!"
  • À la question du système de modules, choisissez CJS (CommonJS) avec Jest pour suivre le plan (modules ES et CommonJS).
  • --no-observe évite la proposition d'installer @nestjs/observe, que TaskFlow n'utilise pas.
  • La CLI installe les dépendances, lance git init et écrit un .gitignore (sauf avec --skip-git).

Ce que TaskFlow a gardé du projet généré : tsconfig.json (module: nodenext, strict, experimentalDecorators et emitDecoratorMetadata, indispensables à l'injection), la configuration oxlint et les scripts Jest, qui tournent avec node --experimental-vm-modules. AppController et AppService ont été supprimés au profit des modules de domaine.

Docker Compose : PostgreSQL et Adminer ​

Le fichier de TaskFlow reprend celui du plan, avec des profils pour les services optionnels :

yaml
# docker compose up -d                 -> PostgreSQL + Adminer for `npm run start:dev`
# docker compose --profile queue up -d -> + Redis for the BullMQ queue (J9)
# docker compose --profile app up -d   -> + the API itself, built from the Dockerfile (J11)
services:
  db:
    image: postgres:16-alpine
    container_name: taskflow-db
    environment:
      POSTGRES_USER: taskflow
      POSTGRES_PASSWORD: taskflow
      POSTGRES_DB: taskflow
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U taskflow -d taskflow"]
      interval: 5s
      timeout: 3s
      retries: 10

  # Throwaway database for e2e tests: data lives in memory (tmpfs).
  db-test:
    image: postgres:16-alpine
    container_name: taskflow-db-test
    environment:
      POSTGRES_USER: taskflow
      POSTGRES_PASSWORD: taskflow
      POSTGRES_DB: taskflow_test
    ports:
      - "5433:5432"
    tmpfs:
      - /var/lib/postgresql/data

  adminer:
    image: adminer
    ports:
      - "8081:8080"
    depends_on:
      - db

  redis:
    image: redis:7-alpine
    profiles: [queue]
    ports:
      - "6379:6379"

  app:
    build: .
    profiles: [app]
    environment:
      NODE_ENV: production
      PORT: 3000
      DB_HOST: db
      DB_PORT: 5432
      DB_USER: taskflow
      DB_PASSWORD: taskflow
      DB_NAME: taskflow
      # Empty unless exported: the app then refuses to start (validateEnv), while
      # the other services still work without it.
      JWT_SECRET: ${JWT_SECRET:-}
    ports:
      - "3000:3000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  pgdata:
ServiceRôleAccès depuis votre machine
dbPostgreSQL 16 de développement, données dans le volume pgdatalocalhost:5432
db-testBase jetable pour les tests e2e, données en mémoire (tmpfs)localhost:5433
adminerInterface web pour PostgreSQLhttp://localhost:8081
redisFile BullMQ, profil queue (J9)localhost:6379
appL'API conteneurisée, profil app (J11)localhost:3000
bash
docker compose up -d          # db, db-test et adminer (les profils restent arrêtés)
docker compose ps             # attendre que db soit "healthy"
docker compose logs -f db
docker compose down           # arrête les conteneurs, garde le volume pgdata

Dans Adminer, choisissez le système PostgreSQL et le serveur db, pas localhost : Adminer tourne dans un conteneur et joint la base par le réseau de Compose. Utilisateur, mot de passe et base : taskflow. Votre application Nest, lancée sur la machine avec npm run start:dev, joint la même base sur localhost:5432.

Les identifiants doivent correspondre à ceux du fichier d'environnement. Copiez le modèle versionné :

bash
cp .env.example .env.development
dotenv
# Copy to .env.development (npm run start:dev) and .env.test (npm run test:e2e).
# Only this example file is versioned.
NODE_ENV=development
PORT=3000
DB_HOST=localhost
DB_PORT=5432
DB_USER=taskflow
DB_PASSWORD=taskflow
DB_NAME=taskflow
JWT_SECRET=change-me
JWT_EXPIRES_IN=15m
# typeorm (PostgreSQL) or memory (the J2-J3 in-memory store)
PROJECTS_STORE=typeorm
# Set to enable the BullMQ notifications queue (docker compose --profile queue up -d)
# REDIS_URL=redis://localhost:6379
# Comma-separated origins allowed by CORS; unset = no cross-origin access
# CORS_ORIGIN=http://localhost:5173

Le chargement et la validation de ces variables sont le sujet de J7 ; les profils Compose et les variables sont résumés dans Docker Compose et .env. Spring Boot 3.1 et plus sait démarrer un fichier Compose au lancement de l'application (module spring-boot-docker-compose) ; ici, vous le démarrez vous-même.

GET /health ​

Le livrable du jour est un endpoint qui répond { status: 'ok' }, utile aux load balancers, aux orchestrateurs et au HEALTHCHECK Docker. Version minimale :

ts
// Exemple hors TaskFlow : la version J1, remplacée par Terminus en J11
import { Controller, Get, Module } from '@nestjs/common';

@Controller('health')
export class HealthController {
  @Get()
  check() {
    return { status: 'ok' };
  }
}

@Module({ controllers: [HealthController] })
export class HealthModule {}

Ajoutez HealthModule aux imports d'AppModule (ou générez-le avec nest g module health puis nest g controller health), puis :

bash
curl http://localhost:3000/health
json
{ "status": "ok" }
La version finale de TaskFlow (J11)

En J11, le contrôleur passe à @nestjs/terminus : /health/live (liveness, sans dépendance) et /health ou /health/ready (readiness : base, mémoire, disque). La réponse garde status: "ok" et ajoute le détail de chaque indicateur.

ts
// /actuator/health/liveness and /readiness. VERSION_NEUTRAL keeps them at
// /health/... instead of /v1/health/... for load balancers and Kubernetes.
@ApiTags('health')
@Public()
@SkipThrottle()
@Controller({ path: 'health', version: VERSION_NEUTRAL })
export class HealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly db: TypeOrmHealthIndicator,
    private readonly memory: MemoryHealthIndicator,
    private readonly disk: DiskHealthIndicator,
  ) {}

  /** Liveness: the process answers. No dependency, or a DB outage restarts every pod. */
  @Get('live')
  @HealthCheck()
  live() {
    return this.health.check([]);
  }

  /** Readiness: ready to receive traffic. */
  @Get(['', 'ready'])
  @HealthCheck()
  ready() {
    return this.health.check([
      () => this.db.pingCheck('database', { timeout: 1_500 }),
      () => this.memory.checkHeap('memory_heap', 512 * 1024 * 1024),
      () => this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }),
    ]);
  }
}

VERSION_NEUTRAL garde l'URL /health une fois le versioning /v1 activé, @Public() la dispense de JWT et @SkipThrottle() du rate limiting. Explications complètes : J11.

Dépôt git, README et LEARNINGS ​

nest new a déjà lancé git init et écrit un .gitignore. Celui-ci ignore .env et les fichiers .env.*.local, mais pas .env.development ni .env.test, que le plan ne versionne pas. Ajoutez .env* puis !.env.example (la négation doit venir après). TaskFlow a son propre .gitignore, dans examples/, qui ne dépend pas de celui du dépôt du site :

text
node_modules/
dist/
coverage/
*.log
*.tsbuildinfo
.env*
!.env.example

Complétez ensuite le dépôt avec :

  • README.md : comment démarrer (Compose, .env.development, npm run start:dev) et les scripts. Celui de TaskFlow sert de modèle.
  • LEARNINGS.md : le journal de fin de journée (ce qui diffère de Spring, les pièges, les questions ouvertes). TaskFlow en contient le gabarit ; la méthode est décrite dans le plan.
bash
git add .
git commit -m "feat: squelette TaskFlow, Docker Compose et /health"
git tag jour-01

Exercice : /block contre /ping ​

L'exercice du plan est conservé dans TaskFlow, sous /demo :

ts
// 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 };
  }
}

Démarrez l'application (docker compose up -d, .env.development en place, npm run start:dev), puis utilisez deux terminaux :

bash
# Terminal 1 : occupe le thread JavaScript pendant 5 s
curl http://localhost:3000/demo/block

# Terminal 2, aussitôt après : mesure le temps de réponse de /ping
curl -w '\n%{time_total} s\n' http://localhost:3000/demo/ping

Recommencez en remplaçant /demo/block par /demo/sleep. Mesures relevées sur TaskFlow :

PendantTemps de réponse de /demo/pingPourquoi
/demo/block4,7 sLa boucle while occupe le seul thread ; /ping attend la fin de la boucle (le temps restant après son lancement)
/demo/sleep~1 msawait sleep(5_000) rend la main à l'event loop, qui sert /ping immédiatement

Avec Spring MVC, /ping aurait répondu tout de suite dans les deux cas, servi par un autre thread du pool. Ici, la seule protection est de ne jamais bloquer le thread : await pour les I/O, worker_threads ou une queue pour le calcul.

Quelques détails du contrôleur, expliqués plus tard : @Public() dispense du JWT (J8), @SkipThrottle() du rate limiting (J11), et VERSION_NEUTRAL garde les URL hors du préfixe /v1. AppModule ne charge ce module qu'en dehors de la production, via ConditionalModule.registerWhen(DemoModule, (env) => env.NODE_ENV !== 'production').

Situations ​

  • Une requête lente ralentit toutes les autres → cherchez du code synchrone dans le handler : boucle, API *Sync, gros JSON.parse (event loop, exercice).
  • Vous devez attendre deux appels indépendants → await Promise.all([...]) plutôt que deux await successifs (async/await).
  • Une erreur disparaît, ou une écriture arrive après la réponse → un await manque ; lancez le lint (no-floating-promises) (async/await).
  • Cannot use import statement outside a module ou require is not defined → vérifiez le champ type du package.json et la cible du compilateur (modules).
  • Vous démarrez un nouveau service Nest → nest new en CommonJS si vous testez avec Jest (générer le projet).
  • Vous ajoutez un module, un contrôleur ou un service → nest g mo|co|s <nom>, qui le déclare dans le bon module (CLI).
  • Il vous faut un PostgreSQL local et un outil pour le consulter → docker compose up -d, Adminer sur le port 8081 avec le serveur db (Compose).
  • Un load balancer ou Docker doit savoir si l'API répond → un endpoint /health hors versioning (health, J11).

Pièges ​

Bloquer l'event loop

Tout calcul synchrone dans un handler bloque toutes les requêtes : boucle CPU, fs.readFileSync, crypto.pbkdf2Sync, sérialisation d'un très gros objet. Utilisez les versions asynchrones des API, et déportez le calcul lourd dans des worker_threads ou une queue (J9).

Promesse non attendue

this.repository.save(dto); sans await : la réponse part avant l'écriture, l'erreur éventuelle n'est vue par personne, et une promesse rejetée non gérée arrête le processus (Node 15+). Gardez la règle no-floating-promises en erreur et marquez les exceptions voulues avec void.

docker compose down -v efface la base

L'option -v supprime aussi les volumes, donc pgdata et toutes les données de développement. docker compose down seul les conserve.

  • array.forEach(async (item) => { await save(item); }) n'attend rien : forEach ignore les promesses renvoyées. Utilisez for (const item of array) { await save(item); } pour du séquentiel, ou await Promise.all(array.map((item) => save(item))) pour du concurrent.
  • setTimeout(fn, 0) ne veut pas dire « maintenant » : le callback attend la fin du code synchrone et des microtasks, et toute boucle bloquante le retarde d'autant.
  • Le .gitignore généré ignore .env mais pas .env.development ni .env.test : ajoutez .env* et !.env.example avant le premier commit qui contient un secret.
  • Dans Adminer, le serveur localhost désigne le conteneur Adminer lui-même : utilisez db.
  • nest new propose ESM avec Vitest par défaut. Si vous suivez le plan (Jest), choisissez CommonJS ; sinon, les imports relatifs devront porter l'extension .js et les exemples Jest ne s'appliqueront pas tels quels.

Exercices ​

  • [ ] npm i -g @nestjs/cli, puis nest new taskflow (CommonJS, npm).
  • [ ] Lire main.ts, app.module.ts, app.controller.ts, app.service.ts et noter l'équivalent Spring de chaque fichier (SpringApplication.run ↔ NestFactory.create).
  • [ ] Créer docker-compose.yml (PostgreSQL + Adminer), docker compose up -d, se connecter dans Adminer.
  • [ ] Exposer GET /health renvoyant { status: 'ok' } et le vérifier avec curl.
  • [ ] Compléter le dépôt git : .gitignore (avec .env* et !.env.example), README.md, LEARNINGS.md, premier commit, git tag jour-01.
  • [ ] Exercice de compréhension : un endpoint /block qui boucle 5 s de façon synchrone et un endpoint /ping. Observer que /ping est bloqué, puis remplacer la boucle par await sleep(5000) et comparer les temps.
  • [ ] Exécuter le script setTimeout / Promise / nextTick de la section event loop, en .cjs puis en .mjs, et expliquer la différence d'ordre.
  • [ ] Écrire une fonction qui lance deux appels avec Promise.all, puis la même avec deux await successifs, et comparer les durées.
  • [ ] Noter dans LEARNINGS.md ce qui change par rapport au modèle un thread par requête de Spring MVC.

Pour aller plus loin ​