--- 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. --- ## 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; // 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` - [ ] `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 --- ## 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 --- ## 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 --- ## 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). --- ## 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 --- ## 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 --- ## 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 = {}; 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); }); ``` --- ## 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(): migration + 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(): rigidification Zod sur ` ### 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). --- ## 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 ``` --- ## 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// types.ts ← SupportedXCode union fermée + SUPPORTED_X_CODES tuple runtime .ts ← Constantes du variant A (typées ) 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 - `