Comment détecter les dépendances cycliques dans votre codebase (avec exemples)
Les dépendances circulaires sont l'un des problèmes les plus insidieux en architecture logicielle. Elles s'infiltrent silencieusement, se cachent derrière des couches d'abstraction, et ne se révèlent que quand votre build casse un vendredi à 17h. À ce moment-là, les démêler ressemble à désamorcer une bombe sans notice.
Ce guide explique ce que sont les dépendances cycliques, pourquoi elles sont bien plus dangereuses que la plupart des développeurs ne le pensent, et comment les détecter avant qu'elles ne deviennent un problème structurel dans votre codebase.
Qu'est-ce qu'une dépendance circulaire ?
Une dépendance circulaire survient lorsque deux modules ou plus dépendent les uns des autres, directement ou indirectement, formant une boucle dans le graphe de dépendances.
Le cas le plus simple est un cycle direct entre deux fichiers :
// userService.ts
import { sendWelcomeEmail } from './emailService';
export function createUser(name: string, email: string) {
const user = { id: generateId(), name, email };
sendWelcomeEmail(user);
return user;
}
export function getUserDisplayName(userId: string): string {
// ...logique de recherche
return 'John Doe';
}
// emailService.ts
import { getUserDisplayName } from './userService';
export function sendWelcomeEmail(user: { id: string; email: string }) {
const displayName = getUserDisplayName(user.id);
// envoi de l'email avec displayName...
}
userService importe depuis emailService, et emailService importe depuis userService. C'est une dépendance circulaire directe. Ça peut fonctionner — Node.js et les bundlers ont des mécanismes pour gérer certains cycles — mais « ça marche » n'est pas synonyme de « c'est correct ».
Les cycles indirects sont plus difficiles à repérer
Le schéma le plus dangereux implique trois modules ou plus :
A → B → C → A
Le module A importe B, B importe C, et C importe A. Aucun fichier individuel ne semble incorrect. Le cycle ne devient visible que lorsqu'on trace la chaîne complète des imports. Dans une codebase de plusieurs centaines de fichiers, ces cycles indirects peuvent traverser cinq, six modules ou plus.
Pourquoi les dépendances circulaires sont dangereuses
1. Erreurs d'exécution et imports indéfinis
En Node.js (CommonJS), lorsqu'une dépendance circulaire est détectée, le runtime retourne un module partiellement chargé. L'import peut donc être undefined au moment où il est utilisé :
// auth.ts
import { db } from './database';
export const AUTH_SECRET = 'my-secret';
export function authenticate(token: string) {
return db.query('SELECT * FROM sessions WHERE token = ?', [token]);
}
// database.ts
import { AUTH_SECRET } from './auth';
console.log(AUTH_SECRET); // undefined ! auth.ts n'a pas fini de se charger
export const db = {
query: (sql: string, params: string[]) => {
// utilise AUTH_SECRET pour la connexion...
}
};
Quand database.ts s'exécute, auth.ts n'a pas terminé son exécution. AUTH_SECRET est undefined. Aucune erreur n'est levée — vous obtenez simplement un bug silencieux et déroutant qui se manifeste par « l'authentification a soudainement cessé de fonctionner ».
Avec les ES modules, le comportement est différent (les imports sont des « live bindings »), mais la confusion et le potentiel de bugs restent identiques.
2. Échecs de build et de bundling
Webpack, Vite, Rollup et les autres bundlers gèrent les dépendances circulaires avec plus ou moins de grâce. Certains émettent des warnings. Certains produisent silencieusement des bundles cassés. Certains plantent carrément pendant le tree-shaking parce qu'ils ne peuvent pas déterminer ce qui est réellement utilisé.
Un symptôme classique : votre app fonctionne en développement (où les modules sont chargés paresseusement) mais plante en production (où le bundler tente de résoudre l'intégralité de l'arbre de dépendances de manière statique).
3. Les tests deviennent un cauchemar
Si le module A dépend de B et B dépend de A, vous ne pouvez tester aucun des deux de manière isolée. Mocker l'un nécessite de charger l'autre, qui tente de charger le premier. Votre setup de test devient un château de cartes :
// Tentative de test de userService...
jest.mock('./emailService'); // emailService importe userService
// Ce qui déclenche le chargement de userService à nouveau
// Qui tente de charger le vrai emailService
// Jest entre dans une boucle récursive de résolution de mocks
C'est pour cette raison que les équipes avec des dépendances circulaires ont souvent une faible couverture de tests — ce n'est pas de la paresse, c'est que l'architecture rend les tests sincèrement difficiles.
4. La paralysie du refactoring
Les dépendances circulaires créent une dynamique du « on ne peut pas bouger une chose sans tout bouger ». Vous voulez extraire userService dans un package séparé ? Impossible, car il est enchevêtré avec emailService. Vous voulez découper votre monolithe en microservices ? Chaque dépendance circulaire est une frontière que vous ne pouvez pas découper proprement.
5. Fuites mémoire dans certains contextes
Dans certains environnements — en particulier les anciens patterns Node.js et certains systèmes de modules — les dépendances circulaires peuvent empêcher le garbage collection. Le module A retient une référence vers B, qui retient une référence vers A. Aucun des deux ne peut être libéré. Dans des processus serveur longue durée, cela entraîne une croissance lente de la mémoire extrêmement difficile à diagnostiquer.
Comment détecter les dépendances circulaires
Détection manuelle : suivre la chaîne des imports
L'approche brutale : ouvrir un fichier, regarder ses imports, ouvrir chaque fichier importé, regarder leurs imports, et répéter jusqu'à trouver une boucle ou épuiser la chaîne. Ça fonctionne pour les petites codebases (moins de 20 fichiers). Au-delà, c'est impraticable.
Le cerveau humain n'est pas optimisé pour le parcours de graphes. Vous raterez les cycles indirects, c'est garanti.
Utiliser ESLint
Le paquet eslint-plugin-import inclut une règle spécifiquement conçue pour les dépendances circulaires :
npm install --save-dev eslint-plugin-import
{
"plugins": ["import"],
"rules": {
"import/no-cycle": ["error", { "maxDepth": 5 }]
}
}
Cela détecte les cycles jusqu'à la profondeur spécifiée. C'est une bonne première ligne de défense, mais avec des limites :
- Performance : Sur les grosses codebases (1000+ fichiers),
import/no-cyclepeut ajouter plusieurs minutes à votre temps de lint. Le paramètremaxDepthexiste précisément pour limiter le coût de calcul. - Imports dynamiques : La règle ne détecte pas les expressions
import()ni les appelsrequire()à l'intérieur des fonctions. - Cross-package : Dans un monorepo, elle n'analyse généralement qu'un seul package à la fois.
Utiliser Madge
Madge est un outil dédié à l'analyse des dépendances :
npx madge --circular --extensions ts src/
Sortie :
Circular dependencies found!
1) src/services/userService.ts → src/services/emailService.ts → src/services/userService.ts
2) src/modules/auth/index.ts → src/modules/database/index.ts → src/modules/auth/index.ts
3) src/utils/logger.ts → src/config/index.ts → src/utils/env.ts → src/utils/logger.ts
Madge peut aussi générer des graphes visuels de dépendances :
npx madge --image graph.svg src/
Cela produit un SVG de votre arbre de dépendances complet, avec les dépendances circulaires mises en surbrillance. Extrêmement utile pour comprendre la structure d'une codebase dont vous avez hérité.
Utiliser dpdm
dpdm est un autre outil CLI axé sur l'analyse des dépendances pour les projets TypeScript :
npx dpdm --circular --exit-code circular:1 src/index.ts
Il est plus rapide que Madge sur les gros projets TypeScript car il utilise la résolution de modules du compilateur TypeScript lui-même.
Intégrer les vérifications dans la CI
L'approche la plus robuste est de vérifier les dépendances circulaires dans votre pipeline CI :
# .github/workflows/ci.yml
jobs:
check-architecture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Vérifier les dépendances circulaires
run: npx madge --circular --extensions ts src/
# Cette commande retourne le code 1 si des cycles sont trouvés
Cela empêche l'introduction de nouvelles dépendances circulaires. Mais ça ne vous aide pas à corriger celles qui existent déjà, et ça ne vous montre pas la vue architecturale d'ensemble.
Visualiser le graphe de dépendances complet
Les outils CLI vous disent qu'un cycle existe. Mais comprendre pourquoi il existe — et comment le corriger sans tout casser — nécessite de voir la vue d'ensemble. Quand le module A dépend de B qui dépend de C qui dépend de A, la solution implique souvent de déplacer une préoccupation partagée dans un nouveau module D dont les trois autres peuvent dépendre.
C'est là que les outils de visualisation deviennent essentiels. Voir votre graphe de dépendances sous forme de vrai graphe — nœuds et arêtes, clusters et éléments isolés — révèle des problèmes structurels invisibles dans la sortie d'un terminal.
Des outils comme ReposLens génèrent des cartes de dépendances interactives directement depuis votre dépôt GitHub. Vous connectez votre repo, et en moins d'une minute vous pouvez voir chaque module, chaque relation d'import et chaque cycle — avec un code couleur et une exploration interactive. Au lieu de lancer des commandes CLI et d'analyser du texte, vous naviguez dans une carte visuelle où les dépendances circulaires sont immédiatement évidentes.
Comment corriger les dépendances circulaires
Une fois les cycles identifiés, voici les schémas de résolution les plus efficaces :
1. Extraire la logique partagée dans un nouveau module
La correction la plus courante. Si userService et emailService ont tous deux besoin de getUserDisplayName, créez un module userHelpers depuis lequel les deux peuvent importer :
Avant : userService ↔ emailService
Après : userService → userHelpers ← emailService
2. Utiliser l'injection de dépendances
Au lieu d'importer directement, passez la dépendance en paramètre :
// emailService.ts — n'importe plus depuis userService
export function sendWelcomeEmail(
user: { id: string; email: string },
getDisplayName: (userId: string) => string
) {
const displayName = getDisplayName(user.id);
// envoi de l'email...
}
// userService.ts
import { sendWelcomeEmail } from './emailService';
export function createUser(name: string, email: string) {
const user = { id: generateId(), name, email };
sendWelcomeEmail(user, getUserDisplayName); // injection de la fonction
return user;
}
Le cycle est brisé. emailService ne connaît plus userService.
3. Utiliser des événements ou un bus de messages
Pour les cas complexes où de nombreux modules doivent communiquer sans dépendances directes :
// eventBus.ts
type EventHandler = (data: unknown) => void;
const handlers = new Map<string, EventHandler[]>();
export function emit(event: string, data: unknown) {
handlers.get(event)?.forEach(handler => handler(data));
}
export function on(event: string, handler: EventHandler) {
if (!handlers.has(event)) handlers.set(event, []);
handlers.get(event)!.push(handler);
}
// userService.ts
import { emit } from './eventBus';
export function createUser(name: string, email: string) {
const user = { id: generateId(), name, email };
emit('user:created', user); // aucune dépendance directe vers emailService
return user;
}
// emailService.ts
import { on } from './eventBus';
on('user:created', (user) => {
// envoi de l'email de bienvenue...
});
Aucun service ne connaît l'autre. Ils communiquent par événements.
4. Appliquer le principe de ségrégation des interfaces
Si deux modules sont fortement couplés parce qu'ils partagent trop de surface, définissez une interface étroite qui capture uniquement le nécessaire :
// types/userLookup.ts
export interface UserLookup {
getDisplayName(userId: string): string;
}
Désormais, emailService dépend de l'interface, pas du userService concret. C'est particulièrement puissant en TypeScript où les interfaces n'ont aucun coût au runtime.
Prévention : des règles d'architecture qui tiennent
Corriger les cycles existants est important. Empêcher les nouveaux l'est encore plus.
Définir des règles de couches
Établissez des frontières claires : services/ peut importer depuis repositories/ et utils/, mais jamais depuis controllers/. controllers/ peut importer depuis services/, mais jamais depuis d'autres controllers/. Écrivez ces règles. Appliquez-les avec de l'outillage.
Imposer les frontières de modules
Dans un monorepo, utilisez des outils comme @nx/enforce-module-boundaries ou créez des règles ESLint personnalisées qui interdisent les imports traversant les frontières de packages.
Ajouter des vérifications architecturales aux revues de PR
Intégrez l'analyse de dépendances dans votre processus de revue de pull requests. Certaines équipes ajoutent des commentaires automatiques sur les PR qui montrent l'impact de la modification sur les dépendances — nouveaux imports ajoutés, modules nouvellement connectés, cycles créés.
ReposLens fournit cela via sa fonctionnalité d'analyse de PR : chaque pull request reçoit une vérification architecturale automatique qui signale les nouvelles dépendances circulaires, le couplage accru ou les violations de frontières avant que le code ne soit mergé.
Surveiller dans la durée
L'architecture se dégrade progressivement. Un seul nouvel import ne semble pas dangereux. Mais après cinquante PR, chacune ajoutant « juste un import de plus », votre architecture propre est devenue un graphe enchevêtré. Suivez vos métriques de dépendances dans le temps — nombre de cycles, couplage moyen, profondeur des dépendances — de la même façon que vous suivez la couverture de tests.
Conclusion
Les dépendances circulaires sont un problème structurel, pas un problème de syntaxe. Les linters ne les détecteront pas toutes. Les revues de code non plus. Vous avez besoin d'une analyse dédiée — automatisée, visuelle et continue.
Commencez par lancer npx madge --circular src/ sur votre codebase aujourd'hui. Vous pourriez être surpris par ce que vous découvrirez. Ensuite, mettez en place un système — que ce soit une vérification CI, un outil de visualisation, ou les deux — pour vous assurer que le nombre diminue, pas l'inverse.
Votre futur vous, en train de debugger un incident de production à minuit, vous remerciera.