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

20 KiB
Raw Blame History

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)

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

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)

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

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() 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

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

// ❌ 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é UI
  • switch exhaustif dans getXConstants

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 de SUPPORTED_X_CODES runtime
  • 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/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é)

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