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>
31 KiB
title: Backend — Patterns : Contracts domain: backend bucket: patterns tags: [contracts, zod, api, error-codes, monorepo, idempotence, http-semantics] applies_to: [analysis, implementation, review, architecture] severity: high validated_on: 2026-06-25 source_projects: [app-alexandrie, RL799_V2]
Backend — Patterns : Contracts
Extrait de la base de connaissance Lead_tech. Voir
knowledge/backend/patterns/README.mdpour l'index complet.
Pattern : Contracts-First / Zod-Infer / No-DTO (monorepo TypeScript fullstack)
- Objectif : avoir une seule source de vérité pour les contrats d'interface entre API et client, sans redéfinition manuelle de types.
- Contexte : monorepo TypeScript avec un package partagé (
packages/contractsou équivalent), consommé par le backend et le front/mobile. - Quand l'utiliser : dès qu'une API est consommée par un client TypeScript dans le même repo.
- Quand l'éviter : si le client est externe (autre organisation, autre langage) — dans ce cas, OpenAPI reste la référence.
- Avantage :
- Zéro drift entre contrat et implémentation
- Types TypeScript gratuits via
z.infer<>— aucune réécriture - Changement de contrat = erreur de compilation immédiate côté client
- Mocks de tests alignés automatiquement
- Limites / vigilance :
- Ne pas mettre de logique métier dans
packages/contracts(IO only) - Attention aux dépendances circulaires si le package grossit
- Ne pas mettre de logique métier dans
- Validé le : 07-03-2026
- Contexte technique : TypeScript / Zod / NestJS + Expo (React Native) — pattern agnostique framework
Implémentation (exemple minimal)
// packages/contracts/src/auth/auth.schemas.ts
export const RegisterRequestSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>; // type GRATUIT
// packages/contracts/src/index.ts
export * from './auth/auth.schemas';
export * from './errors/error-code';
// apps/api/src/modules/auth/auth.controller.ts
import type { RegisterRequest } from '@monrepo/contracts';
// + ZodValidationPipe → validation automatique, zéro DTO manuel
// apps/mobile/src/domains/auth/auth.store.ts
import type { RegisterRequest } from '@monrepo/contracts';
// même type, même schéma, zéro duplication
Structure cible du package contracts
packages/contracts/src/
auth/auth.schemas.ts ← request/response auth
users/users.schemas.ts ← request/response users
billing/billing.schemas.ts ← request/response billing (Epic suivant)
errors/error-code.ts ← enum codes d'erreur stables
http/envelopes.ts ← { data, meta } / { error, meta }
index.ts ← re-export tout
Ce qui appartient à contracts
- Schémas Zod request/response
- Types inférés (
z.infer<>) - Codes d'erreur applicatifs stables
- Enums et constantes partagées (ex : liste officielle de sujets/topics)
- Tout texte business affichable dont backend ET client/mail/PDF partagent le contrôle : motif d'erreur, label de statut, message de refus métier doit transiter par le contrat, sous forme de constante
as const(ex :DM_ELIGIBILITY_MESSAGES). Le backend l'utilise dansHttpException(...), le client dans sa fonctiongetXxxCopy. Anti-pattern : deux dictionnaires de strings parallèles (un dans le service Nest, un dans le module mobile) qui divergent au premier changement de wording. Ne s'applique PAS aux textes purement UI (titres de boutons, libellés de formulaire) qui appartiennent au client seul.
Ce qui n'appartient PAS à contracts
- Logique métier
- Modules/services/guards framework (NestJS, etc.)
- State management client (Zustand, Redux, etc.)
Checklist
- Zéro DTO manuel dans l'API — uniquement
z.infer<typeof Schema> ZodValidationPipeglobal ou par endpoint pour la validation d'entrée- Constantes partagées (enums, listes) dans contracts, jamais dupliquées
- Mocks de tests importent les types depuis contracts
Pattern : Contracts-First — error codes comme contrat obligatoire
- Objectif : maintenir les codes d'erreur API dans
packages/contractspour éviter les clients stringly-typed. - Contexte : monorepo TypeScript avec
packages/contracts/src/errors/error-code.ts. - Règle : toute nouvelle erreur API ⇒ ajout obligatoire dans
error-code.tsavant merge, pas après. - Risque si ignoré : clients qui testent des strings hardcodées au lieu d'importer l'enum → drift silencieux.
- Validé le : 09-03-2026
- Contexte technique : TypeScript / NestJS + Expo (React Native)
Checklist
- Nouvel
error.code→ ajout danspackages/contracts/src/errors/error-code.tsen même commit - Clients importent l'enum, pas une string littérale
- PR review : vérifier
error-code.tsà chaque ajout d'endpoint d'erreur
Pattern : Réponse HTTP 200 avec payload métier pour les états d'accès
- Objectif : éviter les codes 4xx pour des états métier normaux qui nécessitent un rendu côté client.
- Contexte : endpoints dont la réponse varie selon les droits ou l'état d'abonnement, sans que l'absence de contenu soit une erreur.
- Quand l'utiliser : paywall, trial read-only, quota soft, état d'accès partiel — quand le client doit décider du rendu.
- Quand l'éviter : accès réellement interdit côté serveur (403), non authentifié (401), endpoint inexistant (404).
- Avantage :
- pas de gestion d'exception côté client mobile pour des états courants
- rendu conditionnel (paywall, teaser, empty) piloté par le payload
- log serveur propre — 4xx réservés aux erreurs techniques/sécurité
- Limites / vigilance :
- ne pas généraliser aux vraies erreurs de sécurité — 401/403/404 gardent leur sémantique HTTP
- Validé le : 20-03-2026
- Contexte technique : NestJS / Expo React Native — app-alexandrie story 4.1
Implémentation (exemple minimal)
// GET /community/forums
// Sans abonnement → 200 + { data: { forums: [], paywallRequired: true }, meta }
// Avec abonnement → 200 + { data: { forums: [...], paywallRequired: false }, meta }
// ❌ Anti-pattern
return res.status(402).json({ error: { code: 'SUBSCRIPTION_REQUIRED' } });
// ✅ Pattern correct
return res.status(200).json({
data: { forums: [], paywallRequired: true },
meta: { total: 0 },
});
Règle
- 4xx = erreur technique ou de sécurité (401 non authentifié, 403 accès interdit, 404 introuvable)
- 200 + flag métier = état métier normal que le client doit interpréter pour le rendu
Pattern : Idempotence POST = retour de la ressource existante
- Objectif : garantir qu'un POST dont l'AC demande l'idempotence produit le même résultat quel que soit le nombre d'appels.
- Contexte : endpoint de création où une duplication est détectable (ressource déjà créée pour le même sujet, ex : demande d'export, déclenchement de job unique). Complément du pattern « HTTP 200 + payload métier » ci-dessus, appliqué au cas duplication.
- Quand l'utiliser : POST déclaré idempotent (retries client, double-tap mobile, rejeu réseau).
- Quand l'éviter : création stricte où une duplication est une vraie erreur métier que l'appelant doit traiter explicitement.
- Validé le : 13-04-2026
- Contexte technique : NestJS / contrat API — app-alexandrie story 9.2
Règle
Un POST idempotent retourne la ressource existante (HTTP 200 / 201) quand une duplication est détectée, sans lever d'erreur. Un 409 CONFLICT est un comportement de blocage, pas d'idempotence : il force l'appelant à gérer un cas d'erreur et casse le contrat « N appels = 1 résultat ».
// ✅ IDEMPOTENT — retourne la ressource existante
if (existing) return this.serialize(existing);
// ❌ NON-IDEMPOTENT — lève une erreur de blocage
if (existing) throw new HttpException({ error: { code: 'ALREADY_EXISTS' } }, 409);
Côté client
Dédupliquer par id avant d'insérer dans la liste/store local, pour éviter les doublons en cas de race condition (deux requêtes parallèles recevant la même ressource).
Pattern : Cohérence du pattern Result dans un repository
- Objectif : garantir une sémantique uniforme du retour d'erreur dans un même fichier repository.
- Contexte : repository utilisant le pattern
{ ok: true; data } | { ok: false }pour certaines fonctions. - Quand l'utiliser : dès qu'un repository a au moins une fonction utilisant le pattern Result.
- Risque si ignoré : retourner
nullsur erreur dans une fonction alors que les voisines retournent{ ok: false }crée une ambiguïté sémantique (null = pas trouvé vs null = erreur) et empêche le service d'adapter sa réponse HTTP (404 vs 500). - Validé le : 03-04-2026
- Contexte technique : TypeScript / Prisma — RL799_V2 story 6A.5
Règle
Quand un repository utilise le pattern { ok: true; data } | { ok: false } pour certaines fonctions, toutes les fonctions du même fichier doivent utiliser le même pattern.
Signal review
catch { return null }dans un repository qui utilise{ ok: false }ailleurs
Pattern : API crypto — variantes binaire et base64
- Objectif : éviter un round-trip base64 coûteux en mémoire quand les données sont déjà en binaire.
- Contexte : fonction crypto qui travaille en base64 pour la sérialisation (stockage DB, transport JSON) mais appelée aussi depuis des lectures fichier (binaire natif).
- Quand l'utiliser : dès qu'une fonction crypto accepte uniquement du base64 mais est appelée avec des données binaires.
- Quand l'éviter : si toutes les sources de données sont déjà en base64.
- Validé le : 08-04-2026
- Contexte technique : Node.js crypto / fichiers — RL799_V2 story 13-7
Règle
Quand une fonction crypto travaille en base64 pour la sérialisation, prévoir une variante *Buffer qui accepte un Buffer natif pour les cas où les données sont déjà en binaire (lecture fichier, stream). Cela évite un double encodage (fichier → base64 → Buffer → déchiffrement).
Signal review
buffer.toString('base64')suivi immédiatement dedecrypt(base64String)qui faitBuffer.from(str, 'base64')→ round-trip inutile
Pattern : Zod .strict() systématique sur les schémas de mutation
- Objectif : bloquer la pollution de champs internes via PATCH/POST/PUT en rejetant tout champ supplémentaire non listé dans le schéma.
- Contexte : tout schéma Zod qui valide un payload de mutation côté API.
- Quand l'utiliser : systématiquement sur tous les schémas de mutation.
- Quand l'éviter : schémas de réponse (où l'API est l'émetteur) ou schémas d'enrichissement intentionnel.
- Avantage :
- première ligne de défense contre la pollution de payload (
uploadedBy,createdAt,isAdmininjectés par un client malveillant) - rejet à 400 avant d'atteindre Prisma → pas de risque de spread accidentel dans
data: parsed.data
- première ligne de défense contre la pollution de payload (
- Limites / vigilance :
- ne dispense pas de la deuxième ligne de défense : ne JAMAIS spread
parsed.datadirectement dansprisma.update, construiredataau champ près
- ne dispense pas de la deuxième ligne de défense : ne JAMAIS spread
- Validé le : 20-04-2026
- Contexte technique : TypeScript / Zod — RL799_V2
Implémentation
export const updateXxxSchema = z.object({
name: z.string().min(1).optional(),
status: z.enum(['active', 'inactive']).optional(),
}).strict();
Combiné avec le repo
const data: Partial<UpdateXxxData> = {};
if (parsed.data.name !== undefined) data.name = parsed.data.name;
if (parsed.data.status !== undefined) data.status = parsed.data.status;
// …jamais `data: parsed.data` brut
Test à ajouter
test('PATCH .strict() rejette les champs hors-whitelist', async () => {
const r = await PATCH(makeReq({ name: 'OK', uploadedBy: 'attacker' }));
expect(r.status).toBe(400);
});
Pattern : Rigidification Zod en 2 phases (données d'abord, schémas ensuite)
- Objectif : rigidifier un schéma Zod artificiellement laxiste sans casser la suite de tests en cascade.
- Contexte : schéma qui accepte une forme large (
z.string().min(1).max(128)) pour compenser une donnée hétérogène en base (slugs + UUIDs cohabitent), avant d'avoir uniformisé la donnée. - Quand l'utiliser : tout chantier de rigidification (
.uuid(),.email(),.enum()) sur un champ dont la base contient encore l'ancien format. - Quand l'éviter : si la donnée est déjà uniforme — rigidifier directement.
- Avantage :
- diagnostic séparé : si le commit 2 casse un test, on sait que c'est la rigidification, pas la migration
- rollback granulaire : on peut rollback la rigidification sans reperdre la migration
- revue plus lisible : un reviewer valide indépendamment "migration correcte" puis "rigidification sûre"
- Limites / vigilance :
- tentation de tout faire d'un coup → écarter
- Validé le : 24-04-2026
- Contexte technique : Zod / Prisma — RL799_V2
Séquence obligatoire (2 commits séparés)
Phase 1 — Normalisation des données :
- Migrer la base (seed, fixtures, lignes legacy via
prisma migrate) - Adapter tous les consommateurs qui référencent l'ancien format (tests, helpers E2E, scripts admin)
- Le schéma Zod reste laxiste à ce stade — il accepte les deux formats pendant la transition
- Ajouter un test d'invariant qui valide que la base ne contient plus que le format cible
- Commit :
feat(<domaine>): migration <X> + adaptation tests
Phase 2 — Rigidification du schéma :
- Remplacer
z.string()parz.uuid()/z.email()/z.enum()sur les champs concernés - Adapter les quelques tests qui reposaient sur l'ancienne sémantique laxiste
- Vérifier par grep final qu'aucun autre schéma n'a le même pattern laxiste oublié
- Commit :
feat(<domaine>): rigidification Zod sur <X>
Signaux de dérive
- Schéma avec un commentaire "accepte toute chaîne pour compatibilité avec X" → dette à rigidifier dès que X est migré
.min(1).max(128)sur un champ conceptuellement UUID/email/enum → forme laxiste en attente de rigidification
Sous-règle — .optional() uniquement si le producteur peut réellement omettre le champ
.optional() sur un schéma Zod doit refléter la réalité du producteur, jamais servir de filet pour la rétrocompatibilité des fixtures de tests. Si le serveur projette toujours le champ (valeur par défaut comprise), le schéma ne doit PAS le marquer .optional().
// ❌ Trompeur : le serveur projette toujours ces champs
visibilityStatus: VisibilityStatusSchema.optional(),
placeholderLabel: z.string().nullable().optional(),
// ✅ Honnête : reflète ce que le serveur retourne
visibilityStatus: VisibilityStatusSchema,
placeholderLabel: z.string().nullable(),
Pourquoi : un .optional() permissif infère T | undefined → on est forcé d'écrire x ?? 'DEFAULT' partout côté client inutilement ; et il masque les drifts de fixtures (un objet construit en omettant le champ, ou avec un nom de champ différent comme isAutoHidden: false au lieu de visibilityStatus: 'VISIBLE', passe le type-check). Méthode : pour chaque champ, demander « le producteur peut-il LÉGITIMEMENT omettre ce champ ? » — si non, pas de .optional() ; si oui (rétrocompat lecture seule), documenter la raison en commentaire. Sur app-alexandrie story 8.2, durcir 3 schémas a révélé un champ fantôme isAutoHidden côté store mobile (14 erreurs TS en cascade).
Pattern : Enum canonique + sous-ensembles nommés (vs flags par usage)
- Objectif : factoriser les règles métier sur une enum partagée par plusieurs domaines fonctionnels sans alourdir l'enum elle-même de flags.
- Contexte : enum (rôles, statuts, types) qui sert plusieurs usages avec des règles différentes (annuaire, pointage rituel, mandats administratifs).
- Quand l'utiliser : dès qu'un même
enum.filter(r => …)apparaît à plusieurs endroits avec une règle métier explicite. - Quand l'éviter : si le filtre n'apparaît qu'une fois — laisser inline, l'extraction est prématurée.
- Avantage :
- chaque sous-ensemble a un nom métier explicite — le lecteur comprend sans chercher
- les règles sont localisées au point de définition, pas éparpillées en flags
- ajouter un usage = ajouter un sous-ensemble, pas modifier la structure de l'enum
- Limites / vigilance :
- les sous-ensembles doivent être typés
readonly Role[]pour bénéficier du narrowing - propagation côté front ET côté Zod backend (defense-in-depth)
- les sous-ensembles doivent être typés
- Validé le : 21-04-2026
- Contexte technique : TypeScript / Zod — RL799_V2
Anti-pattern
// ❌ Flag par usage, multipliable, illisible
export const OFFICER_ROLES = [
{ code: 'venerable', label: '...', isRitual: true, isAdmin: true },
{ code: 'archiviste', label: '...', isRitual: false, isAdmin: true },
// … 12 rôles × 3-4 flags
];
Pattern correct
export const OFFICER_ROLES = [
'venerable', 'premier-surveillant', /* … */ 'archiviste',
] as const;
type OfficerRole = (typeof OFFICER_ROLES)[number];
/** Officiers avec fonction rituelle pendant la tenue (pointage). */
export const RITUAL_OFFICER_ROLES: readonly OfficerRole[] =
OFFICER_ROLES.filter((role) => role !== 'archiviste');
/** Officiers éligibles à un mandat administratif. */
export const MANDATABLE_OFFICER_ROLES = OFFICER_ROLES;
Propagation Zod backend
// Le sous-ensemble est utilisé côté front ET côté Zod
export const tenueOfficerAssignmentSchema = z.object({
role: z.enum(RITUAL_OFFICER_ROLES as readonly [OfficerRole, ...OfficerRole[]]),
});
// → POST avec role: 'archiviste' = 400, sans duplication de la règle
Pattern : Constantes par variant figé + sélecteur enum strict
- Objectif : figer dans le code des règles ou textes versionnés via Git tout en sélectionnant l'implémentation à l'exécution via un champ DB (tenant, pays, juridiction).
- Contexte : règles métier figées (CGV par juridiction, formats de facture par pays, libellés réglementaires par régulateur) qui doivent rester typées strictement et versionnées via Git, mais sélectionnées au runtime.
- Quand l'utiliser : préparation multi-variant avant d'avoir réellement plusieurs implémentations, OU cas où on veut des diffs visibles dans la PR à chaque modification (texte à autorité).
- Quand l'éviter : règles métier admin-éditables runtime — ces données appartiennent à la DB, pas au code.
- Avantage :
- une seule source de vérité par variant, typée strictement
- étendre l'union à
'A' | 'B'propage automatiquement la nouvelle option (Zod, UI, tests) - diff visible dans la PR à chaque modification — review éclate sur un mot changé
- Limites / vigilance :
- throw explicite dans le sélecteur (pas de fallback silencieux) — un drift DB doit échouer fort
- pour du texte à autorité, préférer
expect(X).toBe(...)àtoMatchSnapshot— diff visible vs snapshot file rarement lu
- Validé le : 28-04-2026
- Contexte technique : TypeScript / Zod — RL799_V2
Structure type
packages/shared/src/<domain>/
types.ts ← SupportedXCode union fermée + SUPPORTED_X_CODES tuple runtime
<variantA>.ts ← Constantes du variant A (typées <Constants>)
index.ts ← getXConstants(code) + isSupportedXCode + UnsupportedXError
Source de vérité unique pour le code
// types.ts
export type SupportedRiteCode = 'REAA';
export const SUPPORTED_RITE_CODES = ['REAA'] as const
satisfies readonly SupportedRiteCode[];
SUPPORTED_RITE_CODES est consommé partout :
z.enum([...SUPPORTED_RITE_CODES] as [...])côté validation<select v-for="code in SUPPORTED_RITE_CODES">côté UIswitchexhaustif dansgetXConstants
Sélecteur avec narrowing runtime
export class UnsupportedRiteError extends Error { /* … */ }
export const isSupportedRiteCode = (v: string): v is SupportedRiteCode =>
(SUPPORTED_RITE_CODES as readonly string[]).includes(v);
export const getRitualConstants = (code: SupportedRiteCode): RitualConstants => {
switch (code) {
case 'REAA': return REAA_RITUAL;
}
throw new UnsupportedRiteError(code); // garde-fou DB drift
};
Côté service backend, assertSupportedX(record.code) AVANT d'exposer dans le DTO public — protège contre une row DB qui aurait drift.
Tests : assertions explicites pour texte à autorité
expect(X.formule).toBe('chaîne exacte'); // diff visible en review
// Avec glyphes Unicode à risque de swap (ex : ' U+0027 vs ’ U+2019)
expect([...X.formule].map(c => c.codePointAt(0)!)).toEqual([0x41, 0x2234, /* … */]);
expect(X.formule).not.toMatch(/'/); // anti-régression typographique
Anti-patterns
- Stocker le texte figé en DB "pour pouvoir l'éditer plus tard" — si le texte est versionné, il appartient au code
- Hardcoder le code variant dans la validation UI (
if (code === 'REAA')) — toujours dériver deSUPPORTED_X_CODESruntime - Fallback silencieux dans le sélecteur (
switch (code) { default: return DEFAULT }) — throw explicite
Pattern : Regex critique partagée serveur ↔ client (anti-divergence)
- Objectif : éviter qu'une règle de validation critique (regex anti open-redirect, format de slug) ne dérive entre serveur (Zod) et client (composant, store, Service Worker).
- Contexte : règle de sécurité ou d'intégrité qui doit s'appliquer identiquement des deux côtés.
- Quand l'utiliser : règle où une divergence côté un seul des deux mène à un trou (anti open-redirect, anti SQL injection visible client-side, format de path/URL).
- Quand l'éviter : règle UX uniquement (pattern d'email pour autocomplétion live).
- Avantage :
- une seule source de vérité —
packages/sharedou équivalent - dérive impossible (ou détectée au build TS) si l'import partagé est possible
- une seule source de vérité —
- Limites / vigilance :
- si le client ne peut PAS importer le package partagé (cas Service Worker en mode
injectManifest), DUPLIQUER avec un commentaire⚠️ DOIT correspondre à <chemin>+ un test croisé qui vérifie l'alignement string-wise
- si le client ne peut PAS importer le package partagé (cas Service Worker en mode
- Validé le : 28-04-2026
- Contexte technique : monorepo TypeScript — RL799_V2
Implémentation (cas idéal — import partagé)
// packages/shared/src/dto/push.ts (source de vérité)
/**
* Regex unique anti open-redirect : démarre par '/' simple (pas '//'),
* caractères alphanum + '/_-?&=%.', pas de ':' (bloque 'javascript:').
*/
export const INTERNAL_PATH_REGEX = /^\/(?!\/)[a-zA-Z0-9/_\-?&=%.]*$/;
export const pushPayloadSchema = z.object({
linkUrl: z.string().regex(INTERNAL_PATH_REGEX).optional(),
});
Cas duplication contrôlée (SW mode injectManifest)
// apps/frontend/src/sw-helpers.ts
/**
* ⚠️ DOIT correspondre à INTERNAL_PATH_REGEX de packages/shared/src/dto/push.ts.
* Le SW (mode injectManifest) ne peut pas importer le package partagé directement.
* Test croisé : apps/frontend/src/__tests__/regex-alignment.test.ts
*/
const INTERNAL_PATH_REGEX = /^\/(?!\/)[a-zA-Z0-9/_\-?&=%.]*$/;
Checklist
- Une seule source de vérité, idéalement dans
packages/shared - Si duplication forcée : commentaire
⚠️ DOIT correspondre à <chemin>des deux côtés - Test croisé qui assert l'alignement string-wise des deux regex
- JSDoc qui rappelle que c'est un contrat de cohérence (revue obligatoire si modif)
Pattern : Typer strict à la source, pas au call-site
- Objectif : faire propager automatiquement le typage strict d'un symbole contraint à tous ses consommateurs, et attraper les drifts au compilateur là où ils naissent.
- Contexte : symbole sémantiquement contraint (enum fermé, union de littéraux, identifiant typé :
UserRole,NotificationType,SoireeStatus) déclaré commestringà la source. - Quand l'utiliser : dès qu'une constante, un retour de helper ou un champ DTO porte une valeur d'un type contraint.
- Quand l'éviter : à la frontière externe (entrée HTTP, payload JSON brut, requête SQL raw) où la valeur est forcément
unknown/string— on cast explicitement après validation ; ou quand le type strict crée une dépendance circulaire (rare). - Validé le : 05-05-2026
- Contexte technique : TypeScript — RL799_V2 (chantier durcissement UserRole)
Règle
Tout symbole sémantiquement contraint doit être déclaré avec son type le plus strict à la source de définition, pas au call-site qui en a besoin. Sinon le caller doit caster (as UserRole) et le bug ne sort qu'au moment où un code aval impose le typage strict — pas à la source du drift.
Exemples : Set<UserRole> plutôt que Set<string> pour les constantes RBAC ; retour role: UserRole plutôt que role: string pour les helpers d'auth ; status: SoireeStatus plutôt que status: string dans les DTOs.
Anti-pattern vs pattern
// ❌ Drift silencieux + cast à chaque call-site
export const ROLES_ADMIN: ReadonlySet<string> = new Set(['admin']);
if (ROLES_ADMIN.has(auth.role as UserRole)) { /* cast nécessaire ailleurs */ }
// ✅ Type fort propagé partout
export const ROLES_ADMIN: ReadonlySet<UserRole> = new Set<UserRole>(['admin']);
if (ROLES_ADMIN.has(auth.role)) { /* OK si auth.role: UserRole */ }
Bénéfice mesurable
Une seule modification à la source propage le typage strict à tous les call-sites. Sur RL799_V2, typer 15 constantes ROLES_* en Set<UserRole> a fait gagner le typage strict à 9 fichiers consommateurs et révélé un bug latent (canPublishCommunicationType qui recevait auth.role: string), résolu structurellement.
Pattern : Prop audience pour templates mail/PDF multi-cibles
- Objectif : servir plusieurs audiences (membre, visiteur…) depuis une seule source de vérité de template, sans dupliquer le fichier et donc sans drift entre versions.
- Contexte : template de rendu (mail HTML, PDF) qu'on étend pour une 2ᵉ audience alors que < 40 % du contenu diverge.
- Quand l'utiliser : tant que la divergence de contenu reste faible (< ~40 %) — un changement d'identité, signature, format de date propage alors automatiquement à toutes les audiences.
- Quand l'éviter : au-dessus de ~40 % de divergence — dupliquer le template et extraire un partial commun devient plus maintenable que des conditions dispersées.
- Validé le : 13-05-2026
- Contexte technique : React Email / PDF templates / NestJS — RL799_V2 (chantier convocation visiteurs)
Règle
- Ajouter une prop
audience: 'audienceA' | 'audienceB'à la source du template. Défaut = audience historique (rétrocompat : appelants existants sans la prop rendent la version originale). - Ajouter les URLs/booleans spécifiques à chaque audience en props optionnelles (
visitorRegistrationUrl?,unsubscribeUrl?) pour ne pas casser l'audience historique. - Conditionner via un
const isVisitor = audience === 'visitor'en tête de composant : heading, CTA principal, sections opt-out/désabonnement. - Sortir des chemins de stockage distincts pour les artefacts persistants (
{tenueDir}/{grade}.pdfmembre vs{tenueDir}/visitor-{grade}.pdfvisiteur) — évite l'écrasement quand les deux audiences sont servies pour la même entité. - Factoriser la construction des props dans un builder commun (
buildConvocationProps({ audience, ... })) qui applique les règles spécifiques. - Tests : assertions ciblées par audience dans le même fichier, sections délimitées, avec assertions positives (présence) ET négatives (absence des éléments réservés à l'autre audience).
Trade-off
Compter la ligne de divergence vs la ligne de partage avant d'appliquer. Sous ~40 %, la prop audience tient ; au-dessus, dupliquer + extraire un partial.
Pattern : Verbe HTTP pour endpoint « marker » idempotent (set boolean sur ressource existante)
- Objectif : trancher une fois pour toutes
POSTvsPATCHsur un endpoint qui set un champ booléen sur une ressource déjà créée (ex :POST/PATCH /users/me/onboarding/complete). - Contexte : endpoint « marker » qui passe un champ existant
false → true(User.onboardingCompleted). - Quand l'utiliser : dès que l'action met à jour un champ d'une row déjà existante.
- Quand l'éviter : action qui crée une nouvelle ressource, déclenche un side-effect métier complexe (envoi email, paiement) ou n'a pas de mapping naturel sur une ressource →
POST. - Validé le : 28-05-2026
- Contexte technique : NestJS / contrat API — app-alexandrie (review IA-v2.7)
Règle
Critère de décision : « est-ce que j'update un champ d'une row existante ? » → PATCH (mise à jour partielle, sémantique REST). POST réservé à la création / au side-effect métier.
Bonus cohérence : si le controller a déjà PATCH /me/handle, PATCH /me/topics, aligner PATCH /me/onboarding/complete plutôt qu'introduire un POST exotique au milieu.
Réponse : 204 No Content quand il n'y a pas de payload de retour utile.
Pattern : Endpoint distinct pour intention divergente (vs paramètre force)
- Objectif : exposer une opération qui viole un invariant protecteur d'un endpoint existant sans polluer cet endpoint d'un paramètre
force/bypass. - Contexte : endpoint REST avec un invariant protégeant l'utilisateur d'une opération accidentelle (rétrogradation de statut, suppression silencieuse), et besoin légitime d'exposer l'opération inverse.
- Quand l'utiliser : dès qu'on est tenté d'ajouter un param
force/skipChecks/admin/bypassà un endpoint pour contourner son propre invariant. - Quand l'éviter : si l'invariant n'existe pas (l'endpoint accepte déjà nativement les deux intentions).
- Validé le : 29-05-2026
- Contexte technique : NestJS / contracts Zod — app-alexandrie (ux-cleanup-7)
Règle
Un paramètre force oblige le client à comprendre qu'il bypass un invariant interne, la doc à expliquer « pourquoi force ? », le service à porter un if (payload.force), et casse la lisibilité des audit logs. Préférer un endpoint dédié dont le verbe HTTP porte la sémantique.
// ❌ Anti-pattern : param `force` qui pollue la sémantique
POST /content/:id/consumption { state: 'NOT_STARTED', force: true }
// ✅ Pattern : endpoint dédié à l'intention « reset »
POST /content/:id/consumption { state: 'COMPLETED' } // markConsumed — idempotent, jamais dégrade
DELETE /content/:id/consumption // resetConsumption — volontaire, dégrade explicitement
Bénéfices : le verbe HTTP porte la sémantique destructive ; le service expose 2 méthodes distinctes (markConsumed, resetConsumption) avec invariants préservés ; les audit logs distinguent immédiatement les 2 opérations ; les contracts Zod restent propres (pas de discriminated union artificielle). L'effort marginal (5-10 lignes) est compensé par une clarté permanente.