--- 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. --- ## 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) ### 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 : 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 --- ## 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 - `