# Backend — Patterns : Async > Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/patterns/README.md` pour l'index complet. --- ## Pattern : Exécution asynchrone des tâches longues (queue + outbox light) - Objectif : sortir les opérations longues ou fragiles du chemin request/response. - Contexte : envoi d'emails, appels SaaS, génération de PDF, traitements batch, webhooks sortants. - Quand l'utiliser : dès qu'une opération peut dépasser la latence acceptable ou dépendre d'un service externe. - Quand l'éviter : opérations réellement instantanées et sans dépendances externes. - Avantage : - API plus rapide et plus fiable - Retries maîtrisés - Meilleure résilience aux pannes externes - Limites / vigilance : - Demande une discipline stricte sur l'idempotence - Nécessite une stratégie minimale de dead-letter ou d'alerting - Validé le : 25-01-2026 - Contexte technique : Backend agnostique + DB transactionnelle + worker ### Implémentation (exemple minimal) ```txt - API écrit un job ou event en DB dans la transaction métier - Worker lit les jobs en attente et exécute - Retries avec backoff + compteur - Statut FAILED ou dead-letter + alerte - Idempotence par clé métier ou idempotency key ``` ### Checklist - Job créé dans une transaction (évite les pertes) - Retries et backoff définis - Dead-letter ou statut FAILED visible - Idempotence garantie - Logs corrélés (requestId/traceId) --- ## Pattern : Webhooks sortants robustes et idempotents - Objectif : garantir des intégrations fiables avec des systèmes externes. - Contexte : notifications, synchronisations, événements métier sortants. - Quand l'utiliser : dès qu'un événement doit être transmis à un tiers. - Quand l'éviter : intégrations strictement synchrones et internes. - Avantage : - Tolérance aux pannes réseau - Retries maîtrisés - Observabilité des échecs - Limites / vigilance : - Gestion des retries et du volume - Nécessite une idempotence côté consommateur - Validé le : 25-01-2026 - Contexte technique : Backend + HTTP + worker/queue ### Implémentation (exemple minimal) ```txt - Événement persisté (outbox) en DB - Envoi asynchrone via worker - Retries avec backoff - Signature du payload (HMAC) - Idempotency key dans le header ``` ### Checklist - Payload signé et vérifiable - Retries + backoff définis - Dead-letter ou statut FAILED visible - Idempotence documentée - Logs corrélés (requestId/traceId) --- ## Pattern : Hooks fire-and-forget après création DB critique - Objectif : déclencher des hooks secondaires (mail accusé réception, notification, invalidation cache) après une création DB sans bloquer la réponse HTTP au client. - Contexte : endpoint POST qui crée une ressource en DB et déclenche en cascade des hooks impliquant des appels réseau (Resend, FCM, Redis cache). - Quand l'utiliser : hooks **rapides** (< 1-2 s) qui peuvent vivre dans le même process que la requête HTTP. - Quand l'éviter : tâches lourdes (génération PDF, batch envoi sur 100 destinataires) — utiliser un vrai job queue (BullMQ, pg-boss). - Avantage : - la 201 part dès la création DB (l'AC critique de la route) - chaque hook logge ses propres échecs sans bloquer le caller - `Promise.allSettled` détaché → robustesse même si un hook futur ajoute un comportement async - Limites / vigilance : - dans Next.js 15+, préférer `after()` (cf. `knowledge/backend/patterns/nextjs.md`) qui garantit l'exécution post-réponse même en serverless - `Promise.all` reject au premier échec — `allSettled` attend toutes les promesses - tests : poll DB borné (`waitForX`) plutôt que `setTimeout(50)` (cf. `knowledge/backend/patterns/tests.md`) - Validé le : 30-04-2026 - Contexte technique : Node.js — RL799_V2 ### Implémentation ```typescript // ✅ La 201 part dès la création DB ; les hooks tournent en parallèle const created = await prisma.registration.create({ data }); // Promise.allSettled détaché : ne reject jamais, on capture quand même // au cas où le service de log lui-même bug void Promise.allSettled([ sendAcknowledgmentMail(data.email), notifyObservers(created.id), invalidateCache(`stats:${data.scope}`), ]).catch((err) => { logger.error({ type: 'hooks', event: 'unexpected_error', err: String(err) }); }); return jsonResponse(201, { data: created }); ``` ### Règles d'utilisation 1. **L'AC critique doit être atteint avant** : la création DB doit réussir (await) — c'est le seul résultat que le client attend. 2. **Chaque hook doit logger ses propres échecs** : le service mail doit avoir son propre `logger.error` sur status=failed. Le `.catch()` du `Promise.allSettled` est un filet, pas le canal d'audit primaire. 3. **`Promise.allSettled` (pas `Promise.all`)** : robuste si un hook futur ajoute un comportement asynchrone derrière. 4. **Côté tests** : helper `waitForX` polling-borné plutôt que `setTimeout(N)` arbitraire. --- ## Pattern : Notification fanout fire-and-forget avec filtre grade plancher - Objectif : notifier N destinataires éligibles (filtrage par grade plancher) après une mutation, sans bloquer la réponse HTTP et sans rollback de la création principale si la notif échoue. - Contexte : action métier qui crée une ressource + doit notifier les membres dont le grade ≥ grade plancher de la ressource (`SOIREE_CANCELLED` à tous les membres, `COMMUNICATION_PUBLISHED` aux membres de grade ≥ X, etc.). - Quand l'utiliser : fanout multi-rôles avec filtrage métier sur le profil destinataire. - Quand l'éviter : si la notif est critique (la ressource ne doit pas exister sans notif) — utiliser une transaction. - Avantage : - seuil monotone `gradeRank(member) >= gradeRank(resource)` aligné sur les filtres `list*` consommateurs - exclusion du créateur via `id: { not: userId }` pour éviter de se notifier soi-même - log explicite sur `catch` du fire-and-forget — pas de perte silencieuse - Limites / vigilance : - pas de transaction avec la création principale : best-effort, dégradation acceptable - le `linkUrl` doit être rôle-aware (cf. `knowledge/backend/risques/general.md` risque-notif-linkurl-non-role-aware) - Validé le : 23-04-2026 - Contexte technique : Prisma — RL799_V2 ### Implémentation ```typescript const createResourceNotifications = async (input: { resourceId: string; grade: string; // plancher (seuil monotone) excludeUserId?: string; }): Promise => { const thresholdRank = gradeRank(input.grade); const recipients = await prisma.user.findMany({ where: { isActive: true, role: { in: [...ROLES_ALL_ACTIVE] }, id: input.excludeUserId ? { not: input.excludeUserId } : undefined, profile: { is: {} }, }, select: { id: true, role: true, // pour linkUrl rôle-aware si multi-rôles profile: { select: { grade: true } }, }, }); const eligibleIds = recipients .filter((r) => { const g = r.profile?.grade; if (!g) return false; return gradeRank(g) >= thresholdRank; }) .map((r) => r.id); if (eligibleIds.length === 0) return; await prisma.notification.createMany({ data: eligibleIds.map((recipientId) => ({ type: NotificationType.RESOURCE_CREATED, recipientId, // … linkUrl: ..., // rôle-aware si nécessaire })), }); }; // Côté handler try { const resource = await createResource({ ... }); logAction(userId, 'resource:create', ...); // Fire-and-forget void createResourceNotifications({ resourceId: resource.id, ...minimumDataForNotif, }).catch((err) => { console.error('[resource:create] notification fanout failed:', err); }); return jsonResponse(201, { data: serialize(resource) }); } catch { return errorResponse(500, ...); } ``` ### Pourquoi un seuil monotone `gradeRank(member) >= gradeRank(resource)` = "à partir du grade X", aligné sur les filtres `list*` consommateurs. Évite les sélections non-contiguës (A+M sans C) qui sont pénibles à représenter. --- ## Pattern : Auto-purge côté vue via fenêtre temporelle SQL - Objectif : faire porter la rétention courte par le filtre de lecture plutôt que par un cron de purge réelle, quand une donnée a deux publics avec des besoins de rétention différents. - Contexte : donnée consultée à long terme côté admin/historique mais utile uniquement sur fenêtre courte côté consommateur final (membre lambda). - Quand l'utiliser : 2 publics, rétention courte côté consommateur, rétention longue côté admin, volumétrie raisonnable. - Quand l'éviter : - volumétrie très élevée (millions de rows) — finir par un vrai archivage si le volume explose - RGPD / obligations légales de suppression — il faut **vraiment** supprimer la donnée, pas la masquer - données avec coût de stockage significatif (PDF, blobs, logs verbeux) — purge réelle + archivage externe - Avantage : - pas de cron à écrire, déployer, monitorer - zéro risque de purge destructive : la donnée reste en DB - rétention courte est **déclarative** (paramètre de query), pas cachée dans un job planifié - l'admin conserve l'accès complet via un autre endpoint - Limites / vigilance : - index sur `createdAt` indispensable dès que la table grossit - Validé le : 23-04-2026 - Contexte technique : Prisma / Postgres — RL799_V2 ### Implémentation ```typescript export const listRecentXxxForMember = async ( ...filters, sinceDays = 30, ) => { const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000); return prisma.xxx.findMany({ where: { ...filters, createdAt: { gte: since }, }, orderBy: { createdAt: 'desc' }, }); }; ``` L'admin garde un endpoint distinct sans le filtre temporel pour l'accès historique complet.