Dans l’article sur MyMove, j’ai effleuré le sujet de l’API centrale NestJS sans creuser. Pourtant c’est le morceau du projet qui m’a le plus fait réfléchir. Pas parce que le code est compliqué — au contraire, NestJS rend les choses assez naturelles une fois qu’on a compris le modèle. Mais parce que retrouver côté backend les patterns que je connaissais d’Angular (et avant ça, de Spring) m’a obligé à repenser la façon dont je structure une API Node.js.
Cet article revient sur l’injection de dépendances dans NestJS et la structuration modulaire qu’elle impose, avec des exemples tirés du code de MyMove et une comparaison avec ce qui se fait en Java.
NestJS 7 : le contexte
NestJS 7 est sorti en mars 2020. Le framework existe depuis fin 2017, mais c’est en 2019 que l’adoption a vraiment décollé — les téléchargements npm ont quadruplé en un an. La version 7 apporte un module GraphQL réécrit de zéro (plus de dépendance sur type-graphql), des pipes de validation améliorés, et surtout un système de décorateurs custom qui reçoivent maintenant un ExecutionContext, ce qui unifie l’API entre REST et GraphQL.
Pour MyMove, on est sur du REST pur avec TypeORM et MySQL. Pas de GraphQL. Mais les fondamentaux — modules, providers, injection de dépendances — sont les mêmes quel que soit le transport.
L’injection de dépendances, d’Angular au backend
Le modèle Angular
Angular a popularisé l’injection de dépendances dans l’écosystème JavaScript. Le principe : au lieu d’instancier ses dépendances soi-même, on déclare ce dont on a besoin dans le constructeur, et le framework se charge de fournir les instances.
// Angular — un service déclare ses dépendances dans le constructeur
@Injectable()
export class UserService {
constructor(private http: HttpClient) {}
}
L’injecteur d’Angular est hiérarchique : il suit l’arbre des composants. Un service déclaré dans un module parent est disponible dans tous les modules enfants. Un service déclaré dans un composant est instancié une fois par composant. Ce modèle a du sens dans une UI où la hiérarchie des composants est un arbre.
L’adaptation NestJS
NestJS reprend le même mécanisme, mais adapté au backend. Pas d’arbre de composants — à la place, un graphe de modules. Chaque module déclare ses providers (les services qu’il instancie) et ses exports (les services qu’il rend disponibles aux autres modules).
@Module({
imports: [UsersModule, StripeModule],
providers: [Logger, CurrentCustomerService, JwtStrategy],
exports: [CurrentCustomerService],
})
export class AuthModule {}
L’AuthModule importe UsersModule et StripeModule pour accéder à leurs services exportés. Il déclare CurrentCustomerService comme provider et l’exporte pour que d’autres modules puissent l’injecter. Le conteneur IoC de NestJS résout le graphe de dépendances au démarrage et instancie tout dans le bon ordre.
Côté service, le pattern est identique à Angular :
@Injectable()
export class SearchesService {
constructor(
@InjectRepository(SearchEntity)
private searchesRepository: Repository<SearchEntity>,
private providerResultsService: ProviderResultsService,
private connection: Connection,
private logger: Logger,
) {
logger.setContext(SearchesService.name)
}
}
SearchesService déclare quatre dépendances dans son constructeur. Le conteneur injecte le repository TypeORM (via le token @InjectRepository), le ProviderResultsService (résolu par type), la Connection TypeORM, et le Logger. Aucune instanciation manuelle. Aucun new.
Ce qui change par rapport à Angular
La différence principale, c’est le cycle de vie. Angular crée et détruit des composants en permanence au fil de la navigation. NestJS, par défaut, instancie chaque provider une seule fois au démarrage (scope singleton) et le réutilise pour toutes les requêtes.
Mais il y a des cas où on a besoin d’un état par requête. Dans MyMove, le CurrentCustomerService est scopé à la requête HTTP :
@Injectable({ scope: Scope.REQUEST })
export class CurrentCustomerService {
private _customer?: Stripe.Customer
constructor(
@Inject(REQUEST)
private request: any,
private stripeService: StripeService,
private usersService: UsersService,
private logger: Logger,
) {}
async fetchOrCreateCustomer(): Promise<Stripe.Customer> {
if (!this._customer) {
const { user } = this.request
this._customer =
(await this.fetchExistingCustomer()) ||
(await this.createNewCustomerForUser(user))
}
return this._customer
}
}
Scope.REQUEST instancie un nouveau CurrentCustomerService à chaque requête HTTP. Le service injecte l’objet REQUEST pour accéder à l’utilisateur authentifié, puis cache le client Stripe pour éviter de faire deux appels API dans la même requête. C’est l’équivalent du scope request de Spring.
Comparaison avec Spring et Guice
J’ai bossé sur des projets Java avant de passer au full-stack TypeScript. La comparaison s’impose.
Spring : convention over configuration
Spring Boot, c’est la référence de l’injection de dépendances en Java. Le mécanisme repose sur le component scanning : Spring parcourt le classpath, trouve les classes annotées @Component, @Service, @Repository, et les enregistre automatiquement dans le conteneur.
// Spring Boot — le composant est découvert par scanning
@Service
public class SearchesService {
private final SearchesRepository searchesRepository;
private final ProviderResultsService providerResultsService;
// injection par constructeur (recommandée depuis Spring 4.3)
public SearchesService(
SearchesRepository searchesRepository,
ProviderResultsService providerResultsService
) {
this.searchesRepository = searchesRepository;
this.providerResultsService = providerResultsService;
}
}
Avec Spring, pas besoin de déclarer explicitement les providers dans un module. Le scanner les trouve. C’est puissant quand le projet grossit, mais ça rend le wiring implicite — il faut connaître les conventions pour comprendre d’où vient une dépendance.
NestJS est plus explicite. Chaque module déclare ses imports, ses providers et ses exports. On voit le graphe de dépendances en lisant les décorateurs @Module(). C’est plus verbeux, mais il n’y a pas de magie.
Guice : le binding explicite
Google Guice prend l’approche inverse de Spring. Pas de scanning, pas de convention — tout est déclaré dans des modules programmatiques :
// Guice — on déclare les bindings dans un module
public class SearchesModule extends AbstractModule {
@Override
protected void configure() {
bind(SearchesService.class).in(Singleton.class);
bind(ProviderResultsService.class).in(Singleton.class);
}
@Provides
Connection provideConnection(DataSource dataSource) {
return dataSource.getConnection();
}
}
NestJS se situe entre les deux. Plus explicite que Spring (on déclare les providers par module), moins verbeux que Guice (pas besoin de bind().to(), le type suffit dans la plupart des cas). Le meilleur des deux mondes, à mon avis.
Le tableau comparatif
| NestJS | Spring Boot | Guice | |
|---|---|---|---|
| Enregistrement | providers dans @Module() | Component scanning | bind().to() dans AbstractModule |
| Injection | Constructeur + @Inject() | Constructeur (+ @Autowired) | Constructeur + @Inject |
| Modules | @Module() avec imports/exports | @Configuration + auto-config | AbstractModule.configure() |
| Scopes | Singleton, Request, Transient | Singleton, Prototype, Request, Session | Singleton, no-scope |
| Custom providers | useClass, useValue, useFactory | @Bean dans @Configuration | @Provides, bindings |
| Discovery | Explicite | Scanning implicite | Explicite |
Structuration du projet : les modules comme frontières
Ce que j’apprécie dans NestJS, c’est que la structuration n’est pas optionnelle. Le framework impose des modules, et ces modules créent des frontières dans le code.
L’AppModule comme chef d’orchestre
Dans MyMove, l’AppModule est un point d’entrée minimal qui assemble les modules métier :
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: "mysql",
...configService.get<MysqlConnectionOptions>("orm"),
}),
}),
StripeModule,
UsersModule,
OrdersModule,
AuthModule,
NotificationsModule,
SearchesModule,
ValidationCodeModule,
],
controllers: [
UsersController,
CardsController,
OrdersController,
SearchesController,
],
})
export class AppModule {}
Deux patterns intéressants ici. D’abord, ConfigModule.forRoot() avec isGlobal: true : le module de configuration est enregistré une seule fois et accessible partout sans import explicite. C’est le pattern des modules globaux — à utiliser avec parcimonie, mais la config en est un cas légitime.
Ensuite, TypeOrmModule.forRootAsync() avec useFactory : la connexion base de données est configurée dynamiquement à partir du ConfigService. Le inject: [ConfigService] dit au conteneur d’injecter le service de config dans la factory. C’est l’équivalent du @Bean de Spring ou du @Provides de Guice.
Un module métier : SearchesModule
@Module({
imports: [
TypeOrmModule.forFeature([SearchEntity]),
ProviderResultsModule,
],
providers: [SearchesService, Logger],
exports: [SearchesService, TypeOrmModule],
})
export class SearchesModule {}
TypeOrmModule.forFeature() enregistre le repository pour SearchEntity dans ce module uniquement. Le SearchesService est le seul provider métier. Il est exporté pour que d’autres modules puissent l’utiliser.
La structure de fichiers suit le module :
searches/
├── searches.module.ts # déclaration du module
├── searches.service.ts # logique métier
├── searches.controller.ts # routes HTTP
├── dao/
│ └── search.entity.ts # entité TypeORM
├── dto/
│ ├── selection.dto.ts # validation des entrées
│ ├── place.dto.ts
│ ├── provider-result.dto.ts
│ └── IsGreaterThan.ts # validateur custom
└── interfaces/
└── search.interface.ts # contrats TypeScript
Chaque module est un répertoire autonome. Si je dois toucher la logique de recherche, tout est au même endroit. Pas de services/ global, pas de models/ fourre-tout.
Les patterns utiles au quotidien
Controllers minces, services épais
Les controllers NestJS ne font que du routage HTTP. Zéro logique métier :
@UseGuards(JwtAuthGuard)
@Controller("searches")
export class SearchesController {
constructor(private searchesService: SearchesService) {}
@Get()
async list(@CurrentUser() user: User, @Query("all") all?: boolean) {
return await this.searchesService.list(user, !all)
}
@Put(":searchID")
async saveSelection(
@CurrentUser() user: User,
@Param("searchID") searchID: number,
@Body() selection: SelectionDto,
) {
return await this.searchesService.saveSelection(user, searchID, selection)
}
}
Le controller reçoit la requête, extrait les paramètres via des décorateurs (@CurrentUser(), @Param(), @Body()), et délègue au service. Rien d’autre. La validation est gérée par le ValidationPipe global et les DTOs. L’authentification est gérée par le guard JwtAuthGuard.
Ce pattern est identique à ce qu’on fait dans un controller Spring @RestController. Le fait que NestJS l’impose structurellement (un controller ne peut pas accéder à un repository directement s’il n’est pas dans ses providers) évite la dérive du “controller qui fait tout”.
Guards injectables : quand la sécurité participe au DI
Les guards NestJS ne sont pas de simples middlewares. Ce sont des classes injectables avec leurs propres dépendances :
@Injectable()
export class LoadStripeCustomerGuard implements CanActivate {
constructor(private currentCustomerService: CurrentCustomerService) {}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest()
request.customer =
await this.currentCustomerService.fetchOrCreateCustomer()
return Boolean(request.customer)
}
}
Le LoadStripeCustomerGuard injecte le CurrentCustomerService (qui est lui-même scopé à la requête). Avant d’atteindre le controller, le guard charge ou crée le client Stripe et l’attache à la requête. Si l’opération échoue, le guard retourne false et la requête est rejetée.
On empile les guards sur un endpoint :
@UseGuards(JwtAuthGuard, LoadStripeCustomerGuard)
@Post()
async createOrder(@StripeCustomer() customer: Stripe.Customer) {
// le customer est déjà chargé par le guard
}
C’est comparable aux HandlerInterceptor de Spring ou aux filtres de servlet, mais avec l’injection de dépendances intégrée. Pas besoin de lookup dans un contexte global.
DTOs et validation déclarative
La validation des entrées passe par des DTOs avec des décorateurs class-validator :
export class SelectionDto {
@ValidateNested()
readonly selectedResult: ProviderResultDto
@ValidateNested()
readonly otherResults: ProviderResultDto[]
@IsPositive()
@IsOptional()
readonly co2EmissionsGrams?: number
@IsOptional()
@ValidateNested()
readonly fromPlace?: PlaceDto
@IsOptional()
@ValidateNested()
readonly toPlace?: PlaceDto
}
Le ValidationPipe global valide automatiquement chaque @Body() contre les décorateurs du DTO. Si la validation échoue, NestJS retourne une 400 avec les détails. Zéro code de validation dans le controller.
On peut même écrire des validateurs custom :
@ValidatorConstraint({ name: "isGreaterThan" })
export class IsGreaterThanConstraint
implements ValidatorConstraintInterface
{
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints
const relatedValue = (args.object as any)[relatedPropertyName]
return (
typeof value === "number" &&
typeof relatedValue === "number" &&
value > relatedValue
)
}
}
L’équivalent en Spring, c’est les annotations de Bean Validation (@NotNull, @Min, @Valid) avec un @Validated sur le controller. Le fonctionnement est quasi identique — NestJS a clairement calqué l’approche.
Custom decorators : extraire la plomberie
Les décorateurs de paramètres custom permettent d’extraire les informations de la requête de manière réutilisable :
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest()
return request.user as User
},
)
Au lieu de @Req() req suivi de req.user dans chaque méthode de controller, on écrit @CurrentUser() user: User. Le controller ne sait même pas que l’utilisateur vient de l’objet request — il reçoit un User typé.
Ce que ça change pour les tests
L’injection de dépendances rend le mocking trivial. Pour tester SearchesService en isolation, on remplace ses dépendances par des mocks :
const module = await Test.createTestingModule({
providers: [
SearchesService,
{
provide: getRepositoryToken(SearchEntity),
useValue: {
find: jest.fn().mockResolvedValue([]),
findOneOrFail: jest.fn(),
},
},
{
provide: ProviderResultsService,
useValue: {
createAllInTransaction: jest.fn(),
},
},
{
provide: Connection,
useValue: {
transaction: jest.fn((fn) => fn(mockManager)),
},
},
{
provide: Logger,
useValue: { log: jest.fn(), debug: jest.fn(), setContext: jest.fn() },
},
],
}).compile()
Le provide/useValue remplace un provider par un mock. Le SearchesService reçoit des faux repositories et services sans savoir qu’ils sont faux. C’est le même pattern que @MockBean de Spring Boot Test ou les bind().toInstance() de Guice en test.
Sans injection de dépendances, tester un service qui parle à MySQL, Stripe et Firebase en isolation, c’est un cauchemar de monkey-patching. Avec, c’est trois lignes de mock par dépendance.
Pour les tests end-to-end, on monte l’application complète et on tape dessus avec supertest :
const app = await NestFactory.create(AppModule)
await app.init()
const response = await request(app.getHttpServer())
.get("/api/searches")
.set("Authorization", `Bearer ${token}`)
.expect(200)
L’application démarre avec toutes ses vraies dépendances (ou avec des modules de test qui overrident la config base de données). On teste le wiring complet, les guards, la validation, la sérialisation.
L’interface comme contrat : leçons du SDK MySam
En parallèle de l’API NestJS, j’ai écrit un SDK TypeScript pour l’API MySam. Le SDK n’utilise pas NestJS, mais il applique le même principe d’injection via une interface :
export type RESTClient = {
get<T>(path: string, params?: any): Promise<T>
getBinary(path: string, params?: any): Promise<ArrayBuffer>
post<T>(path: string, params?: any): Promise<T>
put<T>(path: string, params?: any): Promise<T>
}
Le APIClient accepte soit un objet { subdomain, apiKey } (et il crée un client Axios), soit directement un RESTClient custom :
export default class APIClient {
constructor(params: APIClientParams) {
const restClient = isRESTClient(params)
? params
: new AxiosClient(params.subdomain, params.apiKey)
this.clients = new ClientsAPIClient(restClient)
this.estimation = new EstimationAPIClient(restClient)
this.trips = new TripsAPIClient(restClient)
// ...
}
}
Ce pattern permet de tester chaque endpoint du SDK sans faire de requêtes HTTP : on passe un RESTClient mock qui retourne des réponses prédéfinies. L’injection est manuelle (pas de conteneur IoC), mais le principe est identique à ce que fait NestJS avec ses providers.
C’est aussi ce que fait Guice à plus grande échelle : on définit une interface, on bind une implémentation, et le code consommateur ne connaît que l’interface. La différence, c’est que TypeScript efface les interfaces au runtime. Dans NestJS, quand on a besoin d’injecter par interface plutôt que par classe, on passe par des tokens d’injection :
// token symbolique
const REST_CLIENT = Symbol("REST_CLIENT")
// dans le module
providers: [
{
provide: REST_CLIENT,
useFactory: (config: ConfigService) =>
new AxiosClient(config.get("mysam.subdomain"), config.get("mysam.apiKey")),
inject: [ConfigService],
},
]
// dans le service
constructor(@Inject(REST_CLIENT) private restClient: RESTClient) {}
C’est plus verbeux, mais ça préserve l’inversion de dépendance. Le service ne connaît que RESTClient, pas AxiosClient.
Ce qu’on en retient
NestJS n’a pas inventé l’injection de dépendances. Spring le fait depuis 2003, Guice depuis 2007, Angular depuis 2016. Ce que NestJS fait, c’est amener ce modèle dans l’écosystème Node.js, avec TypeScript comme langage de premier plan.
Pour un projet comme MyMove, une API qui orchestre Stripe, des comptes utilisateurs, du push Firebase et des réservations chez un fournisseur tiers, la structuration modulaire n’est pas du luxe. Un service ne connaît que les interfaces de ses dépendances. Remplacer Stripe par un autre PSP, c’est changer un provider dans un module. Tester un service en isolation, c’est trois lignes de mock par dépendance, pas du monkey-patching. Et le graphe de dépendances est lisible directement dans les @Module(), sans scanning implicite à deviner.
Un développeur qui connaît Angular ou Spring retrouve ses repères en quelques heures. Si vous venez d’Express et que vous commencez à souffrir du manque de structure sur un projet qui grossit, c’est une porte d’entrée naturelle, sans changer de langage.