Skip to content

J7 · Configuration et logging ​

Jour 7 · mercredi 18 novembre · Livrable : ConfigModule validé, env par environnement

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 registerAs et l'injecter de façon typée.
  • Valider les variables d'environnement au démarrage (class-validator dans TaskFlow, Joi en alternative).
  • Remplacer les profils Spring par NODE_ENV et des fichiers .env.*.
  • Produire des logs JSON structurés avec nestjs-pino, et comprendre le Logger de Nest.
  • Utiliser les hooks de cycle de vie (OnModuleInit, OnApplicationShutdown) et enableShutdownHooks().

Spring → NestJS ​

SpringNestJSÀ 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=devNODE_ENV=development + .env.developmentUn 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', () => ({ ... })) + ConfigTypeLe mapping est écrit à la main, sans binding automatique
@Validated sur la classe de propriétésvalidate: validateEnv (class-validator) ou validationSchemaÉchec au démarrage, pas à la première lecture
@Profile("dev"), @ConditionalOnPropertyConditionalModule.registerWhen(), provider useFactoryChoix explicite dans le module
SLF4J + Logback, MDCLogger de Nest + nestjs-pino, AsyncLocalStoragePas de ThreadLocal : le contexte suit la chaîne asynchrone
@PostConstructonModuleInit() (OnModuleInit)Peut être async : Nest attend la Promise
@PreDestroyonModuleDestroy() (OnModuleDestroy)Appelé seulement si l'arrêt passe par Nest
Hook d'arrêt JVM, actif par défaut dans Spring Bootapp.enableShutdownHooks()À activer explicitement

Comprendre ​

ConfigModule.forRoot ​

ts
// 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 :

  1. Il lit les fichiers de envFilePath. Si une variable figure dans plusieurs fichiers, le premier gagne : .env.development l'emporte sur .env. Un fichier absent est ignoré.
  2. 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'option override: true.
  3. Il passe l'objet fusionné à validate(). Si la fonction lève une erreur, l'application ne démarre pas.
  4. Il recopie les valeurs validées dans process.env pour les clés qui n'y sont pas encore : les valeurs par défaut du schéma deviennent visibles partout.
  5. Il enregistre ConfigService et les namespaces listés dans load.

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 ​

ts
// 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.KEY est le token d'injection : @Inject(jwtConfig.KEY) config: JwtConfig dans un service, ou inject: [databaseConfig.KEY] dans un forRootAsync() (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 :

ts
// 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 :

text
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.

ts
// 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 ?

ContexteNODE_ENVFichier chargé
npm run start:devnon défini → development.env.development
npm run test:e2eJest le fixe à test s'il n'est pas défini.env.test
Image Docker (Dockerfile, service app du Compose)productionaucun : .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_STORE et REDIS_URL choisissent une implémentation ou activent une file : une variable par fonctionnalité, plutôt que des if (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 (30 info, 40 warn, 50 error) 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 un AsyncLocalStorage. Quand un service appelle this.logger.log(...) pendant la requête, nestjs-pino retrouve ce logger enfant : la ligne porte req.id sans 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 :

MomentHookÉquivalent Spring
Dépendances du module résoluesonModuleInit()@PostConstruct
Tous les modules initialisésonApplicationBootstrap()ContextRefreshedEvent
Début de l'arrêtonModuleDestroy()@PreDestroy
Avant la fermeture du serveur HTTPbeforeApplicationShutdown(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 :

ts
// 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 :

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

Les autres fichiers se créent localement à partir de lui :

bash
cp .env.example .env.development   # npm run start:dev
cp .env.example .env.test          # npm run test:e2e, puis adapter

Pour les tests e2e, .env.test pointe vers le service db-test du Compose (port 5433, base taskflow_test) :

dotenv
# .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=typeorm

Le .gitignore de TaskFlow (examples/.gitignore) exclut tous les fichiers .env* sauf l'exemple :

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

git 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 namespace database déjà typé.
ts
@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(), AuthService et JwtStrategy injectent jwtConfig.KEY (J8). JWT_EXPIRES_IN est validé par @Matches(/^\d+[smhd]$/) avant d'arriver à @nestjs/jwt.
  • Port : il ne justifie pas un namespace. main.ts lit la valeur validée :
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();

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) ​

ts
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
    },
  }),
}),
  • genReqId réutilise ensureCorrelationId(), la même fonction que le CorrelationIdMiddleware de J6 : req.id dans les logs, x-correlation-id dans la réponse et correlationId dans les erreurs ont la même valeur.
  • redact remplace l'en-tête Authorization et 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, puisque LoggingInterceptor le fait déjà (J6). Conséquence : les requêtes rejetées par un guard ne produisent pas de ligne HTTP.
  • Niveau info en production, warn en test (sorties de tests lisibles), debug sinon.
  • 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 :

ts
// 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) :

json
{
  "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=8080 est refusé alors que 3000 passe → 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 au x-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() et onModuleDestroy() / 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.X lu 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 par ConfigService ou un namespace injecté.
  • NODE_ENV=staging : refusé par le schéma de TaskFlow. Utilisez production et d'autres variables.
  • pino-pretty en production : c'est une devDependency, absente de l'image construite avec npm ci --omit=dev. TaskFlow ne l'active qu'en développement.
  • console.log() dans le code : la ligne échappe à pino (pas de JSON, pas de req.id, pas de redact).
  • Oublier enableShutdownHooks() : aucun hook d'arrêt sur SIGTERM, 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.development et .env.test à partir de .env.example ; vérifier avec git status que seul .env.example est versionné.
  • [ ] Configurer ConfigModule.forRoot({ isGlobal: true, validate }) et démarrer sans JWT_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 = 3000 puis PORT: number = 3000, avec un test qui passe PORT: '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 au x-correlation-id envoyé avec curl -H 'x-correlation-id: demo-42', en-tête Authorization masqué.
  • [ ] Ajouter un provider avec onModuleInit() et onApplicationShutdown(), appeler enableShutdownHooks(), 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.

Pour aller plus loin ​