J7 · Configuration et logging
Spring Boot fournit dès le départ application.yml, les profils, @ConfigurationProperties et Logback. En Nest, la configuration est un module comme un autre (@nestjs/config) et le logging se branche sur l'application. Aujourd'hui, TaskFlow choisit son fichier .env selon NODE_ENV, refuse de démarrer si une variable manque ou est mal formée, expose une configuration typée par domaine, écrit des logs JSON qui portent le correlationId et s'arrête proprement sur SIGTERM.
Objectifs
- Charger la configuration avec
ConfigModule.forRoot({ isGlobal, validate })et savoir d'où vient chaque valeur. - Regrouper la configuration par domaine avec
registerAset l'injecter de façon typée. - Valider les variables d'environnement au démarrage (
class-validatordans TaskFlow, Joi en alternative). - Remplacer les profils Spring par
NODE_ENVet des fichiers.env.*. - Produire des logs JSON structurés avec
nestjs-pino, et comprendre leLoggerde Nest. - Utiliser les hooks de cycle de vie (
OnModuleInit,OnApplicationShutdown) etenableShutdownHooks().
Spring → NestJS
| Spring | NestJS | À retenir |
|---|---|---|
application.yml, application-{profil}.yml | .env et .env.${NODE_ENV} chargés par ConfigModule.forRoot() | Fichiers plats CLÉ=valeur, pas de hiérarchie ni de fusion YAML |
spring.profiles.active=dev | NODE_ENV=development + .env.development | Un seul « profil » à la fois, trois valeurs admises dans TaskFlow |
@Value("${jwt.secret}") | config.get('JWT_SECRET') | Valeur validée si un schéma est déclaré, sinon chaîne brute |
@ConfigurationProperties(prefix = "database") | registerAs('database', () => ({ ... })) + ConfigType | Le mapping est écrit à la main, sans binding automatique |
@Validated sur la classe de propriétés | validate: validateEnv (class-validator) ou validationSchema | Échec au démarrage, pas à la première lecture |
@Profile("dev"), @ConditionalOnProperty | ConditionalModule.registerWhen(), provider useFactory | Choix explicite dans le module |
| SLF4J + Logback, MDC | Logger de Nest + nestjs-pino, AsyncLocalStorage | Pas de ThreadLocal : le contexte suit la chaîne asynchrone |
@PostConstruct | onModuleInit() (OnModuleInit) | Peut être async : Nest attend la Promise |
@PreDestroy | onModuleDestroy() (OnModuleDestroy) | Appelé seulement si l'arrêt passe par Nest |
| Hook d'arrêt JVM, actif par défaut dans Spring Boot | app.enableShutdownHooks() | À activer explicitement |
Comprendre
ConfigModule.forRoot
// application.yml + spring.profiles.active: .env.development, .env.test...
// picked by NODE_ENV, then checked by validateEnv before anything starts.
ConfigModule.forRoot({
isGlobal: true,
cache: true,
envFilePath: [`.env.${process.env.NODE_ENV ?? 'development'}`, '.env'],
load: [databaseConfig, jwtConfig],
validate: validateEnv,
}),Au démarrage, forRoot() fait, dans l'ordre :
- Il lit les fichiers de
envFilePath. Si une variable figure dans plusieurs fichiers, le premier gagne :.env.developmentl'emporte sur.env. Un fichier absent est ignoré. - Il fusionne le résultat avec
process.env. Une variable déjà définie dans l'environnement réel (Docker, CI, shell) l'emporte sur les fichiers, sauf avec l'optionoverride: true. - Il passe l'objet fusionné à
validate(). Si la fonction lève une erreur, l'application ne démarre pas. - Il recopie les valeurs validées dans
process.envpour les clés qui n'y sont pas encore : les valeurs par défaut du schéma deviennent visibles partout. - Il enregistre
ConfigServiceet les namespaces listés dansload.
isGlobal: true rend ConfigService injectable dans tous les modules sans réimporter ConfigModule (comme un module @Global, voir J3). cache: true mémorise les lectures. Dans @nestjs/config 12, forRoot() renvoie une Promise : rien à changer, Nest l'attend dans imports.
ConfigService.get(clé) cherche dans cet ordre : les namespaces chargés (config.get('database.host')), puis les valeurs validées (config.get('PORT') renvoie le nombre 8080), puis process.env (une chaîne). getOrThrow() lève une erreur si la clé est absente. Le paramètre de type de getOrThrow<number>('PORT') dans main.ts ne convertit rien : c'est la validation qui a produit un nombre.
registerAs et configuration typée
// One namespace per concern, injected with @Inject(databaseConfig.KEY).
// Values come from process.env after validateEnv has checked them.
export const databaseConfig = registerAs('database', () => ({
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? 5432),
username: process.env.DB_USER ?? 'taskflow',
password: process.env.DB_PASSWORD ?? 'taskflow',
database: process.env.DB_NAME ?? 'taskflow',
}));
export type DatabaseConfig = ConfigType<typeof databaseConfig>;
export const jwtConfig = registerAs('jwt', () => ({
secret: process.env.JWT_SECRET ?? '',
expiresIn: process.env.JWT_EXPIRES_IN ?? '15m',
}));
export type JwtConfig = ConfigType<typeof jwtConfig>;registerAs('database', factory) crée un namespace. La factory est exécutée comme un provider, une fois les fichiers chargés et validés. Elle produit un objet typé, et deux choses en découlent :
databaseConfig.KEYest le token d'injection :@Inject(jwtConfig.KEY) config: JwtConfigdans un service, ouinject: [databaseConfig.KEY]dans unforRootAsync()(modules dynamiques).ConfigType<typeof databaseConfig>déduit le type du retour de la factory : pas d'interface à maintenir en double.
Côté Spring, c'est un record annoté @ConfigurationProperties(prefix = "database"). L'analogie s'arrête au binding : Spring associe database.port au champ port et convertit le type ; ici, vous écrivez Number(process.env.DB_PORT ?? 5432) vous-même. Une faute de frappe dans un nom de variable n'est pas détectée par la factory, c'est le rôle du schéma de validation.
Les valeurs par défaut de la factory (?? 'localhost') servent surtout à la CLI TypeORM et au seed : data-source.ts appelle databaseConfig() directement, sans passer par validateEnv (migrations).
Validation du schéma d'environnement
TaskFlow décrit chaque variable dans une classe class-validator, convertie puis validée au démarrage :
// Every variable the app reads, with its type and default. Like a
// @ConfigurationProperties class validated with @Validated at startup.
// Write the types out (`PORT: number = 3000`): enableImplicitConversion reads
// the emitted design:type metadata, and an inferred type emits `Object`, so
// "8080" would stay a string.
class EnvironmentVariables {
@IsEnum(Environment)
NODE_ENV: Environment = Environment.Development;
@IsInt()
@Min(1)
@Max(65535)
PORT: number = 3000;
@IsString()
DB_HOST: string = 'localhost';
@IsInt()
DB_PORT: number = 5432;
@IsString()
DB_USER: string;
@IsString()
DB_PASSWORD: string;
@IsString()
DB_NAME: string;
@IsString()
@MinLength(8)
JWT_SECRET: string;
@Matches(/^\d+[smhd]$/)
JWT_EXPIRES_IN: string = '15m';
@IsIn(['typeorm', 'memory'])
PROJECTS_STORE: 'typeorm' | 'memory' = 'typeorm';
@IsOptional()
@IsString()
REDIS_URL?: string;
}
export function validateEnv(config: Record<string, unknown>) {
const validated = plainToInstance(EnvironmentVariables, config, {
enableImplicitConversion: true,
});
const errors = validateSync(validated);
if (errors.length > 0) {
// Fail fast: the app refuses to start with a wrong configuration.
throw new Error(`Invalid environment:\n${errors.map(String).join('\n')}`);
}
return validated;
}plainToInstance(..., { enableImplicitConversion: true }) convertit chaque chaîne vers le type de la propriété, puis validateSync() applique les décorateurs. Avec JWT_SECRET absent, le démarrage s'arrête sur :
Error: Invalid environment:
An instance of EnvironmentVariables has failed the validation:
- property JWT_SECRET has failed the following constraints: minLength, isStringÉcrivez les types : PORT: number = 3000
La conversion implicite lit la métadonnée design:type que TypeScript émet pour chaque propriété décorée (emitDecoratorMetadata). Cette métadonnée vient de l'annotation de type. Avec PORT = 3000, le type est inféré, pas annoté : TypeScript émet Object au lieu de Number, class-transformer ne convertit rien et "8080" reste une chaîne. @IsInt() échoue alors, mais seulement quand la variable est définie (fichier .env ou environnement) : sans elle, la valeur par défaut 3000 est déjà un nombre et tout semble fonctionner. Même cause que « Nest can't resolve dependencies » avec un paramètre de constructeur typé par une interface (types et métadonnées).
Le test env.validation.spec.ts vérifie que PORT: '8080' devient 8080, que les valeurs par défaut sont posées, et qu'un JWT_SECRET absent, JWT_EXPIRES_IN: 'soon' ou NODE_ENV: 'staging' font échouer la validation. Comme il ne démarre pas Nest, il importe lui-même reflect-metadata (tests sans Nest).
Alternative : Joi. Le plan propose Joi ou class-validator. Avec @nestjs/config 12, l'option validationSchema attend un schéma compatible Standard Schema : Zod, ArkType, ou Joi 18 (la version avec laquelle @nestjs/config 12 est testé). Pour Joi, le module garde par défaut allowUnknown: true et abortEarly: false, pour ignorer les autres variables de process.env et signaler toutes les erreurs à la fois.
// Exemple hors TaskFlow : le même contrôle avec Joi
import * as Joi from 'joi';
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'),
PORT: Joi.number().integer().min(1).max(65535).default(3000),
JWT_SECRET: Joi.string().min(8).required(),
}),
});Joi convertit "8080" en nombre sans dépendre des métadonnées TypeScript. En échange, vous ajoutez une dépendance et une seconde façon de décrire des types, alors que TaskFlow utilise déjà class-validator pour ses DTO.
Profils : NODE_ENV et fichiers .env.*
La liste envFilePath de TaskFlow (d'abord .env.<NODE_ENV>, puis .env) remplace spring.profiles.active. Qui fixe NODE_ENV ?
| Contexte | NODE_ENV | Fichier chargé |
|---|---|---|
npm run start:dev | non défini → development | .env.development |
npm run test:e2e | Jest le fixe à test s'il n'est pas défini | .env.test |
Image Docker (Dockerfile, service app du Compose) | production | aucun : .env* est exclu par .dockerignore, les variables viennent de l'environnement |
Le schéma n'accepte que development, test et production. Express et de nombreuses bibliothèques changent de comportement avec NODE_ENV=production ; un environnement de recette se lance donc avec NODE_ENV=production et ses propres variables (base, secrets), pas avec NODE_ENV=staging.
Là où Spring active des beans avec @Profile, TaskFlow décide dans les modules, de façon explicite :
ConditionalModule.registerWhen(DemoModule, (env) => env.NODE_ENV !== 'production'): les routes/demo/*n'existent pas en production (piège de la forme chaîne).PROJECTS_STOREetREDIS_URLchoisissent une implémentation ou activent une file : une variable par fonctionnalité, plutôt que desif (NODE_ENV === ...)dispersés (choix du store).- Le niveau de log et le format (JSON ou lisible) dépendent de
NODE_ENV(cas pino).
Pour Docker Compose et les variables du service app, voir Docker et .env.
Logging : Logger Nest et nestjs-pino
Dans le code, TaskFlow écrit ses logs avec la classe Logger de @nestjs/common : private readonly logger = new Logger(TasksScheduler.name), puis log, warn, error, debug, verbose ou fatal. Le nom passé au constructeur devient le contexte de la ligne. L'implémentation par défaut (ConsoleLogger) écrit du texte coloré ; depuis Nest 11, new ConsoleLogger({ json: true }) écrit du JSON, mais sans contexte de requête.
app.useLogger(...) remplace l'implémentation derrière tous les Logger, y compris les logs de démarrage de Nest. NestFactory.create(AppModule, { bufferLogs: true }) garde ces premiers logs en mémoire jusqu'à cet appel, pour qu'ils sortent eux aussi au bon format.
nestjs-pino apporte deux choses :
- pino, un logger JSON rapide : une ligne JSON par événement, avec un niveau numérique (
30info,40warn,50error) facile à filtrer dans un agrégateur de logs. - pino-http, enregistré comme middleware : pour chaque requête, il crée un logger enfant qui porte l'objet
req(identifiant, méthode, URL, en-têtes). Ce logger est rangé dans unAsyncLocalStorage. Quand un service appellethis.logger.log(...)pendant la requête, nestjs-pino retrouve ce logger enfant : la ligne portereq.idsans que vous passiez quoi que ce soit.
C'est le rôle du MDC de Logback. L'analogie s'arrête au mécanisme : le MDC est un ThreadLocal, inutilisable quand un seul thread sert toutes les requêtes. AsyncLocalStorage suit la chaîne asynchrone (await, callbacks) de chaque requête (event loop). Pour des champs structurés ({ projectId }), nestjs-pino propose aussi d'injecter un PinoLogger avec @InjectPinoLogger().
Cycle de vie
Nest appelle des méthodes optionnelles sur les providers, contrôleurs et modules :
| Moment | Hook | Équivalent Spring |
|---|---|---|
| Dépendances du module résolues | onModuleInit() | @PostConstruct |
| Tous les modules initialisés | onApplicationBootstrap() | ContextRefreshedEvent |
| Début de l'arrêt | onModuleDestroy() | @PreDestroy |
| Avant la fermeture du serveur HTTP | beforeApplicationShutdown(signal) | — |
| Serveur HTTP fermé | onApplicationShutdown(signal) | — |
Les hooks peuvent être async : Nest attend la Promise avant de passer à la suite. À l'arrêt, Nest appelle onModuleDestroy, puis beforeApplicationShutdown, ferme le serveur HTTP, puis appelle onApplicationShutdown. Les providers à portée requête ne reçoivent pas ces hooks.
À l'arrêt, ces hooks ne sont appelés que si l'arrêt passe par Nest : app.close() (ce que font les tests e2e dans afterAll), ou un signal (SIGTERM, SIGINT…) si app.enableShutdownHooks() a été appelé. Spring Boot enregistre un hook d'arrêt JVM par défaut ; Nest ne le fait pas, car chaque application ajoute ses écouteurs de signaux au processus (plusieurs applications dans un même processus, comme des tests Jest en parallèle, en accumuleraient). TaskFlow l'active dans setupApp() (cas pino). Sans lui, un SIGTERM n'exécute aucun hook et le processus s'arrête sans fermer le pool PostgreSQL, que @nestjs/typeorm ferme dans son propre onApplicationShutdown().
TaskFlow ne définit pas de hook dans ses propres classes. Un exemple d'usage :
// Exemple hors TaskFlow : préparer un état au démarrage, le libérer à l'arrêt
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
@Injectable()
export class RatesCache implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(RatesCache.name);
private timer?: NodeJS.Timeout;
async onModuleInit() {
await this.refresh(); // l'application ne démarre qu'une fois le cache rempli
this.timer = setInterval(() => void this.refresh(), 60_000);
}
onModuleDestroy() {
clearInterval(this.timer); // sinon le timer survit à app.close() (tests, scripts)
}
private async refresh() {
try {
// appel HTTP, mise à jour d'une Map...
} catch (error) {
this.logger.warn(`Refresh failed: ${String(error)}`);
}
}
}Pour une tâche périodique réelle, préférez @Interval() de @nestjs/schedule (J9).
Cas TaskFlow
Fichiers .env par environnement
Seul .env.example est versionné. Il documente chaque variable :
# 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:5173Les autres fichiers se créent localement à partir de lui :
cp .env.example .env.development # npm run start:dev
cp .env.example .env.test # npm run test:e2e, puis adapterPour les tests e2e, .env.test pointe vers le service db-test du Compose (port 5433, base taskflow_test) :
# .env.test : local, non versionné
NODE_ENV=test
PORT=3000
DB_HOST=localhost
DB_PORT=5433
DB_USER=taskflow
DB_PASSWORD=taskflow
DB_NAME=taskflow_test
JWT_SECRET=test-secret-not-for-production
JWT_EXPIRES_IN=15m
PROJECTS_STORE=typeormLe .gitignore de TaskFlow (examples/.gitignore) exclut tous les fichiers .env* sauf l'exemple :
node_modules/
dist/
coverage/
*.log
*.tsbuildinfo
.env*
!.env.examplegit check-ignore -v .env.test indique la règle qui exclut le fichier.
Configuration typée : base, JWT, port
Chaque besoin lit sa configuration par le chemin le plus direct :
- Base de données :
TypeOrmModule.forRootAsync()reçoit le namespacedatabasedéjà typé.
@Module({
imports: [
TypeOrmModule.forRootAsync({
inject: [databaseConfig.KEY],
useFactory: (db: DatabaseConfig) => ({
...postgresOptions(db),
// Picks up every entity registered with TypeOrmModule.forFeature().
autoLoadEntities: true,
}),
}),
],
})
export class DatabaseModule {}- JWT :
JwtModule.registerAsync(),AuthServiceetJwtStrategyinjectentjwtConfig.KEY(J8).JWT_EXPIRES_INest validé par@Matches(/^\d+[smhd]$/)avant d'arriver à@nestjs/jwt. - Port : il ne justifie pas un namespace.
main.tslit la valeur validée :
// 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();Les services et contrôleurs ne lisent jamais process.env. Les lectures directes se limitent au choix du fichier .env (app.module.ts, data-source.ts), aux factories de configuration.ts (exécutées après la validation), au mot de passe du seed et à CORS_ORIGIN dans setupApp(). Ces deux dernières variables, optionnelles, ne figurent pas dans le schéma : elles ne sont donc pas validées.
Logs JSON avec correlationId (nestjs-pino)
LoggerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
pinoHttp: {
level: { production: 'info', test: 'warn' }[config.get<string>('NODE_ENV') ?? ''] ?? 'debug',
// Same id as CorrelationIdMiddleware: every log line of a request carries it.
genReqId: (req, res) => ensureCorrelationId(req, res),
redact: ['req.headers.authorization', 'req.headers.cookie'],
autoLogging: false, // LoggingInterceptor already logs each request
transport:
config.get('NODE_ENV') === 'development'
? { target: 'pino-pretty', options: { singleLine: true } }
: undefined, // JSON lines in test and production
},
}),
}),genReqIdréutiliseensureCorrelationId(), la même fonction que leCorrelationIdMiddlewarede J6 :req.iddans les logs,x-correlation-iddans la réponse etcorrelationIddans les erreurs ont la même valeur.redactremplace l'en-têteAuthorizationet les cookies par"[Redacted]": un token JWT dans les logs permettrait de rejouer la session.autoLogging: false: pino-http ne journalise pas la fin de chaque requête, puisqueLoggingInterceptorle fait déjà (J6). Conséquence : les requêtes rejetées par un guard ne produisent pas de ligneHTTP.- Niveau
infoen production,warnen test (sorties de tests lisibles),debugsinon. pino-pretty(une devDependency) rend les lignes lisibles en développement uniquement. En test et en production, la sortie reste du JSON, une ligne par événement.
setupApp(), appelé par main.ts et par les tests e2e, branche ce logger et active les hooks d'arrêt :
// HTTP-level settings that are not providers. main.ts and the e2e tests both
// call it, so the tests exercise the same application as production.
export function setupApp(app: INestApplication): void {
app.useLogger(app.get(Logger));
app.use(helmet());
app.enableCors({ origin: process.env.CORS_ORIGIN?.split(',') ?? false });
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); // /v1/...
app.enableShutdownHooks(); // SIGTERM -> onModuleDestroy / onApplicationShutdown
}Une requête GET /v1/projects envoyée avec x-correlation-id: demo-42 produit une ligne de ce type (JSON reformaté ; pid, hostname, remoteAddress et les autres en-têtes retirés) :
{
"level": 30,
"time": 1793095173677,
"req": {
"id": "demo-42",
"method": "GET",
"url": "/v1/projects",
"headers": { "x-correlation-id": "demo-42", "authorization": "[Redacted]" }
},
"context": "HTTP",
"msg": "GET /v1/projects 200 0ms"
}Pour suivre une requête signalée par un client, cherchez son correlationId dans les logs : toutes les lignes écrites pendant cette requête, quel que soit le service, portent le même req.id.
Situations
- Vous ajoutez une variable d'environnement → propriété typée dans le schéma, ligne commentée dans
.env.example, et namespace si elle appartient à un domaine (validation). - Un module dynamique a besoin de la configuration →
forRootAsync({ inject: [xxxConfig.KEY], useFactory })(config typée). PORT=8080est refusé alors que3000passe → annotez le type :PORT: number = 3000(validation).- Une fonctionnalité ne doit pas exister en production →
ConditionalModule.registerWhen()avec une fonction (profils). - Vous devez retrouver tous les logs d'une requête → filtrez sur
req.id, égal aux-correlation-id(cas pino). - Un secret apparaît dans les logs → ajoutez son chemin à
redact(cas pino). - Les connexions doivent être fermées sur
SIGTERM→app.enableShutdownHooks()etonModuleDestroy()/onApplicationShutdown()(cycle de vie).
Pièges
Un fichier .env versionné
Un .env.development commité avec de vrais secrets reste dans l'historique git même après suppression. Ne versionnez que .env.example, avec des valeurs factices, et vérifiez le .gitignore avant le premier commit. En production, les secrets viennent de l'environnement (orchestrateur, gestionnaire de secrets).
Booléens d'environnement
enableImplicitConversion convertit une propriété boolean avec Boolean(valeur) : la chaîne "false" devient true. Pour un drapeau d'environnement, convertissez explicitement avec @Transform(({ value }) => value === 'true'), comme pour ?archived=false en J2.
- Type inféré dans le schéma (
PORT = 3000) : la conversion ne se fait pas (validation). process.env.Xlu partout dans le code : la valeur n'est pas validée, c'est toujours une chaîne, et un test ne peut pas la remplacer simplement. Passez parConfigServiceou un namespace injecté.NODE_ENV=staging: refusé par le schéma de TaskFlow. Utilisezproductionet d'autres variables.pino-prettyen production : c'est une devDependency, absente de l'image construite avecnpm ci --omit=dev. TaskFlow ne l'active qu'en développement.console.log()dans le code : la ligne échappe à pino (pas de JSON, pas dereq.id, pas deredact).- Oublier
enableShutdownHooks(): aucun hook d'arrêt surSIGTERM, le pool de connexions et les autres ressources ne sont pas libérés proprement. - Test unitaire du schéma sans
import 'reflect-metadata': « Reflect.getMetadata is not a function » (tests sans Nest).
Exercices
- [ ] Créer
.env.developmentet.env.testà partir de.env.example; vérifier avecgit statusque seul.env.exampleest versionné. - [ ] Configurer
ConfigModule.forRoot({ isGlobal: true, validate })et démarrer sansJWT_SECRET: l'application doit refuser de démarrer avec un message clair. - [ ] Écrire la configuration typée de la base, du JWT et du port (
registerAs,ConfigType), et l'injecter avec@Inject(xxxConfig.KEY). - [ ] Reproduire le piège :
PORT = 3000puisPORT: number = 3000, avec un test qui passePORT: '8080'. - [ ] (Variante) Écrire le même schéma avec Joi et comparer.
- [ ] Brancher
nestjs-pino: logs JSON en test, lisibles en développement,req.idégal aux-correlation-idenvoyé aveccurl -H 'x-correlation-id: demo-42', en-têteAuthorizationmasqué. - [ ] Ajouter un provider avec
onModuleInit()etonApplicationShutdown(), appelerenableShutdownHooks(), puis arrêter avec Ctrl+C et observer l'ordre des logs. - [ ] Noter dans
LEARNINGS.md:@ConfigurationProperties↔registerAs,@PostConstruct↔OnModuleInit,@PreDestroy↔OnModuleDestroy, profils ↔NODE_ENV.