NestJS — Providers & services
TL;DR — Un provider = toute chose injectable (classe, valeur, factory). Un service = une classe
@Injectable()qui porte une responsabilité métier. Vise : thin controllers, fat services, repositories isolés, sans side-effects au constructeur. Le service idéal est testable sans HTTP, sans DB, sans framework — juste avec des deps mockées.
🧠 Mental model
Tout dans le container Nest est un provider. La différence entre MyService, 'CONFIG' (string token), Logger (classe vendor) n'est qu'un détail de déclaration — pour le container, c'est le même triplet (token, factory, scope).
Provider taxonomy:
┌────────────────────────────────────────────────────────┐
│ Class provider : @Injectable() class X │
│ Value provider : { provide: T, useValue: ... }│
│ Factory provider : { provide: T, useFactory ... }│
│ Existing (alias) : { provide: T2, useExisting:T}│
└────────────────────────────────────────────────────────┘
Service layering (idéal):
┌──────────────────┐
│ Controller │ ← validation in, mapping out
└────────┬─────────┘
▼
┌──────────────────┐
│ Application │ ← use-cases (orchestrent)
│ Service │
└────────┬─────────┘
▼
┌──────────────────┐
│ Domain Service │ ← règles métier pures
└────────┬─────────┘
▼
┌──────────────────┐
│ Repository │ ← I/O DB
└──────────────────┘Analogie : controller = serveur de restaurant (prend la commande, apporte le plat), service = chef (cuisine), domain = recette (logique pure), repository = garde-manger (I/O). Mélanger ces rôles fait des god-classes.
🛠️ Code minimal
// users.repository.ts — I/O only
import { Injectable } from '@nestjs/common';
export interface UserRow { id: string; email: string; name: string; createdAt: Date; }
@Injectable()
export class UsersRepository {
constructor(/* @Inject('DB') private db: Pool */) {}
async findByEmail(email: string): Promise<UserRow | null> { /* ... */ return null; }
async insert(row: Omit<UserRow, 'createdAt'>): Promise<UserRow> { /* ... */ return { ...row, createdAt: new Date() }; }
}
// users.domain.ts — pure
export class User {
constructor(public id: string, public email: string, public name: string) {}
rename(newName: string) {
if (newName.length < 2) throw new Error('Name too short');
return new User(this.id, this.email, newName);
}
}
// users.service.ts — orchestrateur
import { Injectable, ConflictException } from '@nestjs/common';
import { UsersRepository } from './users.repository';
import { User } from './users.domain';
@Injectable()
export class UsersService {
constructor(private readonly repo: UsersRepository) {}
async register(email: string, name: string): Promise<User> {
const existing = await this.repo.findByEmail(email);
if (existing) throw new ConflictException('email already used');
const row = await this.repo.insert({ id: crypto.randomUUID(), email, name });
return new User(row.id, row.email, row.name);
}
}
// users.controller.ts — thin
import { Body, Controller, Post } from '@nestjs/common';
@Controller('users')
export class UsersController {
constructor(private readonly users: UsersService) {}
@Post()
async register(@Body() dto: { email: string; name: string }) {
const user = await this.users.register(dto.email, dto.name);
return { id: user.id, email: user.email, name: user.name };
}
}🎯 Patterns courants
1. Thin controllers, fat services. Le controller ne fait que (a) extraire/valider l'input (via DTO + ValidationPipe), (b) appeler une méthode du service, (c) mapper la réponse. Si une méthode de controller fait > 15 lignes, c'est suspect.
2. Service responsibility layering. Sépare en couches :
- Application service : use-case complet (
registerUser,placeOrder). Orchestre repos + domain + autres services. - Domain service : logique métier pure, sans I/O (
calculateTax,applyDiscount). - Repository : SQL/HTTP/file, retourne des DTOs ou entities.
@Injectable()
export class CheckoutService { // application
constructor(
private readonly carts: CartRepository, // I/O
private readonly pricing: PricingDomainService, // pure
private readonly orders: OrdersRepository, // I/O
) {}
async checkout(userId: string) {
const cart = await this.carts.get(userId);
const total = this.pricing.calculate(cart); // pure call
const order = await this.orders.create({ userId, total });
return order;
}
}3. Repository pattern + ports/adapters. Définis une interface (port) + plusieurs adapters (in-memory, Postgres, HTTP).
export interface UserRepo {
findByEmail(email: string): Promise<User | null>;
}
export const USER_REPO = Symbol('USER_REPO');
@Injectable()
class PostgresUserRepo implements UserRepo { /* ... */ }
@Injectable()
class InMemoryUserRepo implements UserRepo { /* ... pour tests */ }
@Module({
providers: [
{ provide: USER_REPO, useClass: process.env.NODE_ENV === 'test' ? InMemoryUserRepo : PostgresUserRepo },
UsersService,
],
})
export class UsersModule {}4. Lifecycle hooks plutôt que side-effects. Ne fais rien dans le constructeur (pas de DB connect, pas de fetch). Utilise les hooks :
@Injectable()
export class CacheService implements OnModuleInit, OnModuleDestroy {
private redis!: Redis;
async onModuleInit() { this.redis = new Redis(process.env.REDIS_URL!); await this.redis.ping(); }
async onModuleDestroy() { await this.redis.quit(); }
}Liste : OnModuleInit, OnApplicationBootstrap (toutes les deps prêtes), OnModuleDestroy, BeforeApplicationShutdown, OnApplicationShutdown (graceful shutdown actif via app.enableShutdownHooks()).
5. Provider composition (decorator pattern). Empile des fonctionnalités via providers.
@Injectable()
class CachingUsersRepo implements UserRepo {
constructor(@Inject('INNER_USER_REPO') private inner: UserRepo) {}
async findByEmail(email: string) {
// check cache, fallback inner.findByEmail
}
}
@Module({
providers: [
PostgresUserRepo,
{ provide: 'INNER_USER_REPO', useExisting: PostgresUserRepo },
{ provide: USER_REPO, useClass: CachingUsersRepo },
],
})Tu décores PG avec du cache sans toucher au consommateur.
6. Testing-friendly service design. Règles :
- Injecte tout ce qui dépend du temps/réseau/random :
Clock,IdGenerator,HttpClient. - Pas de
new Date()in-place : injecte unClock. Pas deMath.random(): injecte unRandomizer. - Pas d'import direct de globals (
process.env) — passe parConfigService. - Retourne au lieu de muter quand possible (favorise les fonctions pures dans la couche domaine).
@Injectable()
export class Clock { now() { return new Date(); } }
@Injectable()
export class TokenService {
constructor(private clock: Clock) {}
isExpired(token: { exp: number }) { return token.exp < this.clock.now().getTime() / 1000; }
}
// test : { provide: Clock, useValue: { now: () => new Date('2026-01-01') } }🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
- Nest 7 :
@Injectable()standard. HooksOnModuleInit/OnApplicationShutdowndéjà présents maisenableShutdownHooksà appeler explicitement. - Nest 8 : Refonte du
Loggerinjectable.LoggerServiceinterface standardisée — tu peux remplacer Logger par Pino/Winston via override de provider. - Nest 9 : Améliorations DX sur les erreurs "Nest can't resolve dependencies of X" — pointe le param exact manquant.
- Nest 10 : Suppression de
HttpModuledu core →@nestjs/axios. Si tu injectesHttpService, il faut désormais le bon package. Les providers REQUEST sont mieux tracés en log. - Nest 11 : Le logger par défaut supporte le mode JSON natif et le buffering (
BufferLogger) pour les apps logguant en JSON dès le boot.@Optional()injection (@Optional() @Inject('FOO') private foo?: Foo) plus prévisible.
⚠️ Pitfalls
- God service. Une classe
UsersServiceavec 30 méthodes qui touche à 10 tables. Sépare par use-case (UserRegistrationService,UserProfileService). - Side-effects dans le constructeur. Connecter à Redis, lire un fichier, faire un
fetch— tout ça doit être dansonModuleInit. Sinon ton test unitaire fait des I/O. - Service qui dépend du framework. Si ton service importe
@Req()ou manipule des objets Express, il n'est plus testable hors HTTP. Mets ça dans le controller ou dans un middleware. - Méthodes statiques.
UsersService.findById(id)— non injectable, non mockable, casse le pattern. Si tu veux un util, mets-le dans un module dédié et injecte-le. - Async getter au boot non attendu.
@Injectable() class X { constructor() { this.load(); /* async non-await */ } }— race condition. UtiliseOnApplicationBootstrapasync. - Repository qui retourne des entities domain. Souvent OK, mais attention aux dépendances cycliques. Préfère retourner des DTOs/rows et mapper dans le service.
- Inject de
Requestdans un singleton. LeRequestest REQUEST-scoped. L'injecter dans un service DEFAULT lève une erreur — ou pire, contamine en REQUEST. - Trop d'abstractions au début. Ports/adapters/use-cases pour 3 endpoints CRUD = over-engineering. Commence simple (controller → service → repo), refacto quand la complexité justifie.
🧪 Testing
import { Test } from '@nestjs/testing';
import { UsersService } from './users.service';
import { UsersRepository } from './users.repository';
describe('UsersService.register', () => {
let svc: UsersService;
let repo: jest.Mocked<UsersRepository>;
beforeEach(async () => {
repo = { findByEmail: jest.fn(), insert: jest.fn() } as any;
const mod = await Test.createTestingModule({
providers: [UsersService, { provide: UsersRepository, useValue: repo }],
}).compile();
svc = mod.get(UsersService);
});
it('rejects duplicate email', async () => {
repo.findByEmail.mockResolvedValue({ id: '1', email: 'a@b', name: 'x', createdAt: new Date() });
await expect(svc.register('a@b', 'X')).rejects.toThrow(/already used/);
expect(repo.insert).not.toHaveBeenCalled();
});
it('creates user when email is free', async () => {
repo.findByEmail.mockResolvedValue(null);
repo.insert.mockResolvedValue({ id: '1', email: 'a@b', name: 'X', createdAt: new Date() });
const user = await svc.register('a@b', 'X');
expect(user.id).toBe('1');
});
});Trois niveaux de test :
- Unit : service isolé, repos mockés (le plus rapide, le plus nombreux).
- Integration module :
TestingModuleavecimports: [UsersModule], override des deps externes (DB → in-memory). - E2E :
createNestApplication()+ supertest, vraie DB de test.
🎬 Cas d'usage concrets
Scénario 1 — Assistance juridique en ligne (LegalTech B2C)
Qui — Une startup FR (15 ETP) qui permet à des particuliers de générer des actes juridiques (mise en demeure, contrat de bail, statuts d'asso) avec assistance IA. ≈ 8k actes générés/mois.
Problème métier — Un seul service LegalService faisait tout : validation, génération de template, appel à OpenAI, sauvegarde DB, envoi par email, facturation Stripe. 1 200 lignes, impossible à tester sans tout monter. Les bugs étaient systémiques.
Comment ce concept aide — Split en application service (use-case) + domain service (règles métier pures) + repositories (I/O). L'application service orchestre, le domain calcule, les repos persistent.
@Injectable()
export class LegalActDomainService {
validateForm(act: LegalActType, payload: Record<string, any>): ValidationResult {
const schema = getSchemaFor(act);
return schema.safeParse(payload);
}
computePricing(act: LegalActType, userPlan: PlanType): PricingResult {
const base = ACT_PRICES[act];
const discount = userPlan === 'pro' ? 0.3 : 0;
return { base, discount, total: base * (1 - discount) };
}
}
@Injectable()
export class GenerateActUseCase {
constructor(
private readonly domain: LegalActDomainService,
private readonly aiGenerator: AiGeneratorPort,
private readonly repo: LegalActRepository,
private readonly mailer: MailerService,
private readonly billing: BillingService,
) {}
async execute(input: GenerateActInput) {
const validation = this.domain.validateForm(input.actType, input.payload);
if (!validation.success) throw new BadRequestException(validation.errors);
const pricing = this.domain.computePricing(input.actType, input.userPlan);
const draft = await this.aiGenerator.generate(input.actType, input.payload);
const saved = await this.repo.save({ ...input, draft, pricing });
await this.billing.charge(input.userId, pricing.total);
await this.mailer.sendDraft(input.userEmail, saved.id);
return saved;
}
}Gains chiffrés — Couverture de tests passée de 18% à 84%, MTTR sur bugs production divisé par 4, temps de dev d'un nouveau type d'acte tombé de 5 jours à 1.5 jour (le domain pur se teste sans dépendances).
Scénario 2 — Gestion locative syndic : factures et appels de fonds
Qui — Un éditeur FR (40 ETP) de SaaS syndic — 6 200 immeubles gérés. Module financier : appels de fonds trimestriels, refacturation des charges, suivi des impayés.
Problème métier — Les règles de répartition des charges (tantièmes, clés de répartition, exceptions) sont complexes et changent par immeuble. Mélangé au code d'I/O DB, c'était impossible à valider unitairement. Les régressions sur les fiches comptables des copros étaient hebdomadaires.
Comment ce concept aide — Un ChargesAllocationDomainService pur (sans I/O) qui prend Building, Expenses, OwnershipShares et retourne AllocationResult[]. Testable avec des fixtures JSON, déterministe, refactorable sans toucher à la persistence.
@Injectable()
export class ChargesAllocationDomainService {
allocate(building: Building, expenses: Expense[], shares: OwnershipShare[]): AllocationResult[] {
return building.lots.map((lot) => {
const lotShares = shares.filter((s) => s.lotId === lot.id);
const allocatedExpenses = expenses.map((exp) => {
const key = building.allocationKeys.find((k) => k.id === exp.allocationKeyId);
const share = lotShares.find((s) => s.allocationKeyId === key.id);
return {
expenseId: exp.id,
amount: Math.round(exp.amount * (share?.percentage ?? 0) * 100) / 100,
};
});
const total = allocatedExpenses.reduce((sum, e) => sum + e.amount, 0);
return { lotId: lot.id, expenses: allocatedExpenses, total };
});
}
}
@Injectable()
export class GenerateQuarterlyCallUseCase {
constructor(
private readonly buildings: BuildingsRepository,
private readonly expenses: ExpensesRepository,
private readonly shares: SharesRepository,
private readonly allocator: ChargesAllocationDomainService,
private readonly invoices: InvoicesRepository,
private readonly clock: Clock,
) {}
async execute(buildingId: string, quarter: string) {
const [building, expenses, shares] = await Promise.all([
this.buildings.findById(buildingId),
this.expenses.findForQuarter(buildingId, quarter),
this.shares.findForBuilding(buildingId),
]);
const allocations = this.allocator.allocate(building, expenses, shares);
return this.invoices.createBatch(allocations, { quarter, issueDate: this.clock.now() });
}
}Gains chiffrés — Régressions sur fiches comptables passées de ~2/semaine à 0 sur 9 mois, l'équipe a pu modifier 3 fois les règles de répartition sans casser les copros existantes (snapshots tests sur le domain pur).
Scénario 3 — Helpdesk industrie : tickets et SLA
Qui — Une ETI industrielle FR (450 personnes) qui édite en interne un helpdesk pour son support après-vente B2B (machines-outils, contrats SLA stricts).
Problème métier — Les services métier (TicketsService) appelaient directement new Date(), Math.random() pour les codes de ticket, process.env.SLA_HOURS partout. Impossible à tester de manière déterministe, les tests s'appuyaient sur jest.useFakeTimers() partout et étaient flaky.
Comment ce concept aide — Wrap tout ce qui dépend du temps/random/config dans des providers injectables. Clock, IdGenerator, SlaConfig. Les tests injectent des fakes déterministes.
@Injectable() export class Clock { now() { return new Date(); } }
@Injectable() export class IdGenerator { ticketCode() { return `TCK-${crypto.randomUUID().slice(0, 8).toUpperCase()}`; } }
@Injectable()
export class TicketsService {
constructor(
private readonly repo: TicketsRepository,
private readonly clock: Clock,
private readonly ids: IdGenerator,
private readonly slaConfig: SlaConfigService,
) {}
async open(input: OpenTicketInput) {
const sla = this.slaConfig.forCustomer(input.customerId);
const dueAt = new Date(this.clock.now().getTime() + sla.responseHours * 3600_000);
return this.repo.save({
code: this.ids.ticketCode(),
...input,
openedAt: this.clock.now(),
dueAt,
status: 'open',
});
}
}Gains chiffrés — Tests passés de flaky (~12% de faux échecs) à déterministes (0% sur 6 mois), CI accélérée de 35% (plus de setTimeout de stabilisation), confiance des devs restaurée sur le module.
🛠️ Exemple end-to-end
Use case — Helpdesk industriel. On gère l'ouverture de tickets SAV avec SLA différencié par contrat client. L'app montre toutes les bonnes pratiques : thin controller, fat service orchestrateur, domain service pur, repository isolé, providers injectables pour le temps et la config, lifecycle hooks pour init.
// src/tickets/domain/sla.domain.ts
export interface SlaPolicy {
responseHours: number;
resolutionHours: number;
priority: 'standard' | 'premium' | 'critical';
}
export class SlaCalculator {
static calculateDeadlines(openedAt: Date, policy: SlaPolicy) {
const responseDeadline = new Date(openedAt.getTime() + policy.responseHours * 3600_000);
const resolutionDeadline = new Date(openedAt.getTime() + policy.resolutionHours * 3600_000);
return { responseDeadline, resolutionDeadline };
}
static isOverdue(deadline: Date, now: Date): boolean {
return now > deadline;
}
}// src/shared/clock.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class Clock {
now(): Date { return new Date(); }
durationMs(from: Date): number { return this.now().getTime() - from.getTime(); }
}// src/shared/id-generator.ts
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
@Injectable()
export class IdGenerator {
ticketCode(): string {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const rand = randomUUID().slice(0, 6).toUpperCase();
return `TCK-${date}-${rand}`;
}
uuid(): string { return randomUUID(); }
}// src/tickets/sla-config.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SlaPolicy } from './domain/sla.domain';
@Injectable()
export class SlaConfigService implements OnModuleInit {
private policies = new Map<string, SlaPolicy>();
constructor(private readonly cfg: ConfigService) {}
async onModuleInit() {
const raw = this.cfg.get<string>('SLA_POLICIES_JSON') ?? '{}';
const parsed = JSON.parse(raw) as Record<string, SlaPolicy>;
for (const [contract, policy] of Object.entries(parsed)) {
this.policies.set(contract, policy);
}
}
forContract(contractType: string): SlaPolicy {
return this.policies.get(contractType) ?? {
responseHours: 24,
resolutionHours: 72,
priority: 'standard',
};
}
}Le SlaConfigService charge la config au boot via onModuleInit — pas dans le constructeur (sinon le test unitaire fait l'I/O). Si la config doit venir d'une DB ou d'un service externe, OnApplicationBootstrap est plus sûr (toutes les deps sont prêtes).
// src/tickets/tickets.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../shared/prisma.service';
export interface TicketRow {
id: string;
code: string;
customerId: string;
title: string;
description: string;
status: 'open' | 'in_progress' | 'resolved' | 'closed';
openedAt: Date;
responseDeadline: Date;
resolutionDeadline: Date;
}
@Injectable()
export class TicketsRepository {
constructor(private readonly prisma: PrismaService) {}
async save(ticket: Omit<TicketRow, 'id'>): Promise<TicketRow> {
return this.prisma.ticket.create({ data: ticket });
}
async findById(id: string): Promise<TicketRow | null> {
return this.prisma.ticket.findUnique({ where: { id } });
}
async findOverdue(now: Date) {
return this.prisma.ticket.findMany({
where: { status: { in: ['open', 'in_progress'] }, resolutionDeadline: { lt: now } },
});
}
}// src/tickets/tickets.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { TicketsRepository, TicketRow } from './tickets.repository';
import { SlaConfigService } from './sla-config.service';
import { SlaCalculator } from './domain/sla.domain';
import { Clock } from '../shared/clock';
import { IdGenerator } from '../shared/id-generator';
import { NotificationService } from '../notifications/notification.service';
export interface OpenTicketInput {
customerId: string;
contractType: string;
title: string;
description: string;
}
@Injectable()
export class TicketsService {
constructor(
private readonly repo: TicketsRepository,
private readonly slaConfig: SlaConfigService,
private readonly clock: Clock,
private readonly ids: IdGenerator,
private readonly notifications: NotificationService,
) {}
async open(input: OpenTicketInput): Promise<TicketRow> {
const policy = this.slaConfig.forContract(input.contractType);
const openedAt = this.clock.now();
const { responseDeadline, resolutionDeadline } = SlaCalculator.calculateDeadlines(openedAt, policy);
const ticket = await this.repo.save({
code: this.ids.ticketCode(),
customerId: input.customerId,
title: input.title,
description: input.description,
status: 'open',
openedAt,
responseDeadline,
resolutionDeadline,
});
await this.notifications.notifyTicketOpened(ticket);
return ticket;
}
async getOverdueTickets() {
const now = this.clock.now();
return this.repo.findOverdue(now);
}
async findById(id: string): Promise<TicketRow> {
const ticket = await this.repo.findById(id);
if (!ticket) throw new NotFoundException('ticket_not_found');
return ticket;
}
}Le service orchestre : il appelle le repo (I/O), passe par le domain pur (SlaCalculator), utilise les providers injectables (Clock, IdGenerator, SlaConfigService). Aucune référence à new Date(), crypto.randomUUID(), process.env. C'est ce qui le rend 100% testable unitairement.
// src/tickets/tickets.controller.ts
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { TicketsService, OpenTicketInput } from './tickets.service';
import { OpenTicketDto } from './dto/open-ticket.dto';
@Controller({ path: 'tickets', version: '1' })
export class TicketsController {
constructor(private readonly tickets: TicketsService) {}
@Post()
open(@Body() dto: OpenTicketDto) {
return this.tickets.open(dto as OpenTicketInput);
}
@Get(':id')
byId(@Param('id') id: string) {
return this.tickets.findById(id);
}
@Get('overdue/list')
overdue() {
return this.tickets.getOverdueTickets();
}
}Controller minimaliste — 3 endpoints, 3 lignes utiles chacun. Tout est dans le service.
// src/tickets/tickets.module.ts
import { Module } from '@nestjs/common';
import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service';
import { TicketsRepository } from './tickets.repository';
import { SlaConfigService } from './sla-config.service';
import { Clock } from '../shared/clock';
import { IdGenerator } from '../shared/id-generator';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [TicketsController],
providers: [TicketsService, TicketsRepository, SlaConfigService, Clock, IdGenerator],
exports: [TicketsService],
})
export class TicketsModule {}Cet exemple illustre : (a) thin controller, fat service, (b) domain pur séparé (SlaCalculator), (c) repository isolé, (d) injection de Clock et IdGenerator pour des tests déterministes, (e) lifecycle hook (onModuleInit) pour charger la config sans I/O au constructeur, (f) absence totale de globals (new Date(), crypto, process.env) dans le service. C'est le squelette type d'un service de prod hautement testable.
🤖 Servir/orchestrer un agent IA depuis un provider NestJS
C'est le point où ton stack (NestJS qui sert un agent IA à un front Angular) rencontre les providers. La règle d'or «no side-effects au constructeur, injecte tes deps» ne change pas — elle devient critique, parce qu'un client LLM est exactement le genre de dépendance qu'un junior va instancier en dur dans un champ.
Anti-pattern #1 : new Anthropic() dans un champ
// ❌ NE FAIS PAS ÇA
@Injectable()
export class BadAiService {
private anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
// ^ lecture de process.env au moment de l'import, clé non injectable,
// impossible à mocker en test, pas de retry/timeout configurable,
// une instance par classe au lieu d'un singleton partagé.
}Pourquoi c'est faux du point de vue provider : le client devient invisible pour le container Nest. Tu ne peux pas l'override en test ({ provide: ANTHROPIC, useValue: fake }), tu ne peux pas centraliser la config (timeout, maxRetries, base URL pour un proxy d'entreprise), et tu lis process.env hors de ConfigService.
Le bon pattern : un client LLM DI'd via forRootAsync
Tu construis un module dynamique qui expose le client comme provider factory. Le SDK officiel a déjà un retry exponentiel intégré (maxRetries, défaut 2 sur 429/5xx) — on s'appuie dessus plutôt que de réécrire un backoff.
// llm/llm.module.ts
import { Module, DynamicModule, Provider } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';
export const ANTHROPIC = Symbol('ANTHROPIC'); // token d'injection
@Module({})
export class LlmModule {
static forRootAsync(): DynamicModule {
const client: Provider = {
provide: ANTHROPIC,
inject: [ConfigService],
useFactory: (cfg: ConfigService) =>
new Anthropic({
apiKey: cfg.getOrThrow<string>('ANTHROPIC_API_KEY'),
maxRetries: 3, // retry SDK : 429 / 5xx avec backoff exponentiel
timeout: 60_000, // par requête ; le streaming évite les timeouts longs
}),
};
return {
module: LlmModule,
providers: [client],
exports: [client], // pour que les autres modules puissent injecter ANTHROPIC
global: true, // singleton partagé app-wide
};
}
}// llm/llm.service.ts — le service métier injecte le client, jamais le SDK en dur
import { Inject, Injectable } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from './llm.module';
@Injectable()
export class LlmService {
constructor(@Inject(ANTHROPIC) private readonly anthropic: Anthropic) {}
async summarize(text: string): Promise<string> {
const res = await this.anthropic.messages.create({
model: 'claude-opus-4-8', // flagship ; ou claude-sonnet-4-6 (équilibre), claude-haiku-4-5 (rapide/éco)
max_tokens: 1024,
thinking: { type: 'adaptive' }, // adaptive est le SEUL mode "on" sur Opus 4.8/4.7 ; budget_tokens → 400
messages: [{ role: 'user', content: `Résume :\n${text}` }],
});
const block = res.content.find((b) => b.type === 'text');
return block?.type === 'text' ? block.text : '';
}
}En test, le client devient trivialement mockable — exactement comme UsersRepository plus haut :
const mod = await Test.createTestingModule({
providers: [
LlmService,
{ provide: ANTHROPIC, useValue: { messages: { create: jest.fn() } } },
],
}).compile();Comment un staff raisonne : le client LLM est une dépendance I/O comme une DB. Tout ce qu'on a dit sur les repositories s'applique mot pour mot — token d'injection, factory, singleton, mockable. La seule nouveauté, c'est que cette dépendance coûte de l'argent et de la latence, d'où les sections suivantes (edge guards, jobs).
Choix du modèle et garde-fous SDK (à connaître côté provider)
| Modèle | Quand l'injecter | Caractéristiques |
|---|---|---|
claude-opus-4-8 | Raisonnement agentique long, code, knowledge work | Flagship, 1M context, 128K output |
claude-sonnet-4-6 | Équilibre vitesse/intelligence, gros volume | 1M context, 64K output |
claude-haiku-4-5 | Classif/extraction rapide, sous-tâches, éco | 200K context, le moins cher |
Trois règles qui changent la conception du provider :
max_tokensdoit être généreux mais borné. Une réponse tronquée (stop_reason === 'max_tokens') coûte des tokens pour rien. Défaut sain :~16000en non-streaming (sous le timeout HTTP du SDK),~64000en streaming. Au-delà de~16K, streamer est obligatoire sinon le SDK timeout.- Le retry est déjà dans le SDK.
maxRetries(défaut 2) gère429/5xxavec backoff exponentiel. Ne réécris pas un backoff maison dans le service — c'est de la duplication et ça double les retries. thinkingestadaptive-only sur Opus 4.8/4.7. Le champ par défautdisplayest'omitted': si tu streames le raisonnement vers le front, il sera vide. Pour l'afficher, passethinking: { type: 'adaptive', display: 'summarized' }.temperature/top_p/top_kretournent un400(supprimés) — on pilote par le prompt etoutput_config.effort.
Streamer les tokens vers Angular via SSE + AbortController
Un thin controller expose le stream ; le service orchestre la boucle de tokens ; le Subject RxJS est l'adaptateur entre l'AsyncIterable du SDK et le SSE de Nest. Le point production-critique : annuler côté serveur quand le client se déconnecte (sinon tu continues à payer des tokens pour une réponse que personne ne lit).
// chat/chat.service.ts
import { Inject, Injectable } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from '../llm/llm.module';
@Injectable()
export class ChatService {
constructor(@Inject(ANTHROPIC) private readonly anthropic: Anthropic) {}
// Retourne un AsyncIterable de chunks de texte ; accepte un signal d'annulation.
async *streamReply(prompt: string, signal: AbortSignal): AsyncIterable<string> {
const stream = this.anthropic.messages.stream(
{
model: 'claude-opus-4-8',
max_tokens: 4096,
messages: [{ role: 'user', content: prompt }],
},
{ signal }, // déconnexion client → abort → le SDK ferme la requête upstream
);
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
yield event.delta.text;
}
}
}
}// chat/chat.controller.ts — thin : il câble le SSE et propage la déconnexion
import { Controller, Query, Req, Sse, MessageEvent } from '@nestjs/common';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { ChatService } from './chat.service';
@Controller('chat')
export class ChatController {
constructor(private readonly chat: ChatService) {}
@Sse('stream')
stream(@Query('prompt') prompt: string, @Req() req: Request): Observable<MessageEvent> {
const controller = new AbortController();
req.on('close', () => controller.abort()); // le navigateur ferme l'EventSource → on annule
return new Observable<MessageEvent>((subscriber) => {
(async () => {
try {
for await (const token of this.chat.streamReply(prompt, controller.signal)) {
subscriber.next({ data: { token } });
}
subscriber.next({ data: { done: true } });
subscriber.complete();
} catch (err) {
if (!controller.signal.aborted) subscriber.error(err);
}
})();
return () => controller.abort(); // unsubscribe → annule aussi
});
}
}Le controller reste mince : aucune logique métier, juste l'adaptation SSE ↔ stream et la propagation de l'annulation. Toute la connaissance du LLM vit dans ChatService.
La boucle agentique (tool-use) côté serveur
Quand l'agent appelle des outils, c'est une boucle : tu envoies les tools, le modèle demande un tool_use, tu exécutes, tu renvoies le tool_result, tu reboucles jusqu'à end_turn. Architecturalement c'est un application service qui orchestre des domain/repository services (tes outils) — exactement le layering du début du fichier.
@Injectable()
export class AgentService {
constructor(
@Inject(ANTHROPIC) private readonly anthropic: Anthropic,
private readonly tools: ToolRegistry, // tes outils = des providers injectés, mockables
) {}
async run(userMessage: string, signal: AbortSignal): Promise<string> {
const messages: Anthropic.MessageParam[] = [{ role: 'user', content: userMessage }];
for (let step = 0; step < 10; step++) { // garde-fou : borne le nombre d'itérations
const res = await this.anthropic.messages.create(
{ model: 'claude-opus-4-8', max_tokens: 4096, tools: this.tools.schemas(), messages },
{ signal },
);
messages.push({ role: 'assistant', content: res.content });
if (res.stop_reason !== 'tool_use') {
const text = res.content.find((b) => b.type === 'text');
return text?.type === 'text' ? text.text : '';
}
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of res.content) {
if (block.type === 'tool_use') {
// chaque outil est un provider DI'd → testable sans HTTP, sans LLM
const output = await this.tools.execute(block.name, block.input);
toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: output });
}
}
messages.push({ role: 'user', content: toolResults });
}
throw new Error('agent_max_steps_exceeded'); // failure mode explicite, pas une boucle infinie
}
}Le ToolRegistry est un provider : tu injectes tes outils (qui sont eux-mêmes des services métier déjà testés). L'agent ne sait rien de leur implémentation — il les voit via le port (execute(name, input)). C'est le pattern ports/adapters du début, appliqué aux tools.
Failure modes que ce squelette gère (et ceux à ajouter). Le for (let step = 0; step < 10; step++) est le garde-fou contre la boucle infinie — sans lui, un modèle qui rebouclerait indéfiniment sur des tool_use te ruine. Trois autres modes à traiter en prod :
- Outil qui throw : enrobe
this.tools.execute(...)dans untry/catchet renvoie untool_resultavecis_error: trueplutôt que de faire crasher la boucle — le modèle peut alors corriger sa stratégie. Un throw non géré abandonne la génération en cours (tokens déjà payés). - Sortie agrégée : sur
stop_reason === 'end_turn', lecontentpeut contenir plusieurs blocstext(ex. raisonnement + réponse). Lefindne renvoie que le premier ; en prod, concatène tous les blocstext(content.filter(b => b.type === 'text').map(b => b.text).join('')). - Coût non borné : 10 itérations × plusieurs milliers de tokens chacune peut exploser. Couple ce garde-fou au cost-guard de l'edge, ou utilise les
task_budgetcôté SDK pour que le modèle s'auto-modère.
Jobs IA avec BullMQ : idempotence, coût, sortie partielle
Pour les générations longues ou asynchrones (rapport, batch), tu sors la génération du cycle requête/réponse vers un worker BullMQ. Les concerns production d'un job IA :
| Concern | Pourquoi c'est spécifique à l'IA | Pattern |
|---|---|---|
| Idempotence | Un retry BullMQ ne doit pas régénérer (et refacturer) | jobId = generationId ; check repo.findByGenerationId avant d'appeler le LLM |
| Retry cost-aware | Chaque tentative coûte des tokens | attempts bas (2–3), backoff exponentiel ; ne retry pas un 400/refus (non transitoire) |
| Sortie partielle | Un crash mid-stream a déjà coûté des tokens | Persiste les chunks au fil de l'eau ; à la reprise, repars du dernier checkpoint ou jette le partiel proprement |
| Annulation | L'utilisateur peut annuler un job en cours | Stocke un flag/AbortController keyé sur generationId, vérifié dans la boucle |
@Processor('ai-generation')
export class GenerationProcessor extends WorkerHost {
constructor(
@Inject(ANTHROPIC) private readonly anthropic: Anthropic,
private readonly repo: GenerationRepository,
) { super(); }
async process(job: Job<{ generationId: string; prompt: string }>) {
const { generationId, prompt } = job.data;
// 1. Idempotence : si déjà fait, on ne rappelle pas le LLM (économie + correction)
const existing = await this.repo.findByGenerationId(generationId);
if (existing?.status === 'completed') return existing.result;
try {
const res = await this.anthropic.messages.create({
model: 'claude-opus-4-8', max_tokens: 8192,
messages: [{ role: 'user', content: prompt }],
});
const text = res.content.find((b) => b.type === 'text');
const result = text?.type === 'text' ? text.text : '';
await this.repo.markCompleted(generationId, result);
return result;
} catch (err) {
// 2. Retry cost-aware : ne pas brûler des tokens sur une erreur non transitoire
if (err instanceof Anthropic.BadRequestError) {
await this.repo.markFailed(generationId, 'bad_request');
return; // pas de re-throw → BullMQ ne retry pas
}
throw err; // 429 / 5xx → laisse BullMQ retry avec backoff
}
}
}// enqueue : le jobId EST le garant d'idempotence
await this.queue.add(
'generate',
{ generationId, prompt },
{ jobId: generationId, attempts: 3, backoff: { type: 'exponential', delay: 2000 } },
);Guards à l'edge : rate-limit, cost-guard, idempotence
Avant même d'atteindre le service LLM, l'edge (guard/interceptor) doit protéger : un endpoint IA non gardé est une facture ouverte. Ces guards sont des providers DI'd — donc testables :
- Rate-limit par utilisateur/tenant (
@nestjs/throttlerou un guard custom adossé à Redis). - Cost-guard : un provider qui track les tokens consommés par tenant et rejette (
429) au-delà d'un quota —usage.input_tokens + usage.output_tokensdu SDK alimente le compteur. Attention au prompt caching :usage.cache_read_input_tokenscoûte ~0.1× etcache_creation_input_tokens~1.25× — un cost-guard qui somme bêtement les tokens sans pondérer surestime la facture. - Idempotency-key : un interceptor qui déduplique sur un header
Idempotency-Key, indispensable si le client peut rejouer une requête de génération.
Observabilité d'un provider LLM (production)
Un service LLM sans observabilité est une boîte noire qui brûle de l'argent. Trois signaux à émettre depuis le provider (via un Logger injecté ou un interceptor) :
- Coût/usage par requête : logge
usage(input/output/cache tokens) + lemodelréellement servi (res.model) + unegenerationId. C'est ta source de vérité pour la facturation interne et le débogage de dépassements. - Latence et
stop_reason: unstop_reason: 'max_tokens'récurrent =max_tokenstrop bas (réponses tronquées) ; unstop_reason: 'refusal'= à router/logger à part (ce n'est pas une erreur HTTP, c'est un200). Mesure le time-to-first-token séparément du temps total en streaming. - Trace ID propagée : passe un
traceId(depuis le controller, en argument — pas via@Inject(REQUEST)dans un singleton, cf. pitfall scope) jusqu'au log de la requête LLM, pour corréler une plainte utilisateur avec l'appel exact.
Comment un staff raisonne : tu ne mets pas ces métriques dans le service métier (il deviendrait non testable et couplé à ton APM). Tu les mets dans un interceptor ou un décorateur de provider (cf. pattern #5 du début) qui enveloppe le client LLM — l'observabilité est une préoccupation transverse, pas de la logique métier.
Mental model : LLM = dépendance I/O qui coûte de l'argent. Tout le fichier (DI, ports/adapters, thin controller / fat service, lifecycle hooks, pas de side-effect au constructeur) s'applique tel quel — on ajoute juste trois préoccupations propres à l'IA : annulation (abort sur déconnexion), coût (guards + retry cost-aware), asynchronisme (streaming SSE + jobs idempotents).
🔁 Quand utiliser / éviter
| Pratique | Quand | Éviter |
|---|---|---|
| Repository pattern | DB / I/O multiple, swap d'infra possible | App tiny avec 1 table |
| Domain service séparé | Règles métier complexes | CRUD pur |
| Ports/adapters (Symbol + impl) | Tests + multi-env | One-off, lib jetable |
| Lifecycle hooks | Init async (Redis, Kafka, migrations) | Init purement synchrone |
| Decorator pattern (caching) | Performance ciblée | Premature optimization |
| Static methods | Helpers stateless purs (sans DI) | Tout ce qui doit être mockable |
🏋️ Exercices
Progression : implémenter → rendre production-grade → casser puis réparer. Chaque exercice escalade le précédent.
1 — Le service testable sans I/O (implémenter)
Objectif : écrire un OrderService.place(cart) qui orchestre un PricingDomainService pur (calcul TVA + remise) et un OrdersRepository, sans un seul new Date() / Math.random() / process.env dans le service. Indice/Solution : injecte Clock et IdGenerator. Le test unitaire passe un { now: () => new Date('2026-01-01') } en useValue et vérifie le total calculé sans monter ni DB ni HTTP. Si tu dois jest.useFakeTimers(), c'est que le temps n'est pas injecté → refacto.
2 — Ports/adapters + decorator caching (production-grade)
Objectif : définir un port ProductRepo (Symbol token), deux adapters (PostgresProductRepo, InMemoryProductRepo), puis empiler un CachingProductRepo via useExisting sans toucher au service consommateur. Indice/Solution : { provide: 'INNER_REPO', useExisting: PostgresProductRepo } + { provide: PRODUCT_REPO, useClass: CachingProductRepo } qui injecte @Inject('INNER_REPO'). Vérifie en test que le 2e appel ne touche pas le PG (spy sur l'inner). En env test, swap useClass vers InMemoryProductRepo.
3 — Lifecycle hooks + graceful shutdown (production-grade)
Objectif : un KafkaConsumerService qui connecte au boot et se ferme proprement, sans aucun I/O au constructeur, et qui ne perd pas de message au SIGTERM. Indice/Solution : connexion dans onApplicationBootstrap (toutes les deps prêtes), await consumer.disconnect() dans onApplicationShutdown, et app.enableShutdownHooks() dans main.ts. Teste qu'un constructeur seul (sans bootstrap) ne fait aucune I/O.
4 — Casser : le piège REQUEST-scope (break-then-fix)
Objectif : injecter @Inject(REQUEST) dans un service DEFAULT-scoped, observer la contamination de scope (tout l'arbre devient REQUEST-scoped → perf en chute), puis réparer. Indice/Solution : le symptôme est un service instancié à chaque requête au lieu d'être singleton. Fix : ne pas injecter Request dans le service ; passer la donnée requise (userId, traceId) en argument depuis le controller, ou isoler le besoin REQUEST dans un provider dédié avec Scope.REQUEST explicitement assumé.
5 — Servir un agent IA annulable (production-grade, stack-intégration)
Objectif : exposer GET /chat/stream en SSE qui streame les tokens d'un claude-opus-4-8, avec annulation côté serveur quand le client ferme l'EventSource. Indice/Solution : AbortController créé dans le controller, req.on('close', () => controller.abort()), signal passé à anthropic.messages.stream(..., { signal }). Test du failure mode : coupe le client mid-stream et vérifie (spy/log) que la boucle for await sort bien et qu'aucun token n'est consommé après l'abort.
6 — Job IA idempotent et cost-aware (break-then-fix)
Objectif : un worker BullMQ qui génère un rapport. Casse-le d'abord (retry naïf qui régénère et refacture à chaque tentative), puis rends-le idempotent et cost-aware. Indice/Solution : jobId = generationId + repo.findByGenerationId avant l'appel LLM (idempotence) ; sur Anthropic.BadRequestError → markFailed sans re-throw (pas de retry inutile) ; sur 429/5xx → re-throw pour laisser BullMQ retry avec backoff exponentiel et attempts: 3. Vérifie qu'un double-enqueue du même generationId n'appelle le LLM qu'une fois.
🎤 En entretien
Q : Quelle est la différence entre un provider et un service dans Nest ? R : Un service est un sous-ensemble des providers — une classe @Injectable() qui porte de la logique métier. Un provider est tout ce que le container sait résoudre : classe, valeur (useValue), factory (useFactory), alias (useExisting). Pour le container, c'est le même triplet (token, factory, scope).
Q : Pourquoi ne jamais faire de side-effects (DB connect, fetch) dans le constructeur d'un service ? R : Le constructeur sert à câbler des deps, pas à faire de l'I/O. Un side-effect au constructeur rend le test unitaire impossible sans monter l'infra, crée des race conditions (async non-awaité), et casse l'ordre de résolution du container. On utilise onModuleInit / onApplicationBootstrap (deps prêtes) pour l'init async, et onModuleDestroy / onApplicationShutdown pour le graceful shutdown.
Q : J'injecte Request dans un singleton et l'app ralentit fortement. Pourquoi ? R : Request est REQUEST-scoped. L'injecter contamine tout l'arbre de dépendances en REQUEST : Nest réinstancie ces providers à chaque requête au lieu de réutiliser un singleton — perte de perf et de mémoire. Fix : passer la donnée nécessaire en argument depuis le controller, ou assumer explicitement et isoler le scope REQUEST.
Q : Comment intègres-tu un client LLM (Anthropic) proprement dans NestJS ? R : Jamais new Anthropic() dans un champ — ça lit process.env hors ConfigService, n'est pas mockable et duplique le client. On l'expose comme provider via forRootAsync (useFactory + ConfigService, token Symbol, global/singleton), on s'appuie sur le retry intégré du SDK (maxRetries), et les services métier l'injectent par token. En test, { provide: ANTHROPIC, useValue: fakeClient }. Côté prod, on ajoute annulation (AbortController sur déconnexion), cost-guard à l'edge et jobs idempotents pour les générations longues.
Q : Où mets-tu la mesure de coût/latence d'un appel LLM, et pourquoi pas dans le service ? R : Dans un interceptor ou un décorateur de provider qui enveloppe le client, pas dans le service métier. L'observabilité (usage tokens, stop_reason, latence, trace ID) est une préoccupation transverse : la mettre dans le service le couple à l'APM et casse sa testabilité unitaire. C'est exactement le pattern decorator/provider composition appliqué au cross-cutting — le service reste pur, le wrapper logge. Bonus senior : pondérer les cache_read/cache_creation tokens dans le compteur de coût, sinon on surestime la facture d'un facteur ~10 sur les requêtes cachées.
Q : Un service @Injectable() lit process.env.FEATURE_X directement. Quel est le problème et comment le corriges-tu ? R : Trois problèmes : (1) non testable de façon déterministe (le test dépend de l'env de la machine), (2) non typé/validé (une coquille passe silencieusement), (3) impossible à override par module/contexte. Fix : injecter ConfigService (validé au boot via un schéma Zod/Joi) ou un provider de config dédié ({ provide: FEATURE_FLAGS, useFactory: ... }). Le service reçoit la valeur par DI, le test injecte { provide: FEATURE_FLAGS, useValue: { x: true } }. Règle générale : aucun accès direct à un global (process.env, Date, Math.random, crypto) dans un service.
🔗 Liens
- Providers : https://docs.nestjs.com/providers
- Custom providers : https://docs.nestjs.com/fundamentals/custom-providers
- Lifecycle events : https://docs.nestjs.com/fundamentals/lifecycle-events
- Testing : https://docs.nestjs.com/fundamentals/testing
- "Clean Architecture" — Robert C. Martin (concepts transposables)