---
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.md` pour l'index complet.

---

<a id="pattern-contracts-first-zod-infer-no-dto"></a>

## 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/contracts` ou é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
- Validé le : 07-03-2026
- Contexte technique : TypeScript / Zod / NestJS + Expo (React Native) — pattern agnostique framework

### Implémentation (exemple minimal)

```typescript
// 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 dans `HttpException(...)`, le client dans sa fonction `getXxxCopy`. 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>`
- [ ] `ZodValidationPipe` global 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

---

<a id="pattern-contracts-error-codes"></a>

## Pattern : Contracts-First — error codes comme contrat obligatoire

- Objectif : maintenir les codes d'erreur API dans `packages/contracts` pour é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.ts` **avant 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 dans `packages/contracts/src/errors/error-code.ts` en 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

---

<a id="pattern-http-200-payload-metier"></a>
## 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)

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

---

<a id="pattern-idempotence-post-ressource-existante"></a>
## 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 ».

```typescript
// ✅ 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).

---

<a id="pattern-coherence-result-repository"></a>
## 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 `null` sur 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

---

<a id="pattern-api-crypto-variantes-binaire-base64"></a>
## 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 de `decrypt(base64String)` qui fait `Buffer.from(str, 'base64')` → round-trip inutile

---

<a id="pattern-zod-strict-mutations"></a>
## 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`, `isAdmin` injectés par un client malveillant)
  - rejet à 400 avant d'atteindre Prisma → pas de risque de spread accidentel dans `data: parsed.data`
- Limites / vigilance :
  - ne dispense pas de la deuxième ligne de défense : ne JAMAIS spread `parsed.data` directement dans `prisma.update`, construire `data` au champ près
- Validé le : 20-04-2026
- Contexte technique : TypeScript / Zod — RL799_V2

### Implémentation

```typescript
export const updateXxxSchema = z.object({
  name: z.string().min(1).optional(),
  status: z.enum(['active', 'inactive']).optional(),
}).strict();
```

### Combiné avec le repo

```typescript
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

```typescript
test('PATCH .strict() rejette les champs hors-whitelist', async () => {
  const r = await PATCH(makeReq({ name: 'OK', uploadedBy: 'attacker' }));
  expect(r.status).toBe(400);
});
```

---

<a id="pattern-rigidification-zod-2-phases"></a>
## 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()` par `z.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()`.

```ts
// ❌ 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).

---

<a id="pattern-enum-canonique-sous-ensembles-nommes"></a>
## 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)
- Validé le : 21-04-2026
- Contexte technique : TypeScript / Zod — RL799_V2

### Anti-pattern

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

```typescript
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

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

---

<a id="pattern-constantes-variant-fige-selecteur-strict"></a>
## 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

```typescript
// 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é UI
- `switch` exhaustif dans `getXConstants`

### Sélecteur avec narrowing runtime

```typescript
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é

```typescript
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 de `SUPPORTED_X_CODES` runtime
- Fallback silencieux dans le sélecteur (`switch (code) { default: return DEFAULT }`) — throw explicite

---

<a id="pattern-regex-critique-partagee-anti-divergence"></a>
## 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/shared` ou équivalent
  - dérive impossible (ou détectée au build TS) si l'import partagé est possible
- 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
- Validé le : 28-04-2026
- Contexte technique : monorepo TypeScript — RL799_V2

### Implémentation (cas idéal — import partagé)

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

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

---

<a id="pattern-typer-strict-a-la-source"></a>
## 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é comme `string` à 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

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

---

<a id="pattern-audience-prop-templates"></a>
## 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

1. 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).
2. Ajouter les URLs/booleans spécifiques à chaque audience en props **optionnelles** (`visitorRegistrationUrl?`, `unsubscribeUrl?`) pour ne pas casser l'audience historique.
3. Conditionner via un `const isVisitor = audience === 'visitor'` en tête de composant : heading, CTA principal, sections opt-out/désabonnement.
4. Sortir des **chemins de stockage distincts** pour les artefacts persistants (`{tenueDir}/{grade}.pdf` membre vs `{tenueDir}/visitor-{grade}.pdf` visiteur) — évite l'écrasement quand les deux audiences sont servies pour la même entité.
5. Factoriser la construction des props dans un builder commun (`buildConvocationProps({ audience, ... })`) qui applique les règles spécifiques.
6. 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.

---

<a id="pattern-verbe-http-marker-idempotent"></a>
## Pattern : Verbe HTTP pour endpoint « marker » idempotent (set boolean sur ressource existante)

- Objectif : trancher une fois pour toutes `POST` vs `PATCH` sur 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.

---

<a id="pattern-endpoint-distinct-vs-force"></a>
## 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.

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