Files
_Assistant_Lead_Tech/knowledge/backend/patterns/contracts.md
MaksTinyWorkshop b3417ad77b capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)
Triage du 95_a_capitaliser.md (~75 propositions) :
- 60 entrées intégrées dans knowledge/ (backend, frontend, workflow)
- 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md,
  frontend/patterns/general.md, workflow/patterns/general.md
- 6 doublons rejetés
- Mise à jour des READMEs index pour refléter les nouvelles entrées
- 95_a_capitaliser.md restauré à sa structure initiale
- 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant
- 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI,
  prisma migrate diffs cosmétiques

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:12:44 +02:00

455 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Backend — Patterns : Contracts
domain: backend
bucket: patterns
tags: [contracts, zod, api, error-codes, monorepo]
applies_to: [analysis, implementation, review, architecture]
severity: high
validated_on: 2026-04-07
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)
### 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-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
---
<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)