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