Files
_Assistant_Lead_Tech/knowledge/backend/patterns/nestjs.md
T
MaksTinyWorkshop f1b783407a docs(knowledge): capitalisation backend — intégration du triage local (mai-juin 2026)
Triage et intégration des propositions backend du buffer 95_a_capitaliser.md
(lot local RL799_V2 + app-alexandrie, mai-juin 2026), distinct de la capitalisation
remote antérieure (triage 2026-05-02).

~73 entrées intégrées sur knowledge/backend/, dont :
- patterns/auth.md : série "membrane d'auth fédérée BFF/OIDC" (9 patterns) + jose algo whitelist
- patterns/prisma.md : recette fusionnée "Migration String/Int → enum" (backfill + Cas A/B/C),
  row réactivable, endpoint replace atomique, updateMany conditionnel, etc.
- risques/general.md : 19 risques (epoch s vs ms, keepAliveTimeout=0, upsert+filtre liste,
  fail-safe catch-all, retrait asymétrique front/back, anti-énumération rate-limit, etc.)
- patterns/general, async, nestjs, contracts, tests + risques/auth, contracts, prisma, redis, stripe, tests
- compléments d'entrées existantes (authorize-after-fetch, P3014, cursor opaque, DI swc, Stripe v20...)
- README patterns/risques mis à jour

Doublons internes corrigés en relecture (suppression-champ .map() → general seul ;
e2e DB-based → tests.md seul). Doublons hors backend / entrées projet / rejets non intégrés.
Source 95_a_capitaliser.md non purgée à ce stade (purge en fin de capitalisation complète).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:25:02 +02:00

343 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Backend — Patterns : NestJS
domain: backend
bucket: patterns
tags: [nestjs, guards, auth, redis, quota]
applies_to: [analysis, implementation, review, debug]
severity: medium
validated_on: 2026-03-07
source_projects: [app-alexandrie]
---
# Backend — Patterns : NestJS
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/patterns/README.md` pour l'index complet.
---
<a id="pattern-guard-global-nestjs"></a>
## Pattern : Guard global NestJS — ordre d'enregistrement et décorateurs de bypass
- Objectif : protéger tous les endpoints par défaut, avec un mécanisme explicite pour les exceptions.
- Contexte : API NestJS avec plusieurs guards globaux (authn, authz, feature flags...).
- Quand l'utiliser : dès qu'on a 2+ guards globaux dont l'un dépend du résultat de l'autre.
- Quand l'éviter : si un seul guard suffit.
- Avantage :
- Sécurité par défaut (opt-out, pas opt-in)
- Ordre d'exécution garanti et explicite
- Bypass documenté et traçable via décorateurs
- Limites / vigilance :
- L'ordre des `APP_GUARD` dans `providers[]` est l'ordre d'exécution — ne pas inverser
- Exporter le service depuis son module si injecté dans un guard global d'un autre module
- Validé le : 07-03-2026
- Contexte technique : NestJS v10+
### Implémentation (exemple minimal)
```typescript
// app.module.ts
providers: [
{ provide: APP_GUARD, useClass: AuthGuard }, // 1er : peuple request.user
{ provide: APP_GUARD, useClass: EmailVerifiedGuard }, // 2ème : lit request.user
{ provide: APP_GUARD, useClass: EntitlementsGuard }, // 3ème : lit request.user + entitlements
]
// skip-auth.decorator.ts
export const SKIP_AUTH = 'skipAuth';
export const SkipAuth = () => SetMetadata(SKIP_AUTH, true);
// auth.guard.ts
const skip = this.reflector.getAllAndOverride<boolean>(SKIP_AUTH, [
context.getHandler(),
context.getClass(), // permet @SkipAuth() au niveau classe
]);
if (skip) return true;
```
### Checklist
- [ ] AuthGuard enregistré en premier dans `providers[]`
- [ ] AuthModule exporte AuthService si AuthGuard est dans AppModule
- [ ] Décorateur `@SkipAuth()` sur tous les endpoints publics (auth, health, docs)
- [ ] Tests unitaires sur le guard avec reflector mocké
---
<a id="pattern-redis-health-cache-court"></a>
## Pattern : RedisHealthService avec cache interne court
- Objectif : exposer un état Redis exploitable par les guards globaux sans ping Redis à chaque requête.
- Contexte : backend Node/NestJS avec Redis consulté dans le chemin de décision d'écriture.
- Quand l'utiliser : quand plusieurs requêtes concurrentes doivent consulter l'état Redis.
- Quand l'éviter : si Redis n'est pas consulté dans le chemin request/response.
- Avantage :
- réduit fortement le flood de `PING`
- garde un signal d'état suffisamment frais
- Limites / vigilance :
- la fenêtre de cache doit rester courte
- l'état initial doit être explicite et assumé
- Validé le : 10-03-2026
- Contexte technique : NestJS / Redis
### Implémentation (exemple minimal)
```txt
- Mémoriser lastStatus et lastCheck
- Si le dernier check a moins de 5s, retourner l'état en cache
- Sinon exécuter un vrai PING et mettre le cache à jour
- Utiliser un état initial optimiste (`up`) si le produit ne doit pas bloquer les écritures au boot
```
### Checklist
- Cache court documenté
- Pas de ping Redis à chaque requête
- Comportement initial explicite
---
<a id="pattern-quota-redis-atomique"></a>
## Pattern : Quota journalier Redis atomique (INCR + EXPIREAT pipeline)
- Objectif : implémenter un quota d'action journalier sans race condition ni clé TTL orpheline.
- Contexte : quota par utilisateur sur une fenêtre calendaire UTC (posts, requêtes, actions sensibles).
- Quand l'utiliser : toute limite d'action journalière avec Redis disponible.
- Quand l'éviter : si Redis est down — prévoir un mode dégradé permissif (voir implémentation).
- Avantage :
- atomicité garantie : `INCR + EXPIREAT` dans un pipeline `MULTI/EXEC`
- pas de clé sans TTL même en cas de deux requêtes simultanées (`count === 1` concurrent)
- mode dégradé explicite si Redis down (`count === null` → permissif)
- Limites / vigilance :
- compensation `incrBy(-1)` en cas de dépassement — ne couvre pas les crashes entre INCR et la vérification
- la fenêtre expire à minuit UTC, pas à minuit local
- Validé le : 20-03-2026
- Contexte technique : Redis / NestJS / app-alexandrie story 4.2
### Implémentation (exemple minimal)
```typescript
// RedisService — méthode dédiée
async incrWithExpireAt(key: string, expireAtMs: number): Promise<number | null> {
const pipeline = this.client.multi();
pipeline.incr(key);
pipeline.expireAt(key, Math.floor(expireAtMs / 1000));
const results = await pipeline.exec();
return results[0] as number; // valeur post-INCR
}
// Service métier
const today = new Date().toISOString().split('T')[0]; // yyyy-mm-dd UTC
const midnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
const quotaKey = `app:quota:post:${userId}:${today}`;
const count = await redis.incrWithExpireAt(quotaKey, midnight.getTime());
if (count !== null && count > QUOTA_MAX) {
await redis.incrBy(quotaKey, -1); // compensation
throw new HttpException({ error: { code: 'QUOTA_EXCEEDED' } }, HttpStatus.TOO_MANY_REQUESTS);
}
// count === null → Redis down → mode dégradé permissif
```
### Checklist
- [ ] Vérifier le quota AVANT la création en DB
- [ ] `INCR + EXPIREAT` dans un pipeline atomique
- [ ] Mode dégradé permissif si `count === null` (Redis down)
- [ ] Clé nommée `{app}:quota:{action}:{userId}:{yyyy-mm-dd}` (date UTC)
- [ ] Anti-pattern évité : `incrBy` + `setEx` séparés (race condition si count === 1 concurrent)
---
<a id="pattern-fusionner-lastseenat-reconciliation"></a>
## Pattern : Fusionner `lastSeenAt` dans l'update de réconciliation — évite N requêtes DB par requête
- Objectif : éviter deux appels Prisma distincts (réconciliation + lastSeenAt) sur chaque requête authentifiée.
- Contexte : service de réconciliation d'état de session appelé à chaque request via guard ou middleware.
- Quand l'utiliser : dès qu'un `lastSeenAt` est mis à jour systématiquement et qu'un update conditionnel coexiste.
- Avantage : 1 requête DB par requête authentifiée au lieu de 2.
- Validé le : 30-03-2026
- Contexte technique : NestJS / Prisma — app-alexandrie
### Implémentation (exemple minimal)
```typescript
// ❌ 2 requêtes par requête authentifiée
private async reconcileSessionStatus(session) {
if (statusChanged) await prisma.session.update({ data: { status, graceEndsAt } });
}
await prisma.session.update({ data: { lastSeenAt: now } }); // 2ème update systématique
// ✅ 1 requête — lastSeenAt toujours inclus dans le même appel
private async reconcileSessionStatus(session, now = new Date()) {
await prisma.session.update({
data: { lastSeenAt: now, ...(statusChanged && { status, graceEndsAt }) }
});
}
```
---
<a id="pattern-ressource-paire-non-ordonnee"></a>
## Pattern : Ressource « paire non ordonnée » — adresser par les participants, pas par l'id
- Objectif : éviter un endpoint « créer la ressource » séparé avant le premier écrit, et la race condition associée, pour une ressource dont l'identité est dérivable d'une paire d'acteurs.
- Contexte : DM 1:1, match, follow réciproque, partage 1:1 — toute ressource « paire non ordonnée » où l'id est déductible des deux participants.
- Quand l'utiliser : dès que le premier écrit doit pouvoir créer la ressource implicitement (premier message à un utilisateur, première interaction).
- Quand l'éviter : ressource avec identité propre (commande, document) qui existe indépendamment des participants.
- Avantage :
- 1 seul appel API au lieu de 2 (UX + perf)
- pas de race condition : contrainte unique Prisma sur la paire ordonnée applicative (`userA < userB`)
- l'endpoint GET par id reste légitime pour la pagination des fils existants
- Limites / vigilance :
- appliquer les vérifications d'éligibilité/quota AVANT le `findUnique` pour ne pas leak l'existence de la ressource
- l'upsert doit se faire en transaction (création conversation + création message)
- Validé le : 13-05-2026
- Contexte technique : NestJS / Prisma — app-alexandrie story 10.2
### Implémentation (exemple minimal)
```txt
POST /messaging/conversations/with/:targetUserId/messages
```
Le service, après contrôles d'éligibilité/quota :
1. `findUnique({ where: { userAId_userBId: orderPair(currentUser, target) } })`
2. Si absent → `create` la conversation
3. `create` le message dans la même `$transaction`
Adresser par l'identité des deux participants (ou le seul participant cible, l'autre étant `req.user`), jamais par l'id de la ressource. Faire un upsert idempotent côté serveur en transaction.
### Checklist
- [ ] Endpoint d'écriture adressé par les participants, pas par l'id de la ressource
- [ ] Contrainte unique Prisma sur la paire ordonnée applicative (`userA < userB`)
- [ ] Vérifications d'éligibilité/quota AVANT le `findUnique` (anti-leak d'existence)
- [ ] Upsert conversation + message dans une même `$transaction`
- [ ] Endpoint GET par id conservé pour la pagination
---
<a id="pattern-batched-eligibility-decoration"></a>
## Pattern : Batched eligibility / decoration — ne jamais calculer dans `.map(async)`
- Objectif : éviter le N+1 caché quand chaque row d'une liste doit être décorée d'un flag calculé par une logique partagée (éligibilité, ACL, entitlements).
- Contexte : `listX` qui ajoute un flag dérivé (`isReadOnly`, `canEdit`...) calculé par row à partir de queries internes.
- Quand l'utiliser : tout listing qui décore N rows d'un flag dépendant de queries (DB ou cache) par row.
- Quand l'éviter : flag purement synchrone dérivable de la row elle-même (pas de query).
- Avantage :
- passe de N×K requêtes à un nombre constant de queries bulk
- lookup O(1) via `Set` / `Map` au moment de la décoration
- Limites / vigilance :
- le cache (ex : entitlements 60 s) n'amortit pas la première fenêtre froide
- itérer la page **synchroniquement** après le chargement bulk — pas de `await` dans la boucle de décoration
- Validé le : 13-05-2026
- Contexte technique : NestJS / Prisma — app-alexandrie story 10.2 DM messaging
### Anti-pattern (N+1)
```typescript
// ❌ N appels indépendants → N×K requêtes (K = queries internes de getEligibility)
const items = await Promise.all(
page.map(async (row) => {
const eligibility = await this.getEligibilityForExistingConversation(
currentUserId, peerIdFromRow(row),
);
return { ...row, isReadOnly: eligibility.isReadOnly };
}),
);
```
### Pattern (batched)
1. Extraire les IDs cibles : `const peerIds = page.map(peerIdFromRow)`.
2. Charger TOUTES les dépendances en bulk :
- `await entitlements.getEntitlementsForUser(currentUserId)` (1 fois)
- `await Promise.all(peerIds.map(id => entitlements.getEntitlementsForUser(id)))` (parallèle, cache amortit)
- `await prisma.follow.findMany({ where: { OR: [{ followerId: currentUserId, followingId: { in: peerIds } }, { followerId: { in: peerIds }, followingId: currentUserId }] } })` (1 seule requête)
3. Construire des `Set` / `Map` pour lookup O(1).
4. Itérer la page **synchroniquement** et décorer chaque row.
### Règle
Tout flag décoratif calculé par row qui dépend d'une logique partagée DOIT être factorisé en une méthode `compute<Flag>ForBatch(peerIds[])` retournant `Map<peerId, value>`.
---
<a id="pattern-providers-externes-injection-token"></a>
## Pattern : Providers externes via injection par token (jamais `new XxxProvider()`)
- Objectif : rendre les providers externes mockables, swappables par env, et conformes au contrat DI Nest.
- Contexte : service Nest qui dépend d'un système externe (email, billing, push, OAuth IdP, storage, geocoding...).
- Quand l'utiliser : tout provider qui touche un système externe.
- Quand l'éviter : services purement applicatifs (`UsersService`, `CommunityService`) — injectés par classe directement.
- Avantage :
- mock propre en test (`useValue`)
- rotation d'implémentation par env sans rebuild
- arbre DI complet : le module déclare la dépendance
- Limites / vigilance :
- un `new XxxProvider()` au constructeur s'exécute même quand un test injecte un mock du service → tests cassés
- Validé le : 20-05-2026
- Contexte technique : NestJS — app-alexandrie story infra-5
### Anti-pattern
```typescript
// ❌ instancié au constructeur : non mockable, non swappable, contrat DI incomplet
export class AuthService {
private readonly emailProvider = new NoopEmailProvider();
private readonly authProvider = new GoogleAuthProvider();
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
}
// Conséquences observées (avant fix) : AuthService 5 fails, billing 26 fails, notifications 2 fails.
```
### Pattern correct
```typescript
// 1. Token + interface
export const EMAIL_PROVIDER = Symbol('EMAIL_PROVIDER');
export interface EmailProvider {
sendVerificationEmail(email: string, token: string): Promise<void>;
}
// 2. Implémentation @Injectable()
@Injectable()
export class NoopEmailProvider implements EmailProvider { /* ... */ }
// 3. Binding dans le module
@Module({
providers: [
AuthService,
{ provide: EMAIL_PROVIDER, useClass: NoopEmailProvider },
],
})
export class AuthModule {}
// 4. Injection par token
@Injectable()
export class AuthService {
constructor(
@Inject(EMAIL_PROVIDER) private readonly emailProvider: EmailProvider,
) {}
}
// 5. Mock en test
const module = await Test.createTestingModule({
providers: [
AuthService,
{ provide: EMAIL_PROVIDER, useValue: { sendVerificationEmail: jest.fn() } },
],
}).compile();
```
### Checklist
- [ ] Token (`Symbol`) + interface dans un fichier dédié
- [ ] Implémentation `@Injectable()` qui implémente l'interface
- [ ] Binding `{ provide: TOKEN, useClass: Impl }` dans le module
- [ ] Injection via `@Inject(TOKEN)` au constructeur
- [ ] Aucun `new XxxProvider()` dans un service métier