Clean code et refactoring - 07 - Programmation defensive vs offensive : valider aux frontieres, faire confiance a l'intérieur

Defensive ou offensive ? Valide aux frontieres avec Zod, fais confiance en interne avec des assertions. Le meilleur des deux mondes.

  1. 01 Clean code et refactoring - 00 - Pourquoi le clean code est un investissement, pas un luxe
  2. 02 Clean code et refactoring - 01 - Nommage : la competence la plus sous-estimee
  3. 03 Clean code et refactoring - 02 - Fonctions : courtes, claires, responsables
  4. 04 Clean code et refactoring - 03 - Conditions et lisibilité : sortir de la pyramide
  5. 05 Clean code et refactoring - 04 - Commentaires et documentation : quand le code ne suffit pas
  6. 06 Clean code et refactoring - 05 - Immutabilite et effets de bord : moins de surprises, moins de bugs
  7. 07 Clean code et refactoring - 06 - Gestion des erreurs propre : fail fast, fail loud
  8. 08 Clean code et refactoring - 07 - Programmation defensive vs offensive : valider aux frontieres, faire confiance a l'intérieur
  9. 09 Clean code et refactoring - 08 - SOLID en pratique avec TypeScript
  10. 10 Clean code et refactoring - 09 - DRY, KISS, YAGNI
  11. 11 Clean code et refactoring - 10 - Couplage et cohesion
  12. 12 Clean code et refactoring - 11 - Complexite cyclomatique
  13. 13 Clean code et refactoring - 12 - Abstractions prematurees vs tardives
  14. 14 Clean code et refactoring - 13 - Code smells
  15. 15 Clean code et refactoring - 14 - Techniques de refactoring
  16. 16 Clean code et refactoring - 15 - Refactoring legacy sans tout casser
  17. 17 Clean code et refactoring - 16 - Tests comme filet de sécurité pour le refactoring
  18. 18 Clean code et refactoring - 17 - Structurer un projet — feature-based vs layer-based
  19. 19 Clean code et refactoring - 18 - Constantes, configuration et magic numbers
  20. 20 Clean code et refactoring - 19 - Linting et formatting — ESLint, Biome, automatiser la qualité
  21. 21 Clean code et refactoring - 20 - Conventions d'équipe et ADR
  22. 22 Clean code et refactoring - 21 - Dette technique — quand elle est acceptable, quand elle tue le projet
  23. 23 Clean code et refactoring - 22 - Code review — donner et recevoir du feedback
  24. 24 Clean code et refactoring - 23 - Glossaire — tous les termes de la serie

07 - Programmation defensive vs offensive : valider aux frontieres, faire confiance a l'intérieur

Ce que tu vas apprendre

  • Ce qu'est la programmation defensive (et son coût)
  • Ce qu'est la programmation offensive (assertions, crash early)
  • La regle des frontieres : ou valider et ou faire confiance
  • Le pattern Zod aux frontieres pour valider les donnees externes
  • Les assertions en développement
  • Pourquoi la sur-validation est un problème

Prerequisites

06 - Gestion des erreurs propre


J'ai travaille sur un projet ou chaque fonction validait chaque paramètre. Chaque. Paramètre. La fonction calculateTax verifiait que le montant etait un nombre positif. La fonction applyTax qui l'appelait verifiait aussi que le montant etait un nombre positif. Et le controller qui appelait applyTax verifiait aussi. Le meme check, trois fois.

Le code ressemblait a ca :

typescriptfunction calculateTax(amount: number, rate: number): number {
  if (typeof amount !== "number" || isNaN(amount)) {
    throw new Error("amount must be a number");
  }
  if (amount < 0) throw new Error("amount must be positive");
  if (typeof rate !== "number" || isNaN(rate)) {
    throw new Error("rate must be a number");
  }
  if (rate < 0 || rate > 1) throw new Error("rate must be between 0 and 1");

  return amount * rate;
}

function applyTax(order: Order): Order {
  if (!order) throw new Error("order required");
  if (typeof order.total !== "number") throw new Error("total must be number");
  if (order.total < 0) throw new Error("total must be positive");
  // ... on est encore en train de valider

  const tax = calculateTax(order.total, order.taxRate);
  return { ...order, tax, totalWithTax: order.total + tax };
}

30% du code etait de la validation. Le vrai travail tenait en 2 lignes. Le ratio signal/bruit etait catastrophique. Et le pire : ces validations cachaient les vrais problèmes. Si order.total etait negatif, le vrai bug etait en amont, pas ici.

Programmation defensive : tout vérifier partout

La programmation defensive part d'un principe : ne fais confiance a personne. Chaque fonction vérifié ses entrees, meme si l'appelant est ton propre code.

Les avantages :

  • Chaque fonction est autonome et robuste
  • Les erreurs sont detectees au plus pres de la source
  • Le code fonctionne meme si l'appelant est bugge

Les inconvenients :

  • Code tres verbeux (beaucoup de boilerplate)
  • Performance degradee (checks repetes)
  • Cache les vrais bugs : si une valeur invalide arrive ici, c'est qu'il y a un bug en amont
  • Donne une fausse impression de sécurité

Programmation offensive : crasher tot

La programmation offensive part du principe inverse : si ton propre code t'envoie des donnees invalides, c'est un bug. Et un bug doit crasher, pas etre gere silencieusement.

typescriptfunction calculateTax(amount: number, rate: number): number {
  console.assert(amount >= 0, `Bug: negative amount ${amount}`);
  console.assert(rate >= 0 && rate <= 1, `Bug: invalid rate ${rate}`);

  return amount * rate;
}

Les assert crashent en développement et peuvent etre desactivees en production. Si amount est negatif, c'est que l'appelant a un bug. Le crash force la correction du vrai problème au lieu de le masquer.

La regle des frontieres

La bonne approche combine les deux. Le principe tient en une phrase : valide aux frontieres du système, fais confiance a l'intérieur.

Les frontieres, c'est là où les donnees entrent dans ton système depuis le monde extérieur :

  • Les endpoints API (donnees du client HTTP)
  • Les handlers de formulaires (donnees de l'utilisateur)
  • Les lectures de base de donnees (donnees potentiellement corrompues)
  • Les appels a des API tierces (donnees que tu ne contrôles pas)
  • Les fichiers de configuration (donnees editees a la main)
  • Les variables d'environnement (donnees définies par l'ops)

A l'intérieur du système, entre tes propres fonctions, les donnees sont deja validees. Pas besoin de re-vérifier.

[Monde exterieur] ---> [Frontiere: validation stricte] ---> [Code interne: confiance]
     API client              Zod, validation                    Fonctions pures
     Base de donnees         Type guards                        Calculs metier
     Fichiers config         Parsing                            Transformations

Zod aux frontieres : le pattern qui marche

Zod est une librairie de validation et de parsing pour TypeScript. Elle valide les donnees ET infere les types. C'est l'outil ideal pour les frontieres.

typescriptimport { z } from "zod";

// Schema de validation pour l'API
const CreateOrderSchema = z.object({
  items: z
    .array(
      z.object({
        productId: z.string().uuid(),
        quantity: z.number().int().positive(),
      })
    )
    .min(1, "Au moins un article requis"),
  couponCode: z.string().optional(),
  shippingAddress: z.object({
    street: z.string().min(1),
    city: z.string().min(1),
    zipCode: z.string().regex(/^\d{5}$/),
    country: z.string().length(2),
  }),
});

// Le type est infere automatiquement
type CreateOrderInput = z.infer<typeof CreateOrderSchema>;

Utilisation dans un controller :

typescriptasync function handleCreateOrder(req: Request): Promise<Response> {
  // Frontiere : validation stricte
  const result = CreateOrderSchema.safeParse(req.body);
  if (!result.success) {
    return Response.json(
      { errors: result.error.flatten().fieldErrors },
      { status: 400 }
    );
  }

  // A partir d'ici, result.data est type-safe et valide
  const order = await orderService.create(result.data);
  return Response.json(order, { status: 201 });
}

Apres le safeParse, result.data a le type CreateOrderInput. Plus besoin de vérifier quoi que ce soit dans orderService.create. Les donnees sont propres.

Code interne : faire confiance

Une fois les donnees validees a la frontiere, le code interne les traite sans re-validation :

typescript// Pas de validation ici - les donnees sont deja validees par le controller
function calculateOrderTotal(items: OrderItem[]): number {
  return items.reduce(
    (sum, item) => sum + item.unitPrice * item.quantity,
    0
  );
}

function applyShippingFee(total: number, country: string): number {
  const fee = country === "FR" ? 0 : 15;
  return total + fee;
}

function formatInvoice(order: Order): Invoice {
  return {
    number: generateInvoiceNumber(),
    date: new Date(),
    lines: order.items.map((item) => ({
      description: item.productName,
      quantity: item.quantity,
      unitPrice: item.unitPrice,
      total: item.unitPrice * item.quantity,
    })),
    total: order.totalWithShipping,
  };
}

Pas un seul if (!order) ou if (typeof total !== "number"). Ces fonctions font confiance a leurs appelants. Et c'est normal : elles vivent a l'intérieur du système, pas a la frontiere. On en a parle dans l'article précédent sur la gestion des erreurs.

Assertions en développement

Pour le code interne, les assertions servent de documentation executable. Elles ne remplacent pas la validation aux frontieres. Elles documentent les invariants que le code assume.

typescriptfunction processRefund(order: Order, amount: number): Refund {
  // Ces assertions documentent les preconditions
  // Elles crashent en dev si un bug en amont les viole
  console.assert(order.status === "paid", `Expected paid order, got ${order.status}`);
  console.assert(amount > 0, `Refund amount must be positive: ${amount}`);
  console.assert(
    amount <= order.total,
    `Refund ${amount} exceeds order total ${order.total}`
  );

  return {
    orderId: order.id,
    amount,
    date: new Date(),
    status: "pending",
  };
}

Si ces assertions echouent, c'est un bug dans le code qui appelle processRefund, pas dans processRefund elle-meme. L'assertion indique au développeur exactement quoi corriger.

Pour des assertions plus robustes, tu peux créer un helper :

typescriptfunction invariant(
  condition: unknown,
  message: string
): asserts condition {
  if (!condition) {
    throw new Error(`Invariant violation: ${message}`);
  }
}

// Utilisation - TypeScript comprend le narrowing
function getDiscount(user: User): number {
  invariant(user.plan !== "free", "Free users should not reach getDiscount");

  return user.plan === "premium" ? 0.2 : 0.1;
}

Le asserts condition dit a TypeScript que si la fonction retourne (sans throw), la condition est vraie. Ca combine validation et type narrowing.

Le coût de la sur-validation

La sur-validation n'est pas gratuite :

typescript// 1. Performance : chaque check coute du CPU
function processItems(items: Item[]): Result[] {
  // Ce check est execute 10 000 fois par requete
  if (!Array.isArray(items)) throw new Error("items must be array");
  // ...
}

// 2. Lisibilite : le vrai code est noye dans les checks
function transferMoney(from: Account, to: Account, amount: number) {
  if (!from) throw new Error("from required");
  if (!to) throw new Error("to required");
  if (typeof amount !== "number") throw new Error("amount must be number");
  if (isNaN(amount)) throw new Error("amount must not be NaN");
  if (amount <= 0) throw new Error("amount must be positive");
  if (from.id === to.id) throw new Error("cannot transfer to self");
  if (from.currency !== to.currency) throw new Error("currency mismatch");

  // 7 lignes de validation pour 2 lignes de logique
  from.balance -= amount;
  to.balance += amount;
}

La version avec frontieres :

typescript// Validation a la frontiere (controller)
const TransferSchema = z.object({
  fromAccountId: z.string().uuid(),
  toAccountId: z.string().uuid(),
  amount: z.number().positive(),
}).refine(
  (data) => data.fromAccountId !== data.toAccountId,
  "Cannot transfer to self"
);

// Logique interne : clean
function transferMoney(from: Account, to: Account, amount: number): void {
  if (from.currency !== to.currency) {
    throw new BusinessError("Currency mismatch");
  }
  // La verification de devise est une regle metier, pas de la validation d'input

  from.balance -= amount;
  to.balance += amount;
}

La regle métier (meme devise) reste dans la logique interne. La validation d'input (types, format, non-vide) est a la frontiere. C'est cette séparation qui compte. Je l'applique dans tous mes projets, comme ceux dont je parle sur paltemps.fr.

Résumé en schema

Zone Approche Outil Exemple
Frontiere API Defensive (validation stricte) Zod, express-validator Parser le body HTTP
Frontiere DB Defensive (type guard) Zod, runtime checks Verifier les donnees lues
Code métier interne Offensive (assertions) console.assert, invariant() Documenter les preconditions
Fonctions utilitaires Confiance Types TypeScript Pas de validation runtime

Résumé

  • La programmation defensive valide tout partout : robuste mais verbeux
  • La programmation offensive crashe sur les bugs internes : direct mais rude
  • La bonne approche : validation stricte aux frontieres, confiance a l'intérieur
  • Zod est l'outil ideal pour valider et parser les donnees aux frontieres
  • Les assertions documentent les invariants internes et attrapent les bugs en dev
  • La sur-validation noie le code dans du bruit et cache les vrais problèmes
  • Les regles métier restent dans la logique interne, pas dans la couche de validation

Article précédent : 06 - Gestion des erreurs propre

Article suivant : 08 - SOLID en pratique

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.