--- 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. --- ## 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(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é --- ## 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 --- ## 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 { 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) --- ## 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 }) } }); } ``` --- ## 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 --- ## 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 `computeForBatch(peerIds[])` retournant `Map`. --- ## 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; } // 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