Skip to content

J3 · Injection de dépendances et modules ​

Jour 3 · jeudi 12 novembre · Livrable : couche service, modules

Le conteneur de Nest ressemble à celui de Spring : des singletons créés au démarrage et injectés par constructeur. Deux différences changent pourtant la façon d'écrire le code : les types TypeScript n'existent plus à l'exécution, et rien n'est découvert automatiquement. Ce chapitre montre comment déclarer des providers, injecter une interface par token, choisir un scope et découper l'application en modules. TaskFlow y gagne une couche service et une abstraction de stockage, prête à recevoir TypeORM en J4.

Objectifs ​

  • Comprendre comment Nest résout une dépendance de constructeur, et quand il lui faut un token.
  • Déclarer des providers avec useClass, useValue, useFactory et useExisting.
  • Utiliser des tokens personnalisés avec @Inject, et @Optional pour une dépendance facultative.
  • Choisir un scope (singleton, request, transient) en connaissant son coût.
  • Organiser l'application en modules : imports, exports, @Global, modules dynamiques (forRoot, forRootAsync).
  • Isoler la logique dans ProjectsService, derrière une abstraction ProjectsStore, et créer CommonModule.

Spring → NestJS ​

SpringNestJSÀ retenir
@Service, @Component@Injectable() + déclaration dans providersLe décorateur seul ne suffit pas : pas de component scan
Injection par constructeurInjection par constructeur, type lu dans design:paramtypesLe type du paramètre doit exister à l'exécution (une classe)
@Qualifier("store"), injection d'interface@Inject(PROJECTS_STORE)Les interfaces disparaissent à la compilation : il faut un token
@Configuration + @BeanuseFactory (+ inject)La fabrique reçoit ses dépendances par inject
@Profile, @ConditionalOnPropertyFabrique qui choisit selon la configuration, ConditionalModuleDécidé une fois, au démarrage
Alias de beanuseExistingLa même instance sous deux tokens
@Autowired(required = false), Optional<T>@Optional()undefined si aucun provider ne correspond
Singleton (défaut)Scope.DEFAULT (défaut)Partagé par toutes les requêtes
@RequestScope (via un proxy)Scope.REQUESTPas de proxy : toute la chaîne d'injection est recréée à chaque requête
@Scope("prototype")Scope.TRANSIENTUne instance par classe consommatrice
Contexte plat, @Importimports / exports entre modulesUn provider est privé à son module tant qu'il n'est pas exporté
Auto-configuration + application.ymlModules dynamiques forRoot() / forRootAsync()Configurer un module, c'est appeler une méthode statique
@Lazy sur une dépendance circulaireforwardRef()À éviter : revoir le découpage

Comprendre ​

Comment Nest résout une dépendance ​

Spring lit le type des paramètres de constructeur dans le bytecode, toujours disponible par réflexion. TypeScript, lui, efface les types à la compilation. Pour que Nest les retrouve, le compilateur doit les écrire à part : avec emitDecoratorMetadata (activé dans le tsconfig.json de TaskFlow), toute classe décorée reçoit une métadonnée design:paramtypes, la liste des valeurs qui représentent les types de ses paramètres. Pour constructor(private readonly projects: ProjectsService), cette liste vaut [ProjectsService], c'est-à-dire la classe elle-même.

Au démarrage, Nest lit ces listes, utilise chaque classe comme token (la clé de recherche dans le conteneur), cherche le provider correspondant dans le module courant puis dans les modules importés, instancie les dépendances dans l'ordre et met les instances en cache. Un @Inject(token) sur un paramètre remplace la métadonnée pour ce paramètre.

Ce qui ne survit pas à la compilation devient Object dans design:paramtypes, et Nest ne peut plus rien résoudre :

Type du paramètreMétadonnée émiseRésolution
Une classe importée normalementla classeautomatique
Une interface ou un alias de typeObject@Inject(TOKEN) obligatoire
Une classe importée avec import typeObjectimporter la classe normalement, ou @Inject
Une union de types (une classe ou null, par exemple)Object@Inject

C'est aussi pourquoi Nest utilise les décorateurs « expérimentaux » de TypeScript (experimentalDecorators) : ce sont les seuls compatibles avec emitDecoratorMetadata. La même cause produit d'autres pièges, comme la conversion de variables d'environnement en J7 (types et métadonnées).

Providers : useClass, useValue, useFactory, useExisting ​

Un provider associe un token à une recette. providers: [ProjectsService] est un raccourci pour { provide: ProjectsService, useClass: ProjectsService }. Les quatre recettes :

FormeCe que Nest faitDans TaskFlowÉquivalent Spring
useClassInstancie la classe et injecte ses dépendances{ provide: APP_GUARD, useClass: JwtAuthGuard } ; dans les tests, un store en mémoire à la place du vraiUne classe @Component
useValueFournit une valeur déjà construite, sans injection{ provide: CLOCK, useValue: () => new Date() }, le ValidationPipe globalUn @Bean qui renvoie un objet existant
useFactoryAppelle une fonction ; ses arguments sont listés dans inject ; elle peut être asyncLe choix du PROJECTS_STORE selon la configuration ; toutes les options forRootAsyncUne méthode @Bean d'une classe @Configuration
useExistingDonne un second nom à un provider existant : même instancePas utilisé (exemple ci-dessous)Un alias de bean

Une fabrique async est attendue avant que quoi que ce soit qui en dépend soit créé : c'est ainsi que les modules de base de données ouvrent leur connexion avant le démarrage. Une dépendance de fabrique peut être facultative : inject: [{ token: SomeService, optional: true }].

useExisting crée un alias, alors que useClass crée toujours une nouvelle instance :

ts
// Exemple hors TaskFlow
providers: [
  InMemoryProjectsStore,
  // PROJECTS_STORE et InMemoryProjectsStore désignent la même instance.
  { provide: PROJECTS_STORE, useExisting: InMemoryProjectsStore },
  // Avec useClass, PROJECTS_STORE serait une seconde instance, avec sa propre Map.
],

Les tests utilisent le même mécanisme pour remplacer une dépendance, comme un @MockBean :

ts
describe('ProjectsService', () => {
  let service: ProjectsService;

  beforeEach(async () => {
    // Like a @SpringBootTest slice: only the providers this test needs, with
    // the real in-memory store standing in for the database.
    const moduleRef = await Test.createTestingModule({
      providers: [ProjectsService, { provide: PROJECTS_STORE, useClass: InMemoryProjectsStore }],
    }).compile();
    service = moduleRef.get(ProjectsService);
  });

  it('makes the creator a member who can read the project', async () => {
    const project = await service.create({ name: 'Website' }, alice);
    await expect(service.findOne(project.id, alice)).resolves.toMatchObject({ name: 'Website' });
  });

  it('answers 404, not 403, to a user outside the project', async () => {
    const project = await service.create({ name: 'Website' }, alice);
    await expect(service.findOne(project.id, bob)).rejects.toBeInstanceOf(NotFoundException);
  });

Les mocks et Test.createTestingModule sont détaillés en J10.

Tokens personnalisés, @Inject et @Optional ​

Un token est une classe (le cas par défaut), une chaîne ou un Symbol. Pour tout ce qui n'est pas une classe (une interface, une fonction, un objet de configuration), déclarez une constante et injectez-la avec @Inject :

  • ProjectsService reçoit son stockage par @Inject(PROJECTS_STORE) private readonly store: ProjectsStore (cas TaskFlow). L'interface ProjectsStore ne sert qu'au compilateur ; c'est le token qui sert au conteneur. C'est le rôle de @Qualifier en Spring, avec une différence : en Spring, le type suffit tant qu'il n'y a qu'une implémentation ; ici, le token est obligatoire dès que le type est une interface.
  • TasksService reçoit l'horloge par @Inject(CLOCK) private readonly clock: Clock (CommonModule).
  • Les décorateurs des bibliothèques sont des @Inject déguisés : @InjectRepository(Task) injecte le token getRepositoryToken(Task), qui vaut la chaîne "TaskRepository" (J4).

Préférez un Symbol à une chaîne : Symbol('PROJECTS_STORE') est unique, alors que deux bibliothèques peuvent choisir la même chaîne. Exportez la constante à côté de l'interface, comme projects.store.ts.

@Optional() rend une dépendance facultative : si aucun provider ne correspond, le paramètre vaut undefined au lieu de faire échouer le démarrage. TaskFlow l'utilise dans NotificationsListener : @Optional() @InjectQueue(NOTIFICATIONS_QUEUE) private readonly queue?: Queue<NotificationJob>. La file BullMQ n'existe que si REDIS_URL est défini ; sans elle, le listener simule l'envoi (J9). C'est l'équivalent de @Autowired(required = false).

Scopes : singleton, request, transient ​

ScopeInstancesUsage typiqueCoût
Scope.DEFAULTUne seule, créée au démarrage, partagée par toute l'applicationPresque tout : services, repositories, guardsAucun
Scope.REQUESTUne par requête HTTP, abandonnée après la réponseUn contexte propre à la requête (tenant, utilisateur)L'instance et toute sa chaîne de consommateurs sont recréées à chaque requête
Scope.TRANSIENTUne par classe qui l'injecteUn outil dont l'état dépend de son utilisateur (logger nommé d'après la classe)Une instance par consommateur
ts
// Exemple hors TaskFlow : un provider request-scoped
import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import type { Request } from 'express';

@Injectable({ scope: Scope.REQUEST })
export class RequestContext {
  constructor(@Inject(REQUEST) private readonly request: Request) {}

  get correlationId(): string | undefined {
    return this.request.headers['x-correlation-id'] as string | undefined;
  }
}

Là où l'analogie avec Spring casse : @RequestScope injecte un proxy dans les singletons, qui délègue à l'instance de la requête en cours. Nest n'a pas de proxy. Le scope remonte la chaîne : le service qui injecte RequestContext devient lui aussi request-scoped, puis le contrôleur qui injecte ce service, et tout est recréé à chaque requête. Le coût n'est pas seulement l'objet lui-même. Le scope transient, lui, ne remonte pas : un singleton qui injecte un provider transient reste un singleton.

TaskFlow n'a que des singletons. Les données propres à une requête circulent en paramètres : @CurrentUser() user dans le contrôleur, puis service.findOne(id, user). C'est plus explicite, testable sans contexte HTTP, et sans coût. Un singleton ne doit jamais stocker de données de requête dans ses attributs : deux requêtes concurrentes les partageraient (état dans un singleton).

Modules : imports, exports, @Global ​

Le contexte Spring est plat : tout bean est visible de tous les autres. Un module Nest est une unité d'encapsulation, plus proche d'un module Java (JPMS) : ses providers sont privés, sauf ceux qu'il liste dans exports, et un autre module n'y accède qu'en l'important.

ts
// 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 {}
CléRôle
providersCe que le module instancie (services, stores, fabriques)
controllersLes contrôleurs dont il enregistre les routes
importsLes modules dont il utilise les exports
exportsSes providers (ou modules réexportés) visibles par les modules qui l'importent

Les dépendances entre modules de TaskFlow vont dans un seul sens :

  • AuthModule importe UsersModule, qui exporte UsersService.
  • TasksModule importe ProjectsModule, qui exporte ProjectsService (pour assertAccess), mais pas le store : les autres modules passent par les règles du service (module).
  • NotificationsQueueModule réexporte BullModule, pour que le listener puisse injecter la file.

Côté TasksModule, importer ProjectsModule suffit pour que TasksService reçoive ProjectsService dans son constructeur :

ts
// ProjectsModule exports ProjectsService: importing the module is what makes
// it injectable into TasksService.
@Module({
  imports: [TypeOrmModule.forFeature([Task]), ProjectsModule],
  controllers: [TasksController],
  providers: [TasksService, TasksScheduler],
})
export class TasksModule {}

Un module @Global() rend ses exports disponibles partout sans import, une fois qu'il est importé à la racine. TaskFlow en a deux : CommonModule (horloge) et ConfigModule.forRoot({ isGlobal: true }) (J7). Réservez-le aux briques vraiment transverses : un module global masque ses dépendances, et la lecture des imports ne suffit plus à comprendre le graphe.

Modules dynamiques : forRoot, forRootAsync ​

Un module statique a des métadonnées figées. Un module dynamique est une méthode statique qui construit ces métadonnées à partir d'arguments et renvoie un objet DynamicModule ({ module, providers, exports, imports, global }). C'est le rôle de l'auto-configuration Spring Boot alimentée par application.yml, mais par un appel explicite.

Les noms suivent une convention :

MéthodeSensExemples TaskFlow
forRoot(options)Configuration unique, à la racineConfigModule.forRoot(), EventEmitterModule.forRoot(), ScheduleModule.forRoot()
forRootAsync({ inject, useFactory })Idem, avec des options calculées à partir d'autres providersTypeOrmModule.forRootAsync(), LoggerModule.forRootAsync(), BullModule.forRootAsync()
forFeature(...)Complément par module, appuyé sur la configuration racineTypeOrmModule.forFeature([Project, ProjectMember])
register(options) / registerAsync(...)Configuration propre à chaque importJwtModule.registerAsync(), BullModule.registerQueue()

forRootAsync reprend la forme d'un provider useFactory : la fabrique reçoit ses dépendances par inject, et le module attend son résultat. Le module de base de données de TaskFlow en est l'exemple type :

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 {}

databaseConfig.KEY est le token de la configuration typée de J7. Il est visible ici parce que ConfigModule est global ; sinon, il faudrait ajouter imports: [ConfigModule.forFeature(databaseConfig)] dans les options.

Écrire son propre module dynamique tient en quelques lignes :

ts
// Exemple hors TaskFlow : un module configurable écrit à la main
import { type DynamicModule, Module } from '@nestjs/common';

export const MAILER_OPTIONS = Symbol('MAILER_OPTIONS');
export interface MailerOptions {
  from: string;
}

@Module({})
export class MailerModule {
  static forRoot(options: MailerOptions): DynamicModule {
    return {
      module: MailerModule,
      // MailerService reçoit les options par @Inject(MAILER_OPTIONS).
      providers: [{ provide: MAILER_OPTIONS, useValue: options }, MailerService],
      exports: [MailerService],
    };
  }
}

Pour générer aussi forRootAsync sans tout réécrire, @nestjs/common fournit ConfigurableModuleBuilder. Pour charger un module selon une variable d'environnement, TaskFlow utilise ConditionalModule.registerWhen() de @nestjs/config, avec un piège sur la forme chaîne (ConditionalModule).

Dépendances circulaires et forwardRef ​

Si ProjectsModule importe TasksModule et réciproquement, l'un des deux est encore undefined quand l'autre est évalué. forwardRef() retarde la résolution :

ts
// Exemple hors TaskFlow : à éviter
@Module({ imports: [forwardRef(() => TasksModule)] })
export class ProjectsModule {}

// Et côté provider :
constructor(@Inject(forwardRef(() => TasksService)) private readonly tasks: TasksService) {}

Le plan le dit : mieux vaut revoir le découpage. TaskFlow n'a pas de cycle : TasksModule dépend de ProjectsModule, et quand l'archivage d'un projet doit modifier les tâches, TypeOrmProjectsStore utilise directement l'entité Task dans sa transaction, sans importer TasksModule (J5). Voir aussi dépendance circulaire.

Cas TaskFlow ​

ProjectsService : le contrôleur ne fait que router ​

Le contrôleur de J2 traduit HTTP en appels de méthodes. Les règles métier vivent dans le service :

ts
// Business rules live here; the controller only maps HTTP to these calls.
@Injectable()
export class ProjectsService {
  constructor(@Inject(PROJECTS_STORE) private readonly store: ProjectsStore) {}

  create(dto: CreateProjectDto, user: AuthUser): Promise<Project> {
    return this.store.create(dto, user.id);
  }

  findAll(query: ProjectQueryDto, user: AuthUser): Promise<Page<Project>> {
    // Admins see everything, members only their own projects.
    return this.store.findPage(query, user.role === Role.Admin ? undefined : user.id);
  }

  // A non-member gets 404, not 403: the answer must not reveal that the
  // project exists. A soft-deleted project is not found either, even though
  // its membership rows are kept.
  async findOne(id: string, user: AuthUser): Promise<Project> {
    const project = await this.store.findById(id);
    const allowed = user.role === Role.Admin || (await this.store.isMember(id, user.id));
    if (!project || !allowed) throw new NotFoundException(`Project ${id} not found`);
    return project;
  }

  async update(id: string, dto: UpdateProjectDto, user: AuthUser): Promise<Project> {
    await this.findOne(id, user);
    return (await this.store.update(id, dto))!;
  }

  async remove(id: string, user: AuthUser): Promise<void> {
    await this.findOne(id, user);
    await this.store.softDelete(id);
  }

  async archive(id: string, user: AuthUser): Promise<Project> {
    await this.findOne(id, user);
    return (await this.store.archive(id))!;
  }

  async addMember(id: string, userId: string, user: AuthUser): Promise<void> {
    await this.findOne(id, user);
    await this.store.addMember(id, userId);
  }

  /** Used by TasksService: same rule as findOne(). */
  async assertAccess(projectId: string, user: AuthUser): Promise<void> {
    await this.findOne(projectId, user);
  }
}
  • Le service ne connaît que l'interface ProjectsStore, jamais une implémentation.
  • import { PROJECTS_STORE, type ProjectsStore } : l'import du type seul suffit, car le token est explicite.
  • Les règles d'accès (un administrateur voit tout, un membre ses projets, un non-membre reçoit 404 et non 403, un projet supprimé logiquement répond 404 à tous) sont écrites une fois : findAll() filtre la liste, findOne() contrôle l'accès à un projet. update(), remove(), archive() et addMember() l'appellent d'abord, et assertAccess(), utilisé par TasksService, lui délègue : une règle d'accès ajoutée plus tard vaudra aussi pour les tâches. Le paramètre user arrive en J8 (membres) ; en J3, les méthodes ne prennent que le DTO et l'id.
  • Le service lève des exceptions HTTP de Nest (NotFoundException). C'est pratique ; certains préfèrent des erreurs métier traduites par un filtre (J6).

ProjectsStore : interface, token et implémentation ​

L'abstraction de stockage, avec son token :

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

L'implémentation en mémoire de J2 implémente cette interface (InMemoryProjectsStore implements ProjectsStore, code en J2). En J3, le module la branche simplement :

ts
// Version J3 (hors TaskFlow final) : une seule implémentation
providers: [ProjectsService, { provide: PROJECTS_STORE, useClass: InMemoryProjectsStore }],

En J4 arrive TypeOrmProjectsStore, et le choix devient une affaire de configuration. Voici le module final :

ts
@Module({
  imports: [TypeOrmModule.forFeature([Project, ProjectMember])],
  controllers: [ProjectsController],
  providers: [
    ProjectsService,
    InMemoryProjectsStore,
    TypeOrmProjectsStore,
    // A @Bean method chosen by configuration, like @ConditionalOnProperty.
    {
      provide: PROJECTS_STORE,
      inject: [ConfigService, InMemoryProjectsStore, TypeOrmProjectsStore],
      useFactory: (config: ConfigService, memory: InMemoryProjectsStore, typeorm: TypeOrmProjectsStore) =>
        config.get('PROJECTS_STORE') === 'memory' ? memory : typeorm,
    },
  ],
  // TasksModule needs assertAccess(): export the service, not the store.
  exports: [ProjectsService],
})
export class ProjectsModule {}
  • La fabrique joue le rôle d'une méthode @Bean avec @ConditionalOnProperty : elle reçoit ConfigService et les deux implémentations par inject, et renvoie celle que désigne PROJECTS_STORE (memory ou typeorm, valeur validée au démarrage en J7).
  • Elle renvoie une instance déjà créée par le conteneur, pas un new : l'instance injectée sous PROJECTS_STORE est la même que celle déclarée dans providers.
  • Les deux implémentations sont instanciées dans tous les cas ; seul le choix change. La version TypeORM a donc besoin de la base même en mode memory.
  • exports: [ProjectsService] : le store reste privé au module.

Le contrôleur et le service ne changent pas d'une implémentation à l'autre : c'est tout l'intérêt du token, et ce que J4 vérifie (TypeORM derrière le store).

CommonModule ​

Le plan demande un CommonModule pour les utilitaires partagés. Dans TaskFlow, il fournit une horloge :

ts
// A custom token for something that is not a class: inject it with
// @Inject(CLOCK). Tests replace it with a fixed date (useValue).
export const CLOCK = Symbol('CLOCK');
export type Clock = () => Date;

@Global()
@Module({
  providers: [{ provide: CLOCK, useValue: (() => new Date()) satisfies Clock }],
  exports: [CLOCK],
})
export class CommonModule {}
  • CLOCK n'est pas une classe : un Symbol sert de token, et useValue fournit la fonction. satisfies Clock vérifie le type sans l'élargir.
  • @Global() : TasksService peut écrire @Inject(CLOCK) sans que TasksModule importe CommonModule. AppModule l'importe une fois.
  • Les tests remplacent l'horloge par une date fixe, { provide: CLOCK, useValue: () => now }, ce qui rend le marquage des tâches en retard déterministe (J10). C'est la pratique Spring qui consiste à injecter un java.time.Clock plutôt qu'appeler Instant.now().

Le reste de src/common/ (filtre, intercepteur, middleware, décorateurs, DTO de pagination, fuseau horaire de time-zone.ts) n'est pas fourni par CommonModule : les éléments du pipeline sont enregistrés globalement dans AppModule par APP_FILTER, APP_INTERCEPTOR… (J6), et les décorateurs, DTO et fonctions utilitaires s'importent comme du code ordinaire.

Situations ​

  • Vous devez injecter une interface → un token Symbol exporté avec l'interface, et @Inject(TOKEN) (tokens, ProjectsStore).
  • L'implémentation dépend de la configuration → un provider useFactory qui reçoit ConfigService et les implémentations par inject (module Projects).
  • Un test doit remplacer une dépendance → { provide: TOKEN, useClass: Fake } ou useValue: mock dans Test.createTestingModule (providers).
  • Vous devez injecter une valeur qui n'est pas une classe (horloge, options) → useValue derrière un Symbol (CommonModule).
  • Un service doit être utilisé par un autre module → l'ajouter aux exports de son module, et importer ce module (modules).
  • Un module tiers a besoin de valeurs de configuration → forRootAsync({ inject, useFactory }) (modules dynamiques).
  • Une dépendance n'existe que dans certains environnements → @Optional() et un test sur undefined (tokens).
  • Vous voulez l'utilisateur courant dans un service → passez-le en paramètre ; Scope.REQUEST seulement si c'est impossible (scopes).

Pièges ​

« Nest can't resolve dependencies of the ProjectsService (?) »

Le ? marque le paramètre introuvable. Trois causes fréquentes :

  1. Le paramètre est typé par une interface, un alias ou une union, sans @Inject : la métadonnée vaut Object (résolution). Ajoutez un token et @Inject(TOKEN).
  2. La classe est importée avec import type : même effet. Importez-la normalement. Le message de Nest le suggère d'ailleurs explicitement.
  3. Le provider existe, mais dans un autre module qui ne l'exporte pas, ou que le module courant n'importe pas. Le message propose alors de vérifier les imports.

Données de requête dans un singleton

this.currentUser = user dans un service : la requête suivante, concurrente, l'écrase. Passez la donnée en paramètre (scopes).

  • La même classe dans les providers de deux modules crée deux instances distinctes, chacune avec son état. Déclarez-la dans un seul module, exportez-la, importez ce module.
  • useClass en plus de la classe elle-même (providers: [Store, { provide: TOKEN, useClass: Store }]) crée aussi deux instances. Utilisez useExisting, ou injectez l'instance dans une fabrique comme TaskFlow.
  • Scope.REQUEST posé « pour essayer » rend request-scoped tous ses consommateurs, contrôleurs compris. Mesurez avant, et préférez les paramètres.
  • Des modules @Global() partout : le graphe devient illisible et les tests doivent fournir des dépendances que rien n'annonce.
  • Des tokens en chaîne de caractères peuvent entrer en collision ; préférez un Symbol exporté.
  • forwardRef() pour faire taire un cycle : le cycle reste, avec un ordre d'initialisation plus fragile. Extrayez la partie commune dans un troisième module.

Exercices ​

  • [ ] Isoler la logique dans ProjectsService : le contrôleur ne fait plus que du routing.
  • [ ] Introduire l'interface ProjectsStore et le token PROJECTS_STORE (un Symbol), avec InMemoryProjectsStore comme implémentation, injectée par @Inject(PROJECTS_STORE).
  • [ ] Créer CommonModule (@Global()) avec un provider CLOCK en useValue, et l'injecter dans un service.
  • [ ] Retirer @Inject(PROJECTS_STORE) du constructeur de ProjectsService, lire l'erreur au démarrage, puis le remettre.
  • [ ] Écrire les quatre formes de provider : useClass (dans un test), useValue (CLOCK), useFactory (choix du store selon une variable d'environnement), useExisting (un alias). Vérifier avec app.get(PROJECTS_STORE) === app.get(InMemoryProjectsStore) la différence entre useExisting et useClass.
  • [ ] Rendre temporairement un provider Scope.REQUEST, logger dans son constructeur et dans celui du contrôleur qui l'utilise : observer une instance par requête, des deux côtés. Revenir au singleton.
  • [ ] Utiliser un service d'un module qui ne l'exporte pas, lire l'erreur, corriger avec exports et imports.
  • [ ] Noter dans LEARNINGS.md : @Configuration + @Bean ↔ useFactory, profil conditionnel ↔ provider choisi selon la configuration.

Pour aller plus loin ​