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

14 KiB
Raw Blame History


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)

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

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)

- 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)

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

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)

// ❌ 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)

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)

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


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

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

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