Clean code & architecture

SOLID en JavaScript : les 5 principes expliqués avec du code

Les cinq principes SOLID appliqués à JavaScript moderne : un exemple avant/après pour chacun, ce qui change sans interfaces ni typage, et les pièges du sur-découpage.

Illustration : cinq blocs alignés, un par principe SOLID
Lexique
SOLID

Acronyme de cinq principes de conception orientée objet (Robert C. Martin) pour un code plus facile à faire évoluer.

SRP — Responsabilité unique

Un module ne doit avoir qu’une seule raison de changer.

OCP — Ouvert/fermé

Un module est ouvert à l’extension mais fermé à la modification : on ajoute du code plutôt que de modifier l’existant.

LSP — Substitution de Liskov

Un sous-type doit pouvoir remplacer son type parent sans casser le comportement attendu.

ISP — Ségrégation des interfaces

Mieux vaut plusieurs petites interfaces spécifiques qu’une grosse interface générale.

DIP — Inversion des dépendances

Le code métier dépend d’abstractions, pas des détails techniques (base, API, framework).

Injection de dépendances

Technique qui fournit à un objet ses dépendances de l’extérieur, au lieu de les créer lui-même.

Duck typing

En JavaScript, un objet est accepté s’il possède les méthodes attendues, sans interface déclarée.

Composition

Assembler des comportements en combinant des objets, plutôt qu’en multipliant les niveaux d’héritage.

Sur-découpage

Piège qui consiste à créer trop d’abstractions pour un besoin simple, au détriment de la lisibilité.

Appliquer SOLID en JavaScript, c’est utiliser cinq principes de conception nés en programmation orientée objet pour écrire du code JS plus simple à tester et à faire évoluer. JavaScript n’a ni interfaces ni classes abstraites au sens de Java, mais les idées restent valables : une responsabilité par module, des extensions sans modification, des contrats respectés, des dépendances injectées.

Voici les cinq principes, chacun avec un exemple avant/après en JavaScript moderne, puis ce qui change par rapport à un langage typé.

S — Responsabilité unique (SRP)

Un module ne doit avoir qu’une seule raison de changer. Le symptôme classique : une fonction qui valide, calcule, enregistre et envoie un email.

// Avant : quatre raisons de changer
async function placeOrder(order) {
  if (!order.items.length) throw new Error('Commande vide');
  const total = order.items.reduce((sum, i) => sum + i.price * i.qty, 0);
  await db.insert('orders', { ...order, total });
  await mailer.send(order.email, `Merci ! Total : ${total} €`);
}
// Après : chaque fonction a un seul rôle
const validateOrder = (order) => {
  if (!order.items.length) throw new Error('Commande vide');
};
const computeTotal = (items) => items.reduce((sum, i) => sum + i.price * i.qty, 0);

async function placeOrder(order, { saveOrder, notifyCustomer }) {
  validateOrder(order);
  const total = computeTotal(order.items);
  await saveOrder({ ...order, total });
  await notifyCustomer(order.email, total);
}

computeTotal se teste désormais sans base de données ni serveur mail.

O — Ouvert/fermé (OCP)

Le code doit être ouvert à l’extension et fermé à la modification. En JavaScript, une table d’objets ou de fonctions remplace avantageusement le switch qui grossit à chaque nouveau cas.

Pour un cas Angular complet, voir le pattern Strategy appliqué aux formulaires dynamiques : ajouter un profil utilisateur revient à ajouter une classe, sans modifier le code existant.

// Avant : chaque nouveau mode de livraison modifie la fonction
function shippingCost(mode, weight) {
  switch (mode) {
    case 'standard': return 4.9;
    case 'express': return 9.9 + weight * 0.5;
    default: throw new Error(`Mode inconnu : ${mode}`);
  }
}
// Après : on ajoute une stratégie sans toucher au reste
const shippingStrategies = {
  standard: () => 4.9,
  express: (weight) => 9.9 + weight * 0.5,
  relay: () => 3.5, // nouveau mode : une ligne ajoutée
};

function shippingCost(mode, weight) {
  const strategy = shippingStrategies[mode];
  if (!strategy) throw new Error(`Mode inconnu : ${mode}`);
  return strategy(weight);
}

C’est le pattern Strategy, sans classe : une simple fonction par stratégie.

L — Substitution de Liskov (LSP)

Un objet doit pouvoir remplacer celui dont il hérite sans casser le code qui l’utilise. Sans compilateur pour vérifier les contrats, la violation passe souvent inaperçue jusqu’à la production.

// Violation : un produit numérique « hérite » d’un produit physique
class Product {
  shippingWeight() { return this.weight; }
}
class Ebook extends Product {
  shippingWeight() { throw new Error('Un e-book ne se livre pas'); }
}

// Le panier plante dès qu’il contient un e-book
const totalWeight = (cart) => cart.reduce((w, p) => w + p.shippingWeight(), 0);
// Correction : ne promettre que ce qui est vrai pour tous
class Product { constructor(name, price) { this.name = name; this.price = price; } }
class PhysicalProduct extends Product {
  constructor(name, price, weight) { super(name, price); this.weight = weight; }
}
class Ebook extends Product {}

const totalWeight = (cart) =>
  cart.filter((p) => p instanceof PhysicalProduct).reduce((w, p) => w + p.weight, 0);

I — Ségrégation des interfaces (ISP)

Aucun client ne doit dépendre de méthodes qu’il n’utilise pas. En JavaScript, l’« interface » est la forme de l’objet attendu : gardez-la petite.

// Avant : chaque moyen de paiement doit tout implémenter
const paypal = {
  charge(amount) { /* ... */ },
  refund(amount) { /* ... */ },
  scheduleRecurring() { throw new Error('Non supporté'); },
};
// Après : des capacités séparées, combinées selon le besoin
const canCharge = (impl) => ({ charge: impl.charge });
const canRefund = (impl) => ({ refund: impl.refund });

const paypal = { ...canCharge(paypalApi), ...canRefund(paypalApi) };
const subscriptionProvider = {
  ...canCharge(stripeApi),
  scheduleRecurring: stripeApi.scheduleRecurring,
};

// Le code appelant ne demande que ce dont il a besoin
async function checkout(payment, amount) {
  return payment.charge(amount);
}

D — Inversion des dépendances (DIP)

Le code métier doit dépendre d’abstractions, pas de détails techniques. En JavaScript, le plus simple est d’injecter les dépendances en paramètre plutôt que de les importer en dur.

Côté backend, l’inversion des dépendances est le cœur de l’architecture hexagonale avec Spring Boot : le domaine définit ses ports, l’infrastructure les implémente.

// Avant : impossible à tester sans vraie base
import { pool } from './db.js';
export async function getInvoice(id) {
  const { rows } = await pool.query('SELECT * FROM invoices WHERE id = $1', [id]);
  return rows[0];
}
// Après : la dépendance est fournie de l’extérieur
export const makeInvoiceService = ({ findInvoiceById }) => ({
  async getInvoice(id) {
    const invoice = await findInvoiceById(id);
    if (!invoice) throw new Error(`Facture ${id} introuvable`);
    return invoice;
  },
});

// En test
const service = makeInvoiceService({ findInvoiceById: async () => null });

Ce qui change en JavaScript par rapport à Java

  • Pas d’interfaces natives : le contrat est implicite (duck typing). TypeScript ou JSDoc rendent ces contrats vérifiables.
  • Les fonctions suffisent souvent : une fonction injectée remplace une interface à une méthode.
  • Les modules ES jouent le rôle de frontières : n’exportez que ce qui fait partie du contrat.
  • Composition plutôt qu’héritage : la plupart des violations de Liskov disparaissent quand on combine des objets au lieu d’empiler des classes.

Les pièges à éviter

SOLID n’est pas une liste à cocher. Découper une fonction de dix lignes en cinq modules ou créer une couche d’abstraction pour une seule implémentation rend le code plus difficile à lire, pas plus propre. Appliquez un principe quand le problème qu’il résout apparaît : une fonction qui change pour plusieurs raisons, un switch qui grossit, un test impossible à écrire.

Pour aller plus loin

Une leçon, un kata, chaque mois

Les nouveaux articles et un exercice de refactoring, directement par e-mail.

S'inscrire à la newsletter