Skip to content

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

ts
// 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.
ts
@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).

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

ts
@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.

ts
@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 un Clock. Pas de Math.random() : injecte un Randomizer.
  • Pas d'import direct de globals (process.env) — passe par ConfigService.
  • Retourne au lieu de muter quand possible (favorise les fonctions pures dans la couche domaine).
ts
@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. Hooks OnModuleInit/OnApplicationShutdown déjà présents mais enableShutdownHooks à appeler explicitement.
  • Nest 8 : Refonte du Logger injectable. LoggerService interface 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 HttpModule du core → @nestjs/axios. Si tu injectes HttpService, 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

  1. God service. Une classe UsersService avec 30 méthodes qui touche à 10 tables. Sépare par use-case (UserRegistrationService, UserProfileService).
  2. Side-effects dans le constructeur. Connecter à Redis, lire un fichier, faire un fetch — tout ça doit être dans onModuleInit. Sinon ton test unitaire fait des I/O.
  3. 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.
  4. 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.
  5. Async getter au boot non attendu. @Injectable() class X { constructor() { this.load(); /* async non-await */ } } — race condition. Utilise OnApplicationBootstrap async.
  6. 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.
  7. Inject de Request dans un singleton. Le Request est REQUEST-scoped. L'injecter dans un service DEFAULT lève une erreur — ou pire, contamine en REQUEST.
  8. 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

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

  1. Unit : service isolé, repos mockés (le plus rapide, le plus nombreux).
  2. Integration module : TestingModule avec imports: [UsersModule], override des deps externes (DB → in-memory).
  3. 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.

ts
@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.

ts
@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.

ts
@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.

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

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

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

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

ts
// ❌ 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.

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

ts
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èleQuand l'injecterCaractéristiques
claude-opus-4-8Raisonnement agentique long, code, knowledge workFlagship, 1M context, 128K output
claude-sonnet-4-6Équilibre vitesse/intelligence, gros volume1M context, 64K output
claude-haiku-4-5Classif/extraction rapide, sous-tâches, éco200K context, le moins cher

Trois règles qui changent la conception du provider :

  1. max_tokens doit être généreux mais borné. Une réponse tronquée (stop_reason === 'max_tokens') coûte des tokens pour rien. Défaut sain : ~16000 en non-streaming (sous le timeout HTTP du SDK), ~64000 en streaming. Au-delà de ~16K, streamer est obligatoire sinon le SDK timeout.
  2. Le retry est déjà dans le SDK. maxRetries (défaut 2) gère 429/5xx avec backoff exponentiel. Ne réécris pas un backoff maison dans le service — c'est de la duplication et ça double les retries.
  3. thinking est adaptive-only sur Opus 4.8/4.7. Le champ par défaut display est 'omitted' : si tu streames le raisonnement vers le front, il sera vide. Pour l'afficher, passe thinking: { type: 'adaptive', display: 'summarized' }. temperature/top_p/top_k retournent un 400 (supprimés) — on pilote par le prompt et output_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).

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

ts
@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 un try/catch et renvoie un tool_result avec is_error: true plutô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', le content peut contenir plusieurs blocs text (ex. raisonnement + réponse). Le find ne renvoie que le premier ; en prod, concatène tous les blocs text (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_budget cô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 :

ConcernPourquoi c'est spécifique à l'IAPattern
IdempotenceUn retry BullMQ ne doit pas régénérer (et refacturer)jobId = generationId ; check repo.findByGenerationId avant d'appeler le LLM
Retry cost-awareChaque tentative coûte des tokensattempts bas (2–3), backoff exponentiel ; ne retry pas un 400/refus (non transitoire)
Sortie partielleUn crash mid-stream a déjà coûté des tokensPersiste les chunks au fil de l'eau ; à la reprise, repars du dernier checkpoint ou jette le partiel proprement
AnnulationL'utilisateur peut annuler un job en coursStocke un flag/AbortController keyé sur generationId, vérifié dans la boucle
ts
@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
    }
  }
}
ts
// 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/throttler ou 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_tokens du SDK alimente le compteur. Attention au prompt caching : usage.cache_read_input_tokens coûte ~0.1× et cache_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) + le model réellement servi (res.model) + une generationId. C'est ta source de vérité pour la facturation interne et le débogage de dépassements.
  • Latence et stop_reason : un stop_reason: 'max_tokens' récurrent = max_tokens trop bas (réponses tronquées) ; un stop_reason: 'refusal' = à router/logger à part (ce n'est pas une erreur HTTP, c'est un 200). 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

PratiqueQuandÉviter
Repository patternDB / I/O multiple, swap d'infra possibleApp tiny avec 1 table
Domain service séparéRègles métier complexesCRUD pur
Ports/adapters (Symbol + impl)Tests + multi-envOne-off, lib jetable
Lifecycle hooksInit async (Redis, Kafka, migrations)Init purement synchrone
Decorator pattern (caching)Performance cibléePremature optimization
Static methodsHelpers 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.BadRequestErrormarkFailed 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

Bibliothèque tech perso — Achref