Accueil / Articles / Six modèles de conception JavaScript pour échapper au code spaghetti

Six modèles de conception JavaScript pour échapper au code spaghetti

Explique six modèles JavaScript pratiques — Strategy, Factory, Observer, Adapter, Composition et Pipeline — qui remplacent un code enchevêtré par une structure facile à maintenir.

2146 mots

Transformer des scripts chaotiques en systèmes prévisibles et faciles à maintenir

Tout ceux qui ont développé du logiciel depuis suffisamment longtemps reconnaissent ce moment délicat où, des mois après la mise en production d’un projet, on ne parvient plus à comprendre comment les différentes parties sont connectées.

L’effondrement vers le chaos ne commence généralement pas intentionnellement. On ajoute rapidement un mécanisme de basculement pour l’interface utilisateur, puis une fonction de récupération de données, ensuite des traitements pour les cas limites, des indicateurs de chargement, et enfin une requête d’analyse. Quelques semaines plus tard, le script autrefois ordonné s’est transformé en un enchevêtrement de plus de 800 lignes composé de callbacks imbriqués, de variables globales éparpillées et de chaînes fragiles de if/else.

Telle est l’essence du code spaghetti : les règles métier et la logique de présentation sont tellement entremêlées que modifier une partie perturbe silencieusement deux autres parties ailleurs.

Créer du JavaScript évolutif ne signifie pas ajouter systématiquement des abstractions lourdes de style entreprise à chaque fonction. Il s’agit principalement de séparer les préoccupations et de s’appuyer sur un petit nombre de modèles fiables.

Voici six modèles de conception et architecturaux éprouvés qui permettent de simplifier du JavaScript enchevêtré et de garder votre base de code gérable à mesure qu’elle grandit.

1. Modèle de stratégie : Éliminer les conditions imbriquées

Le problème

Lorsque la logique doit prendre des chemins différents en fonction de la catégorie de l’utilisateur, du mode de paiement ou du mode de traitement, la réaction instinctive de nombreux développeurs est d’accumuler des instructions if/else ou des blocs switch encombrants.

// The Spaghetti Way
function calculateShipping(order) {
  if (order.type === 'standard') {
    return order.weight * 1.5;
  } else if (order.type === 'express') {
    return order.weight * 3.0 + 10;
  } else if (order.type === 'overnight') {
    return order.weight * 5.0 + 25;
  } else if (order.type === 'international') {
    return order.weight * 8.0 + 50;
  } else {
    throw new Error('Unknown shipping method');
  }
}

Chaque fois que votre équipe introduit un nouveau niveau de livraison, vous êtes contraint de modifier cette même fonction centrale. Une seule erreur ou un opérateur défectueux ici entraîne l’arrêt des calculs de livraison pour tous les types de commandes en même temps.

La solution

Le pattern de stratégie extrait chaque algorithme dans sa propre fonction autonome et les stocke dans un objet de recherche partagé.

// The Scalable Way
const shippingStrategies = {
  standard: (order) => order.weight * 1.5,
  express: (order) => order.weight * 3.0 + 10,
  overnight: (order) => order.weight * 5.0 + 25,
  international: (order) => order.weight * 8.0 + 50,
};

function calculateShipping(order) {
  const strategy = shippingStrategies[order.type];

  if (!strategy) {
    throw new Error(`Unsupported shipping method: ${order.type}`);
  }

  return strategy(order);
}

Pourquoi cela fonctionne à grande échelle

  • Principe ouvert/fermé : ajouter une douzaine de nouvelles options de livraison signifie simplement ajouter de nouvelles entrées dans shippingStrategies, sans aucune modification nécessaire à l’intérieur de calculateShipping lui-même.
  • Tests plus faciles : chaque fonction de stratégie peut être exportée, analysée et testée complètement indépendamment.

2. Modèles de module et de factory : maintien du contrôle de l’état

Le problème

Les variables globales à portée floue et les objets mutables partagés génèrent des bugs difficiles à détecter. Lorsque plusieurs composants UI peuvent lire et modifier librement le même état, déterminer lequel a corrompu une valeur devient un véritable travail d’enquêteur.

// The Spaghetti Way
let cart = [];
let total = 0;

function addItem(item) {
  cart.push(item);
  total += item.price;
}

function resetCart() {
  cart = [];
  total = 0;
}

Rien n’empêche un script non lié sur la page de mettre cart à null ou de mettre à jour total sans modifier cart en même temps.

La solution

Les closures à l’intérieur des fonctions factory permettent de garder l’état privé, en ne exposant que les opérations spécifiques dont d’autres codes ont réellement besoin, tout en cachant complètement les variables brutes.

// The Scalable Way
function createCart() {
  // Private variables protected inside the closure
  let items = [];

  return {
    addItem(product) {
      if (!product || typeof product.price !== 'number') {
        throw new Error('Invalid product payload');
      }
      items.push({ ...product, id: crypto.randomUUID() });
    },
    removeItem(productId) {
      items = items.filter((item) => item.id !== productId);
    },
    getItems() {
      // Return a shallow copy so external mutations don't alter state
      return [...items];
    },
    getTotal() {
      return items.reduce((sum, item) => sum + item.price, 0);
    },
    clear() {
      items = [];
    }
  };
}

const userCart = createCart();
userCart.addItem({ name: 'Mechanical Keyboard', price: 120 });
console.log(userCart.getTotal()); // 120

Pourquoi cela fonctionne à grande échelle

  • Aucune variable fuite : le code externe ne peut pas écraser directement items — il doit passer par des méthodes publiques validées.
  • Instances multiples sécurisées : chaque appel à createCart() renvoie son propre état indépendant, sans risque qu’une instance modifie l’autre.

3. Modèle d’observateur (Pub/Sub) : assouplir du code fortement couplé

Le problème

Lorsqu’un acheteur clique sur « Passer la commande », plusieurs actions doivent se déclencher en même temps : le panier est vidé, un message de confirmation apparaît, un pixel de suivi est envoyé, et le backend est notifié. Rassembler toute cette logique dans une seule fonction la transforme en un ensemble difficile à gérer.

// The Spaghetti Way
async function handleCheckout(order) {
  await api.submitOrder(order);

  // UI logic mixed directly with tracking and data operations
  document.querySelector('#cart-count').textContent = '0';
  document.querySelector('#modal').classList.add('active');
  analytics.trackPurchase(order);
  notificationSystem.sendPush('Order confirmed');
}

Si le script de suivi génère une erreur ou qu’un élément DOM est renommé, tout le flux de paiement risque d’échouer.

La solution

// The Scalable Way
class EventEmitter {
  constructor() {
    this.events = new Map();
  }

  subscribe(eventName, listener) {
    if (!this.events.has(eventName)) {
      this.events.set(eventName, new Set());
    }
    this.events.get(eventName).add(listener);

    // Return an easy unsubscribe function
    return () => this.events.get(eventName).delete(listener);
  }

  publish(eventName, data) {
    const listeners = this.events.get(eventName);
    if (listeners) {
      listeners.forEach((listener) => {
        try {
          listener(data);
        } catch (err) {
          console.error(`Error executing listener for ${eventName}:`, err);
        }
      });
    }
  }
}

const appBus = new EventEmitter();

// Feature modules register their own behavior
appBus.subscribe('order:placed', (order) => {
  analytics.trackPurchase(order);
});

appBus.subscribe('order:placed', () => {
  document.querySelector('#cart-count').textContent = '0';
});

// The emitter stays minimal and decoupled
async function handleCheckout(order) {
  await api.submitOrder(order);
  appBus.publish('order:placed', order);
}

Pourquoi cela fonctionne à grande échelle

  • Aucune dépendance entre les composants : la fonction de paiement ne sait pas qui s’y abonne. Vous pouvez intégrer de nouvelles fonctionnalités d’analyse, des notifications par e-mail ou des effets graphiques sans jamais modifier handleCheckout.
  • Isolement des pannes : une erreur dans un écouteur ne perturbe pas la fonction qui a déclenché l’événement.

4. Pattern d’adaptation : isoler votre code des dépendances instables

Le problème

Les services externes, les paquets npm et les points de terminaison internes ont tendance à modifier leurs contrats sans avertissement. Si quinze composants distincts récupèrent des données d’utilisateur et que chacun lit directement les champs de réponse bruts, le simple changement de nom d’une propriété — disons user_id en id — déclenche une refonte qui affecte l’ensemble de votre base de code.

// The Spaghetti Way: scattered across multiple UI components
function renderProfile(rawApiResponse) {
  // Directly tied to backend-specific naming conventions
  const name = `${rawApiResponse.first_name} ${rawApiResponse.last_name}`;
  const address = rawApiResponse.shipping_address_line_1;
  const avatar = rawApiResponse.meta_info.profile_image_url;
}

La solution

Insérez une couche d’adaptateur entre la source de données externe et la logique interne de votre application. Convertissez la forme sous laquelle le payload externe arrive en une structure stable et prévisible avant que quoi que ce soit en aval ne la traite.

// The Scalable Way
function userAdapter(externalUser) {
  return {
    id: externalUser.user_id || externalUser.id,
    fullName: `${externalUser.first_name || ''} ${externalUser.last_name || ''}`.trim(),
    address: externalUser.shipping_address_line_1 || externalUser.street || 'N/A',
    avatar: externalUser.meta_info?.profile_image_url || '/assets/default-avatar.png',
  };
}

// Your components only ever consume normalized models
async function getUserProfile(userId) {
  const response = await fetch(`/api/v1/users/${userId}`);
  const rawData = await response.json();
  return userAdapter(rawData);
}

Pourquoi cela fonctionne à grande échelle

  • Un seul endroit à ajuster : si le backend modifie son schéma de réponse la semaine prochaine, vous n’avez qu’à modifier userAdapter une seule fois au lieu de corriger quarante composants qui ne fonctionnent plus.
  • Doubles de test plus simples : les tests UI n’ont besoin de vérifier que la forme normalisée, et non le format externe en perpétuelle évolution.

5. Préférer la composition à l’héritage : assembler des fonctionnalités comme des blocs de construction

Le problème

Les hiérarchies de classes profondes ont tendance à céder sous leur propre complexité. Supposons que vous commenciez avec une classe générique User et que vous créez des sous-classes telles que AdminUser, ModeratorUser et GuestUser. Tout se complique dès que vous avez besoin d’un GuestModerator — quelqu’un qui dispose de quelques pouvoirs de modérateur mais pas du lot complet.

// The Spaghetti Way: Deep Inheritance
class BaseUser {
  login() { /* ... */ }
}

class Admin extends BaseUser {
  deleteContent() { /* ... */ }
  manageBilling() { /* ... */ }
}

// What happens when you need a "BillingAgent" who cannot delete content?

Les longues chaînes d’héritage relient les capacités de manières qui ne correspondent pas à la réalité, et les sous-classes finissent par hériter de méthodes qui n’ont aucun sens pour elles.

La solution

Préférez plutôt la composition d’objets : de petites fonctions de comportement réutilisables (mixins) que vous ajoutez à un objet selon les besoins. Construisez des ensembles de capacités en fonction de ce que quelque chose fait, plutôt que de le forcer dans une hiérarchie rigide is-a.

// The Scalable Way: Composable Behaviors
const canAuthenticate = (state) => ({
  login: () => console.log(`${state.email} logged in`),
  logout: () => console.log(`${state.email} logged out`),
});

const canModerateContent = () => ({
  deletePost: (postId) => console.log(`Post ${postId} deleted`),
  banUser: (userId) => console.log(`User ${userId} banned`),
});

const canManageBilling = () => ({
  processInvoice: (amount) => console.log(`Invoice processed: ${amount}`),
});

// Build specialized actors on demand
function createSupportStaff(email) {
  const state = { email };
  return {
    email,
    ...canAuthenticate(state),
    ...canModerateContent(),
  };
}

function createSuperAdmin(email) {
  const state = { email };
  return {
    email,
    ...canAuthenticate(state),
    ...canModerateContent(),
    ...canManageBilling(),
  };
}

const moderator = createSupportStaff('support@example.com');
moderator.login();
moderator.deletePost(404);
// moderator.processInvoice is undefined - zero privilege leakage

Pourquoi cela fonctionne à grande échelle

  • Aucune hiérarchie à gérer : les comportements sont combinés au fur et à mesure, sans besoin de planifier à l’avance un arbre de classes.
  • Portable entre différents contextes : un comportement comme canAuthenticate fonctionne tout aussi bien sur les comptes clients, les comptes du personnel interne ou les comptes de bots automatisés.
  • 6. Le modèle Pipeline : étapes asynchrones séquentielles

    Le problème

    La chaînage de plusieurs transformations asynchrones aboutit souvent à du code profondément imbriqué et difficile à suivre, mélangeant des aspects non liés entre eux.

    // The Spaghetti Way
    async function handleImageUpload(file) {
      if (file.size > 5000000) {
        throw new Error('Too large');
      }
      const compressed = await compressImage(file);
      const metadata = await extractExif(compressed);
      const tagged = await tagCategories(compressed, metadata);
      const uploadResult = await uploadToS3(tagged);
      return uploadResult;
    }
    

    Cela reste gérable avec trois étapes, mais dès que l’on commence à ajouter du journalage, de la télémétrie, des tentatives répétées et des validations, tout devient difficile à suivre.

    La solution

    Adoptez le pattern pipeline : modélisez chaque transformation comme une petite fonction à vocation unique, et reliez-les de manière à ce que l’ensemble du processus soit clairement compréhensible du début à la fin, de haut en bas ou de gauche à droite.

    // The Scalable Way
    const pipeAsync = (...functions) => (initialValue) =>
      functions.reduce(
        (currentPromise, currentFunction) => currentPromise.then(currentFunction),
        Promise.resolve(initialValue)
      );
    
    // Each step is an isolated, testable transformation
    const validateSize = async (file) => {
      if (file.size > 5 * 1024 * 1024) throw new Error('File exceeds 5MB limit');
      return file;
    };
    
    const compress = async (file) => compressImage(file);
    const attachWatermark = async (image) => applyWatermark(image);
    const upload = async (finalImage) => uploadToCloud(finalImage);
    
    // Create the pipeline
    const processUserImage = pipeAsync(
      validateSize,
      compress,
      attachWatermark,
      upload
    );
    
    // Usage
    processUserImage(rawFileInput)
      .then((res) => console.log('Upload complete:', res))
      .catch((err) => console.error('Pipeline failed:', err.message));
    

    Pourquoi cela fonctionne à grande échelle

    • Réorganisation facile : ajouter, supprimer ou réorganiser une étape — comme insérer une phase de génération de miniature — ne demande presque aucun effort.
    • Débogage étape par étape : vous pouvez insérer une fonction de journalisation simple à n’importe quel point du pipeline pour examiner ce qui entre et ce qui sort.

    Surmonter le code enchevêtré

    Le code ne devient pas chaotique parce que ceux qui le rédigent manquent de compétences. Il devient chaotique parce que les systèmes se développent sous pression temporelle, et les développeurs recourent à la première ligne de code capable de résoudre le problème immédiat le plus rapidement.

    La véritable clé d’une architecture propre n’est pas l’ajout d’un framework d’entreprise lourd. Il s’agit d’appliquer des structures simples et réfléchies là où elles sont nécessaires :

    1. Adapter le modèle au symptôme réel : optez pour la stratégie lorsque les conditions deviennent incontrôlables, utilisez pub/sub lorsque les modules commencent à s’appeler en boucle, et employez un adaptateur lorsque les contrats de tiers menacent de déstabiliser votre interface utilisateur.
    2. Préférer la simplicité à l’ingéniosité : vous n’avez pas besoin de tous les modèles dès le premier jour. Laissez une partie de la logique se répéter deux fois avant de vous donner la peine de l’abstraire lors de la troisième occurrence.
    3. Garantir que les fonctions soient pures et que les interfaces soient bien définies : des entrées et sorties prévisibles rendent les refacturations futures bien moins difficiles.

    Un code propre n’est pas quelque chose que l’on écrit parfaitement d’un seul coup — c’est un code qui reste facile à modifier même six mois plus tard.

    Lectures complémentaires

  • Cursor, Claude Code et Codex : choisir un outil de programmation avec IA pour JS — Cette comparaison explique comment Cursor, Claude Code et Codex s’adaptent à différents workflows en JavaScript, allant de la programmation via un éditeur aux tâches effectuées par des agents autonomes.
  • Dix patterns JavaScript courants et les compromis associés à chacun — Clauses de protection, tableaux de recherche, objets de résultats, AbortController, allSettled, usines de création et petits caches : quand chaque pattern est avantageux et quand il nuit aux résultats.