---
title: Backend — Risques & vigilance : Contracts
domain: backend
bucket: risques
tags: [contracts, zod, validation, error-codes, requestid]
applies_to: [analysis, implementation, review, debug]
severity: high
validated_on: 2026-04-07
source_projects: [app-alexandrie, RL799_V2]
---

# Backend — Risques & vigilance : Contracts

> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/risques/README.md` pour l'index complet.

---

<a id="risque-contrats-api-implicites"></a>
## Contrats API implicites (validation faible ou absente)

### Risques

- Entrées non validées → erreurs bizarres / vulnérabilités
- Changements qui cassent le front et les intégrations

### Symptômes

- 500 sur erreurs utilisateur
- Incohérences de format de réponse
- "Ça marche en staging, pas en prod" (données réelles)

### Bonnes pratiques / mitigations

- Schémas (OpenAPI/JSON Schema) + validation serveur
- Formats de réponse cohérents
- Versionner/éviter breaking changes

---

<a id="risque-erreurs-non-standardisees"></a>
## Erreurs non standardisées (4xx/5xx incohérents)

### Risques

- Front et automatisations impossibles à rendre robustes
- Debug long (pas de codes internes, pas de corrélation)

### Symptômes

- Clients qui "retry" sur des 4xx
- Messages techniques exposés aux utilisateurs
- Logs inexploitables

### Bonnes pratiques / mitigations

- Mapping HTTP standard + format d'erreur stable
- Codes internes d'erreurs applicatives
- requestId/traceId partout

---

<a id="risque-duplication-constantes-contracts"></a>
## Duplication silencieuse de constantes partagées (contracts) via fichier orphelin

### Risques

- Deux sources de vérité qui divergent silencieusement (ex : topics officiels, enums métier, slugs)
- Bug non détecté par TypeScript si la duplication est dans un fichier non importé (code mort)

### Symptômes

- Incohérences entre API et client sur des listes/enums "censées être partagées"
- "Ça marche chez moi" selon l'endroit où la constante est importée
- Un fichier de config existe dans `apps/*` mais n'est jamais importé/greffé au runtime

### Bonnes pratiques / mitigations

- Toute constante partagée vit dans `packages/contracts/src/` et est importée depuis là (jamais recopiée dans `apps/*`)
- En review : repérer les fichiers "config/constants" ajoutés dans `apps/*` sur des domaines déjà couverts par `contracts`
- (Optionnel) Outillage : intégrer une étape de détection de code mort / exports inutilisés au CI si ça devient récurrent

---

<a id="risque-contracts-schema-orphelin"></a>
## Contracts : schema orphelin / type de retour désynchronisé

### Risques

- Un `RequestSchema` défini dans `packages/contracts` mais jamais importé dans le controller ni le service mobile → dead code silencieux qui crée une fausse confiance
- Un type de retour inline (`string` brut) à la place du type contracts → désynchronisation silencieuse entre contrat et implémentation

### Symptômes

- `grep` du nom du schema ne trouve aucun `import` en dehors de sa définition
- Service retourne `Promise<{ status: string }>` au lieu de `Promise<CurationResponse>` — le `status` n'est pas validé comme `CurationStatus`
- Endpoints `POST /action` sans body ayant un schema `{ pathParam: string }` — le param vient du path, pas du body

### Bonnes pratiques / mitigations

À chaque story qui ajoute des schemas dans `packages/contracts`, vérifier en review :

1. Chaque `RequestSchema` est utilisé dans un `ZodValidationPipe` (API) ou importé dans le service mobile.
2. Les `ResponseSchema` correspondent au type de retour typé du service (`Promise<TheType>`, pas un type inline).
3. Les endpoints sans body (`POST /action`) définissent `z.object({})` ou omettent le body schema — ne jamais placer les path params dans le body schema.

```typescript
// ❌ Anti-pattern — type inline, status non typé
async showcaseThread(...): Promise<{ threadId: string; status: string }> { ... }

// ✅ Pattern correct — type contracts importé
import type { CurationResponse } from '@app-alexandrie/contracts';
async showcaseThread(...): Promise<CurationResponse> { ... }
```

- Contexte technique : NestJS / Zod / contracts-first — app-alexandrie 23-03-2026

---

<a id="risque-code-erreur-generique-409"></a>
## Code d'erreur générique sur statut HTTP sémantique (409 CONFLICT)

### Risques

- Utiliser `VALIDATION_ERROR` ou `INTERNAL_ERROR` sur un 409 rend les erreurs indistinguables côté client et monitoring
- Les clients (mobile, monitoring, tests) ne peuvent pas brancher une logique conditionnelle sans un code sémantique

### Symptômes

- Tous les conflits métier remontent le même code → impossible de distinguer "alias déjà résolu" de "handle déjà pris"
- Tests forcés à matcher le message texte au lieu du code → fragiles

### Bonnes pratiques / mitigations

Chaque scénario métier distinct doit avoir son propre code dans `error-code.ts` :

```typescript
// ❌ Anti-pattern — code générique sur 409
throw new ConflictException({ error: { code: 'VALIDATION_ERROR', message: '...' } });

// ✅ Correct — code sémantique spécifique
throw new ConflictException({ error: { code: 'ALIAS_ALREADY_RESOLVED', message: '...' } });
throw new ConflictException({ error: { code: 'HANDLE_ALREADY_TAKEN', message: '...' } });
```

- **Règle** : 1 scénario métier distinct = 1 code d'erreur distinct
- **Checklist review** : tout 409/422 doit avoir un code dans `error-code.ts`, jamais `VALIDATION_ERROR` ou `INTERNAL_ERROR`

- Contexte technique : NestJS / error-code.ts — app-alexandrie 24-03-2026

---

<a id="risque-forbidden-pour-validation"></a>
## `ForbiddenException` (403) utilisé pour des erreurs de validation

### Risques

- Les clients qui filtrent par HTTP 400 manquent les erreurs de validation lancées en 403
- Sémantique API incorrecte → comportements clients imprévisibles

### Symptômes

- `ForbiddenException` lancée pour des tags invalides, des formats incorrects, des liens HTTP
- Clients API qui ignorent ces erreurs ou les traitent comme des refus d'accès

### Bonnes pratiques / mitigations

Tableau de correspondance :

| Cas | Exception correcte | Code HTTP |
|---|---|---|
| Tags invalides, contenu trop long, format incorrect | `BadRequestException` | 400 |
| Accès refusé explicitement (accès forum, trial read-only) | `ForbiddenException` | 403 |
| Quota dépassé | `HttpException(429)` via `HttpStatus.TOO_MANY_REQUESTS` | 429 |

- **Règle** : HTTP 403 = "tu n'as pas le droit d'effectuer cette action". HTTP 400 = "ta requête est mal formée".
- Contexte technique : NestJS / HTTP — 20-03-2026

---

<a id="risque-process-env-direct-service"></a>
## Feature flags / config : lecture directe de `process.env` dans les services ou helpers métier

### Risques

- Tests verts malgré une dépendance implicite à l'état global du process
- La validation Zod de l'env (ConfigService) existe mais est contournée au runtime via un helper non injecté
- Story conforme "pas de process.env direct en service métier" mais violation dans un helper utilisé par le service

### Symptômes

- `process.env.FEATURE_FLAG_X` dans un helper métier plutôt que dans un module ConfigService
- Tests passent mais comportement diverge selon l'env du process

### Bonnes pratiques / mitigations

- Ne jamais lire `process.env` directement dans les services ni les helpers métier.
- Injecter `ConfigService` (NestJS) et centraliser la lecture via une fonction pure recevant la config injectée.
- **Checklist review** : rechercher `process.env` dans `src/` hors `config/` ou `main.ts` — tout hit est suspect.

- Contexte technique : NestJS / ConfigService — 30-03-2026

---

<a id="risque-statut-metier-non-propage-contrat"></a>
## Nouveau statut métier non propagé dans le contrat partagé

### Risques

- Un repository ou service crée une nouvelle valeur pour un champ enum-like (ex: `'absent_auto'` pour un champ `status: String`) sans la déclarer dans le type DTO ni le schema Zod du package shared
- Le champ Prisma étant un `String` sans contrainte DB, l'écriture passe, mais toute lecture avec validation Zod rejetterait la donnée

### Symptômes

- String literal non présent dans le type union ou le schema Zod du DTO correspondant
- `catch {}` vide dans les repositories qui avalent les erreurs de validation sans log

### Bonnes pratiques / mitigations

Quand un repository ou service crée une nouvelle valeur pour un champ enum-like :
1. Ajouter la valeur au type DTO dans `packages/shared` (ou équivalent)
2. Ajouter la valeur au schema de validation Zod correspondant
3. Vérifier que les endpoints de lecture qui parsent ces données acceptent la nouvelle valeur

- Contexte technique : Zod / contrats partagés — RL799_V2 03-04-2026

---

<a id="risque-zod-email-tolowercase-trim"></a>
## Bug Zod 4 — `z.string().email().toLowerCase().trim()` rejette les emails à trim

### Risques

- Le pattern `z.string().email().toLowerCase().trim()` ne fait **pas** ce qu'il prétend en Zod 4 : `.email()` est une assertion qui valide le format **brut**, **avant** que les transforms `.toLowerCase()` / `.trim()` s'appliquent
- Un email avec espace trailing (`"BOB@X.FR "`) est rejeté `Invalid email` au lieu d'être trim+lower

### Symptômes

- Test fixture `BOB@X.FR ` (trailing space) → 400 alors que l'intention est `bob@x.fr`
- Pattern présent dans plusieurs schémas du projet (`visitorProfileLookupSchema`, `tenueVisitorCreateSchema`, etc.)

### Bonnes pratiques / mitigations

```typescript
// ❌ Pattern legacy faux (Zod 4) — assertion AVANT transforms
const emailSchema = z.string().email().max(254).toLowerCase().trim();

// ✅ Pattern correct : trim/lower AVANT email assertion via pipe
const emailSchema = z
  .string()
  .trim()
  .toLowerCase()
  .pipe(z.string().email().max(254));
```

`.pipe()` chaîne deux schémas — le premier transforme (trim+lower), le second valide (email+max). L'ordre devient explicite et l'assertion est appliquée après normalisation.

**Tests à ajouter** : `BOB@X.FR ` (trailing space) → `bob@x.fr`, `  ALICE@TEST.FR` (leading + casse) → `alice@test.fr`. Si le schéma rejette `Invalid email`, le bug est présent.

**À auditer projet-wide** : grep tous les schémas avec ce pattern (`.email().toLowerCase().trim()`) et migrer en `.pipe()`.

- Contexte technique : Zod 4 — RL799_V2 01-05-2026

---

<a id="risque-ac-affichage-champ-contract-zod"></a>
## AC d'affichage livré « vert » mais champ absent du contract Zod

### Risques

- Un AC métier dit « afficher / truncate / preview de [champ] dans [carte/écran] » mais le champ n'est jamais ajouté au schéma Zod public correspondant → le user ne le voit jamais
- Le service backend peut même charger le champ depuis la DB (`select: { bio: true }`) puis le jeter au mapping de réponse → invisible
- Ni le typage ni les tests unit ne détectent l'absence : la code review est le seul filet

### Symptômes

- AC d'affichage livré « tout vert » (tests/typecheck/lint passent) mais l'écran ne montre rien
- Variante : champ rendu côté UI mais jamais transmis par l'API → `undefined`/`null` silencieux à l'écran

### Bonnes pratiques / mitigations

Quand un AC mentionne « afficher / truncate / preview de [champ] dans [carte/écran] », vérifier la chaîne complète :

1. **Le champ existe dans le schéma Zod public** (ex : AC « carte annuaire affiche bio truncate 80 char » → `DirectoryUserSchema` doit avoir `bio: z.string().nullable()`).
2. **Le service backend l'expose dans le mapping de réponse** (pas seulement dans le `select` Prisma).
3. **Le composant UI lit le champ.**

Le contrat est la barrière minimale : si le champ n'y est pas, l'AC ne peut pas être satisfait.

- Contexte technique : NestJS / Zod / contracts-first — app-alexandrie 28-05-2026

---

<a id="risque-schema-par-audience-pas-par-entite"></a>
## Schéma par audience, pas par entité : vue admin réutilise le schéma public

### Risques

- Une vue ADMIN/back-office réutilise (ou `extend`) le schéma de la vue PUBLIQUE/utilisateur de la même entité → elle hérite d'un schéma qui masque délibérément les champs de gestion
- L'admin ne peut pas distinguer les états (ex : leçons DRAFT vs PUBLISHED) car le contrat ampute l'info structurante (`status`, `body`, timestamps, flags internes)
- Le service inclut correctement les données (pas de filtre status) mais le CONTRAT les supprime — bug invisible au typecheck et au test

### Symptômes

- Un détail admin `extend`/réutilise un schéma existant qui est en réalité la variante de rendu front
- Un test qui ne peut asserter que la **présence** d'un élément, jamais son **état** (le champ d'état n'existe pas dans le schéma)
- AC « prévisualiser avant publication » / « back-office » non satisfait alors que tout est vert

### Bonnes pratiques / mitigations

- Une vue admin/back-office et une vue publique de la MÊME entité ne partagent PAS le schéma de réponse par défaut : l'admin a besoin des champs de gestion (status, body brut, timestamps, flags) que la vue publique masque.
- **Réflexe de revue** : quand un détail admin réutilise un schéma, vérifier que c'est bien la variante ADMIN (porte le statut/les champs éditoriaux), pas la variante de rendu front.

- Contexte technique : NestJS / Zod / contracts-first — app-alexandrie 04-06-2026
